Skip to content
DevRecall
← Roadmap
37

Mock: Shipping & Logistics API Integration (FedEx, UPS, USPS, DHL, EasyPost, Shippo)

drafted

Bộ mock theo yêu cầu JD "proven experience integrating FedEx, UPS, USPS, DHL, EasyPost, Shippo" — domain vận chuyển (rate, label, tracking, address, customs, billable weight), cách carrier API và aggregator hoạt động, và cách thiết kế integration chịu lỗi (adapter, idempotency khi mua label, webhook, retry, reconcile, multi-tenant carrier account).

0/47 rated · mastery 0%

Bài học

7 bài · ~64 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~11 phút· 5 câuverify

    Domain vận chuyển: vòng đời shipment, billable weight, label, manifest, void và return

    Nền tảng nghiệp vụ trước khi đụng API: một kiện hàng đi qua những bước nào, carrier tính tiền ra sao (DIM weight, surcharge), label là gì, manifest/pickup, và cách huỷ label hoặc làm return.

  2. 02~10 phút· 8 câuverify

    Carrier API vs aggregator, OAuth và vòng đời API

    Tích hợp trực tiếp FedEx/UPS/USPS/DHL hay qua EasyPost/Shippo, OAuth client credentials và token cache theo account, migrate khỏi legacy API, build vs buy, và cách trả lời câu hỏi 'bạn đã tích hợp carrier nào'.

  3. 03~8 phút· 7 câuverify

    Address validation, rate shopping và lời hứa ngày giao ở checkout

    Chuẩn hoá địa chỉ và residential flag, gọi nhiều carrier song song trong ngân sách 2 giây với timeout và fallback, cache rate đúng key, và xây ngày giao dự kiến (EDD) đáng tin.

  4. 04~9 phút· 5 câuverify

    Mua label an toàn: idempotency, retry, multi-parcel và batch trước cutoff

    Vì sao mua label là thao tác nguy hiểm nhất trong shipping integration, cách thiết kế để timeout không thành tính tiền hai lần, retry đúng loại lỗi, multi-parcel và pipeline batch hàng nghìn label trước giờ cutoff.

  5. 05~8 phút· 6 câuverify

    Tracking: chuẩn hoá status, webhook và polling

    Biến hàng trăm mã scan của carrier thành một state machine đáng tin, nhận webhook at-least-once đúng cách (verify HMAC trên raw body, dedupe, ack nhanh, xử lý async), chống event sai thứ tự, và poller cho kiện im lặng.

  6. 06~8 phút· 8 câuverify

    Kiến trúc multi-carrier: canonical model, adapter, multi-tenant và testing

    Thiết kế để business code không phụ thuộc carrier nào: canonical model, adapter (anti-corruption layer), đơn vị và tiền, canonical error, capability matrix, credential BYOA theo tenant, chiến lược test, và thiết kế hệ thống 100k shipment mỗi ngày.

  7. 07~10 phút· 8 câuverify

    Quốc tế, đối soát chi phí và vận hành shipping

    Customs (HS code, commercial invoice, CN22/CN23), Incoterms DDP và DAP, vì sao quote, label và hoá đơn lệch nhau, đối soát hoá đơn carrier, xử lý sự cố mùa cao điểm, observability và SLO, và cách kể kinh nghiệm integration trong phỏng vấn.

Tóm tắt & cheat sheet của track

TL;DR

  • Một shipping integration làm 5 việc chính: address validation → rate shopping → mua label (label purchase) → tracking → post-shipment (void/refund, return label, reconcile hoá đơn carrier). Customs chỉ cần khi gửi quốc tế. Nói được flow này end-to-end là qua được 50% câu hỏi.
  • Có 2 cách tích hợp: trực tiếp từng carrier (FedEx, UPS, USPS, DHL: mỗi hãng một API, auth, format, mã lỗi khác nhau) hoặc qua aggregator (EasyPost, Shippo, ShipEngine: một API chung cho hàng chục carrier). Trade-off: aggregator rẻ effort, đắt per-label và phụ thuộc vendor; direct thì kiểm soát và giá tốt hơn nhưng phải tự maintain N adapter.
  • Thao tác nguy hiểm nhất là mua label: nó tốn tiền thật và không idempotent phía carrier. Timeout rồi retry mù là bị tính tiền 2 label. Phải dùng idempotency key/reference, ghi trạng thái trước khi gọi, và reconcile.
  • Tracking là event stream không tin cậy: webhook có thể trùng lặp, đến sai thứ tự, hoặc mất. Status phải normalize về một state machine chung, xử lý idempotent theo event id, và có polling làm fallback.
  • Kiến trúc chuẩn: canonical model + carrier adapter (anti-corruption layer), một OAuth token cache cho mỗi carrier account, timeout/retry/circuit breaker cho từng carrier, và queue cho tác vụ nặng (label hàng loạt, tracking sync). Tiền lưu theo minor unit, đơn vị cân/đo phải quy đổi tường minh.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
CarrierHãng vận chuyển thật, cầm hàng đi giaoFedEx, UPS, USPS, DHL Express
Aggregator / multi-carrier APILớp API chung bọc nhiều carrier, có thêm tính năng (address verify, tracker, insurance)EasyPost, Shippo, ShipEngine
Service levelGói dịch vụ của carrier, quyết định tốc độ và giáFEDEX_GROUND, UPS 03 (Ground), USPS Priority Mail
Rate shoppingHỏi giá nhiều carrier/service cho cùng một kiện, rồi chọn theo giá, thời gian hoặc ruleGround $8.20 / 5 ngày vs 2Day $17.40 / 2 ngày
LabelNhãn vận đơn có barcode + tracking number, mua xong thì carrier tính tiềnPDF 4x6, PNG, hoặc ZPL cho máy in nhiệt
Tracking numberMã định danh kiện, dùng để tra trạng thái1Z999AA10123456784 (định dạng UPS)
Tracking eventMột mốc trên hành trình kiện (scan)in_transit tại Memphis TN, 2026-10-14T03:12Z
Address validationChuẩn hoá và kiểm tra địa chỉ có thật/giao được, kèm cờ residential"123 main st apt 4" → 123 MAIN ST APT 4, ZIP+4, residential=true
Actual vs dimensional (DIM) weightDIM weight = thể tích / divisor. Billable weight = max(actual, DIM)Thùng 20×16×12 in, 5 lb → DIM = 3840/139 ≈ 27.6 → tính 28 lb
Surcharge / accessorialPhụ phí ngoài giá gốcResidential, fuel, delivery area, additional handling, signature
Manifest / end-of-day closeChốt danh sách kiện trong ngày gửi cho carrierUSPS SCAN Form, FedEx Ground close
Void / refund labelHuỷ label chưa dùng để lấy lại tiền, có thời hạnEasyPost refund, UPS void shipment
Return labelLabel cho khách gửi trả, thường pay-on-use (chỉ trả tiền khi được scan)Label in sẵn trong hộp hàng
Customs / commercial invoiceKhai hải quan khi gửi quốc tế: mô tả, HS code, giá trị, xuất xứCN22/CN23 cho bưu chính, commercial invoice cho express
Incoterms (DDP/DAP)Ai trả thuế và phí nhập khẩu: người gửi (DDP) hay người nhận (DAP/DDU)DDP: khách không bị thu thêm tiền khi nhận hàng
Carrier account (BYOA)Tài khoản carrier của merchant, có giá đã đàm phán riêngTenant A dùng UPS account riêng, tenant B dùng rate của aggregator
WebhookCarrier hoặc aggregator gọi về endpoint của mình khi có eventtracker.updated từ EasyPost

Cơ chế hoạt động

Vòng đời một shipment end-to-end

Có 3 thời điểm tiền thật bị trừ hoặc điều chỉnh: lúc mua label, lúc carrier audit lại cân/đo (adjustment trên invoice), và khi void/refund. Rate hiển thị ở checkout chỉ là quote; giá thật chốt lúc mua label và có thể khác.

Mua label an toàn khi mạng chập chờn

Tracking: từ event thô về state machine chung

Mỗi carrier có hàng trăm mã scan riêng. Adapter map chúng về khoảng 8–10 status chung. Status chỉ được tiến lên theo thứ tự hợp lệ (event cũ đến muộn không được kéo delivered về in_transit), trừ các nhánh hợp lệ như exception → in_transit.

Kiến trúc tích hợp nhiều carrier

Trade-offs

Quyết địnhLựa chọn ALựa chọn BKhi nào chọn
Cách tích hợpDirect carrier APIAggregator (EasyPost/Shippo)A: volume lớn, ít carrier, cần tính năng sâu (freight, pickup đặc thù), muốn bỏ phí per-label. B: cần nhiều carrier nhanh, team nhỏ, muốn tracking và address verify có sẵn
Rate ở checkoutGọi carrier realtimeBảng giá tĩnh / flat rate / cacheRealtime cho chính xác khi giá biến động. Bảng tĩnh khi cần nhanh và ổn định, chấp nhận chênh lệch. Thực tế: realtime có cache và fallback
Mua labelĐồng bộ trong requestBất đồng bộ qua queueĐồng bộ cho UI "in label ngay" từng kiện. Queue cho batch hàng trăm kiện, có retry và rate limit
TrackingWebhookPollingWebhook nhanh và rẻ nhưng có thể mất hoặc trùng. Polling chắc chắn nhưng tốn quota. Dùng cả hai: webhook chính, poll định kỳ những kiện "im lặng"
Address validationChặn cứng (block checkout)Gợi ý + cho khách xác nhậnGợi ý gần như luôn đúng hơn: validator có false negative (nhà mới, địa chỉ nông thôn, quốc tế)
Carrier accountDùng account của aggregatorBYOA, mỗi tenant tự kết nối accountBYOA khi merchant có giá đàm phán, đổi lại phải lưu credential an toàn và xử lý lỗi auth theo tenant
Label formatPDF/PNGZPLZPL cho máy in nhiệt ở kho (nhanh, sắc nét). PDF cho merchant nhỏ in bằng máy văn phòng

Pitfalls

  • ❌ Retry mua label khi timeout bằng request mới. ✅ Idempotency key/reference cố định, lookup trước khi mua lại, reconcile cuối ngày.
  • ❌ Lưu tiền bằng float, trộn currency. ✅ Integer minor unit (cents) + currency code; giữ nguyên chuỗi số carrier trả về để audit.
  • ❌ Trộn lb/oz/kg, in/cm. ✅ Canonical unit (ví dụ gram + mm) và convert tại adapter; test riêng phần convert.
  • ❌ Tin rate ở checkout bằng giá label. ✅ Re-rate lúc mua, có ngưỡng chênh lệch và cảnh báo; lưu quote kèm thời hạn.
  • ❌ Xin OAuth token mới cho mỗi request. ✅ Cache token theo account, refresh trước khi hết hạn, single-flight để tránh đua.
  • ❌ Map status bằng if rải khắp codebase. ✅ Bảng mapping trong adapter + state machine chỉ cho phép chuyển hợp lệ.
  • ❌ Webhook handler xử lý nặng rồi mới trả 200. ✅ Verify signature → lưu event (dedupe theo id) → trả 2xx nhanh → xử lý async.
  • ❌ Phụ thuộc label_url của carrier/aggregator lâu dài. ✅ Tải file về S3 ngay sau khi mua.
  • ❌ Sandbox chạy ngon là xong. ✅ Sandbox khác production (dữ liệu giả, tracking giả, thiếu service). Có checklist go-live và test với vài label production rồi void.
  • ❌ Log nguyên request/response. ✅ Mask PII (tên, địa chỉ, SĐT) và credential; giữ correlation id và carrier request id.

Cheat sheet

  • Flow: validate address → rate → mua label → manifest/pickup → track → void/return → reconcile.
  • Billable weight = max(actual, DIM); DIM = L×W×H / divisor. Divisor phổ biến: 139 (in³/lb, FedEx/UPS daily rates), 166 (USPS cho kiện lớn), 5000 (cm³/kg, nhiều carrier quốc tế). Divisor có thể khác theo hợp đồng (verify).
  • Auth: FedEx, UPS và USPS API mới đều dùng OAuth 2.0 client credentials (token sống khoảng 1 giờ, verify). EasyPost dùng API key (Basic auth), Shippo dùng ShippoToken.
  • Legacy đã hoặc đang bị khai tử: FedEx SOAP Web Services, UPS access-key auth, USPS Web Tools XML (verify mốc thời gian). Câu "đã migrate lên API mới" là điểm cộng lớn.
  • Mua label = side effect có tiền: idempotency key, trạng thái PENDING trước khi gọi, không retry mù, reconcile.
  • Tracking = at-least-once, out-of-order: dedupe theo event id, chỉ tiến state theo thứ tự, poll fallback cho kiện im lặng.
  • Resilience theo carrier: timeout ngắn ở checkout (khoảng 2–3 s), circuit breaker, fallback rate, rate limit 429 kèm backoff và jitter.
  • Quốc tế: HS code, giá trị khai báo, xuất xứ, Incoterms (DDP/DAP), giấy tờ điện tử (ETD/paperless), hàng cấm và pin lithium.
  • Multi-tenant: credential carrier mã hoá theo tenant, token cache key theo account, rate limit và circuit breaker theo account.
  • Observability: metrics theo carrier × operation (latency, error rate, % fallback), cảnh báo tỉ lệ label fail và kiện kẹt quá N ngày.

Interview Questions

Showing 47 / 47

// 20 x 16 x 12 in, actual 5 lb, divisor 139
const dim = Math.ceil((20 * 16 * 12) / 139); // 3840 / 139 = 27.6 → 28
const billable = Math.max(Math.ceil(5), dim); // 28 lb
console.log({ dim, billable }); // { dim: 28, billable: 28 }
interface RateQuote {
  carrier: 'ups' | 'fedex' | 'usps' | 'dhl';
  provider: 'direct' | 'easypost' | 'shippo';
  service: 'ground' | 'express_2day' | 'overnight' | 'intl_economy';
  amount: number;        // minor units, e.g. 1740 = $17.40
  currency: 'USD' | 'EUR';
  estDeliveryDays?: number;
  providerRateId: string; // needed to buy this exact rate later
  expiresAt: string;      // ISO 8601
}
const results = await Promise.allSettled(
  carriers.map((c) => withTimeout(c.getRates(shipment), 1500)),
);
const rates = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : []));
return rates.length ? applyRules(rates) : fallbackTableRates(shipment);
app.post('/webhooks/easypost', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyHmac(req.body, req.header('x-hmac-signature'), secret)) return res.sendStatus(401);
  const evt = JSON.parse(req.body.toString('utf8'));
  await db.query(
    `insert into webhook_events(provider, event_id, payload) values ('easypost', $1, $2)
     on conflict (provider, event_id) do nothing`, [evt.id, evt]);
  res.sendStatus(200);           // ack fast, process in a worker
});
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
function shouldRetry(op: 'rate' | 'track' | 'buy', status?: number, err?: Error) {
  if (op === 'buy') return false;              // buy goes through lookup + idem key instead
  if (err && (err.name === 'AbortError' || /ECONNRESET|ETIMEDOUT/.test(err.message))) return true;
  return status !== undefined && RETRYABLE.has(status);
}
async function getAllRates(s: Shipment) {
  const all = await Promise.all(
    carriers.map(async (c) => {
      const token = await c.getToken(); // new OAuth token every call
      const res = await fetch(c.ratesUrl, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}` },
        body: JSON.stringify(c.toRequest(s)),
      });
      const data = await res.json();
      return data.rates.map(c.fromRate);
    }),
  );
  return all.flat();
}
function toUpsPackage(p: Parcel) {           // Parcel stores grams and millimeters
  const weightKg = p.weightGrams / 1000;
  return {
    PackageWeight: { UnitOfMeasurement: { Code: 'LBS' }, Weight: String(weightKg) },
    Dimensions: {
      UnitOfMeasurement: { Code: 'IN' },
      Length: String(p.lengthMm / 10), Width: String(p.widthMm / 10), Height: String(p.heightMm / 10),
    },
  };
}
const price = (parseFloat(rate.TotalCharges.MonetaryValue) * 1.1).toFixed(2);
async function onTrackingEvent(evt: NormalizedEvent) {
  const shipment = await repo.findByTracking(evt.trackingNo);
  shipment.status = evt.status;           // whatever arrived last wins
  shipment.lastEventAt = new Date();      // time received, not time scanned
  await repo.save(shipment);
  await notifyCustomer(shipment, evt.status);
}
const cache: Record<string, { token: string; expiresAt: number }> = {};
export async function getToken(carrier: string, creds: Creds) {
  const cached = cache[carrier];
  if (cached && cached.expiresAt > Date.now()) return cached.token;
  const t = await fetchToken(carrier, creds);   // { access_token, expires_in }
  cache[carrier] = { token: t.access_token, expiresAt: Date.now() + t.expires_in * 1000 };
  return t.access_token;
}
app.use(express.json());
app.post('/webhooks/carrier', (req, res) => {
  const expected = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET!)
    .update(JSON.stringify(req.body)).digest('hex');
  if (req.headers['x-hmac-signature'] !== expected) return res.sendStatus(401);
  handle(req.body);
  res.sendStatus(200);
});