3PL / WMS Integration Guide

Building a Modern Fulfillment API

A practical, vendor-neutral blueprint for exposing your warehouse system to ERPs over a clean REST API — the same three flows, done once, that let every current and future trading partner integrate with you.

Prepared by SKU · a guide for 3PL & WMS partners Audience: Engineering & IT leadership Version 1.0 · July 2026

Overview & philosophy

Your warehouse already knows everything an ERP needs: what orders to pick, what stock is on the shelf, and what shipped with which tracking number. An integration API simply exposes those facts over HTTPS in a predictable shape. Get the shape right once and you never build a bespoke integration again — SKU, and every ERP after us, connects the same way.

This guide is written from real integrations. SKU already connects to ShipStation, Starshipit, ShipHero, Shipfusion, Trackstar and Veracore. Every recommendation below is something that made one of those integrations either smooth or painful. We are handing you the smooth path.

The entire surface reduces to three core flows plus a little reference data:

IN  Orders

The ERP pushes a fulfillment order to you: "ship these SKUs to this address." You return your order ID and, eventually, the shipment(s).

OUT  Shipments

The ERP reads back what left the building: tracking numbers, carrier, cost — one entry per parcel. This is the flow that matters most.

OUT  Inventory

The ERP reads current stock per SKU, broken out per warehouse: on hand, available, allocated, incoming.

OUT  Reference data

Two small lists the ERP maps against once: your warehouses and your shipping methods / carrier services.

Build once, connect many

Nothing in this document is SKU-specific. It is the interface any modern ERP or OMS expects from a 3PL. The work you do here is a capability you offer every client — a competitive differentiator, not a one-off favour, and a spec that every future trading partner can integrate against unchanged.

How to read this guide Every capability is tagged. Look for these two badges on each section — and see the full breakdown in § MVP & rollout plan.
Must have
Required to launch a working integration
Nice to have
Optional upside — add it later

§ Design principles

Seven decisions define whether an integration API is a joy or a liability. Make them once, up front, and apply them everywhere.

PrincipleWhat it means in practice
REST + JSON over HTTPSResource-oriented URLs (/orders, /shipments), standard verbs, JSON request/response bodies, TLS only. No SOAP, no flat files, no FTP. Modern clients, libraries, and your own future engineers all speak this natively.
Versioned from day onePut /v1/ in the base path. You will want to change things later without breaking live integrations. A version prefix is the cheapest insurance in software.
Idempotent writesCreating the same order twice must never ship it twice. Accept an Idempotency-Key and return the same order on a retry. (See the note below — this is the single most important guard against duplicate shipments.)
Cursor-based, incremental readsEvery list endpoint accepts an updated_since filter and returns an opaque next_cursor. Clients poll only what changed — not the whole history, every time.
Model reality, not convenienceOne order can ship as many parcels; return one shipment/parcel object per tracking number, never a comma-joined string. One SKU lives in many warehouses; return a per-warehouse breakdown.
Predictable everythingUTC ISO-8601 timestamps, ISO currency codes, a single error envelope, consistent field names. Surprises are where integrations break at 2am.
A real sandboxA non-production environment where a partner can create test orders and see test shipments come back. This alone can cut integration time from weeks to days.
Why idempotency is non-negotiable

It only takes one incident to prove the point: a fulfillment integration that lacks duplicate-shipment guards will eventually double-ship high-ticket items during an upgrade or a network blip. An Idempotency-Key on order creation makes that class of bug impossible — a network retry, a redeployment, or a nervous operator clicking twice all resolve to the one order. Every mature 3PL API has this, whether via an explicit key (Stripe-style) or a stable client reference the server upserts on.

§ The integration lifecycle

End to end, a single order travels one loop. The ERP owns the order; you own the warehouse truth; the two reconcile continuously through polling (and, optionally, webhooks).

ERP / OMS
SKU & future partners
POST /orders
create + idempotency
Your WMS
pick · pack · ship
GET /shipments
tracking · cost · parcels
ERP / OMS
updates the customer
  1. Create. The ERP calls POST /orders with an idempotency key. You persist it, return your real order ID, and the order enters your pick queue.
  2. Fulfil. Your warehouse picks, packs and ships — possibly in multiple parcels, possibly split across days.
  3. Report shipments. The ERP polls GET /shipments?updated_since=… (and/or you fire a shipment.created webhook). It records each parcel's tracking number and passes it to the sales channel and the end customer.
  4. Reconcile inventory. In parallel, the ERP polls GET /inventory?updated_since=… to keep stock counts aligned across channels.
  5. Amend or cancel. Before a pick, the ERP can PATCH /orders/{id} to push an address fix or line change — same order ID — or POST /orders/{id}/cancel. Once picking starts, both return a clean 409 so the ERP knows to reconcile rather than assume.
The one-to-many rule

Keep orders and shipments as separate resources with a one-to-many relationship. An order is the intent; a shipment is a physical box that actually left, with its own tracking number. This is the single most important modeling decision in the whole API — it makes efficient polling possible and it mirrors what actually happens on your floor.

§ Authentication & environments

Per-partner bearer tokens over TLS. Simple, standard, revocable.

Issue each integration partner an API key (ideally a key/secret pair they can rotate). The client sends it on every request as a bearer token:

HTTP request headers
Authorization: Bearer wms_live_8f2a…c91
Content-Type: application/json
Idempotency-Key: a1b2c3d4-…-e5f6   # on POST /orders only

Two environments, identical shapes

EnvironmentBase URL (illustrative)Purpose
Sandboxhttps://api.sandbox.your-3pl.com/v1Partner testing. Accepts real order shapes, returns realistic shipments, never touches a real carrier. Ship this first.
Productionhttps://api.your-3pl.com/v1Live traffic. Same request/response contract as sandbox, byte for byte.
Recommendation

Keep sandbox and production behaviourally identical. The most common integration delay we see is a sandbox that diverges from production (different fields, different statuses). If a partner's code works in sandbox, it must work in production unchanged.

§ Global conventions

Apply these uniformly to every endpoint. Consistency is what lets a partner write one HTTP client and reuse it across all your resources.

Pagination — opaque cursors

Prefer an opaque cursor over page numbers: it survives inserts, doesn't skip or duplicate rows under concurrent writes, and scales to any window size. Every list response carries the same envelope:

List response envelope
{
  "data": [ /* resource objects */ ],
  "pagination": {
    "next_cursor": "eyJpZCI6MTA0MjB9",   # null when no more pages
    "has_more": true
  }
}

The client passes ?cursor=eyJpZCI6MTA0MjB9 back to fetch the next page, and stops when has_more is false.

Timestamps & money

ConceptFormatExample
Date-timeISO-8601, always UTC, with Z2026-07-02T14:31:07Z
Date onlyISO-8601 date2026-07-02
MoneyDecimal string + ISO-4217 currency"amount": "19.99", "currency": "AUD"
Weight / dimensionsNumber + explicit unit"weight": {"value": 1.2, "unit": "kg"}
Avoid

Local time without an offset (we've chased shipment-date bugs caused by ambiguous timezones), floating-point money, and units implied by convention rather than stated in the payload.

Errors — one envelope, correct status codes

Return the right HTTP status and a machine-readable body. A partner should be able to branch on error.code without string-matching a message.

Error response · HTTP 422
{
  "error": {
    "code": "invalid_warehouse",
    "message": "warehouse_id 'wh_zzz' is not a known warehouse.",
    "field": "warehouse_id",
    "request_id": "req_01J8…"          # echo back for support
  }
}
StatusWhen
200 / 201Success. 201 for a created order.
400 / 422Malformed or invalid request (bad address, unknown SKU, unmapped warehouse).
401 / 403Missing/invalid token; token lacks permission.
404Unknown order/shipment ID.
409Conflict — e.g. cancelling an order that already shipped. Use this deliberately; it lets the ERP react correctly.
429Rate limited. Include Retry-After.
Critical

Never return HTTP 200 with an error buried in the body. We have been burned by APIs that "200-with-error" — the client thinks it succeeded, the order silently never ships. The status code is the contract.

Rate limits

Advertise your limits in headers so well-behaved clients self-throttle:

Rate-limit headers
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1782043260       # unix epoch
Retry-After: 30                   # only on a 429

1 · Orders — inbound (ERP → you) Must have

The ERP creates a fulfillment order: who to ship to, what to ship, from which warehouse, by which method. You accept it, assign your order ID, and own it through to shipment.

POST/v1/ordersERP → you · idempotent

Create a fulfillment order. The client sends an Idempotency-Key header; if you've seen it before, return the existing order (HTTP 200) instead of creating a second.

Request body — POST /v1/orders
{
  "order_number": "SO-100423",          # the ERP's human-facing order #
  "reference": "F-100423-1",            # ERP's unique fulfillment ref (dedup anchor)
  "order_date": "2026-07-02T09:15:00Z",
  "warehouse_id": "wh_syd_1",         # one of YOUR warehouse ids (§4)
  "shipping_method_id": "sm_auspost_express", # or "shipping_method": "Express"
  "signature_required": false,
  "insurance": false,
  "gift_message": null,
  "packing_note": "Leave in porch",
  "ship_to": {
    "name": "Jane Doe",
    "company": "Acme Pty Ltd",
    "email": "[email protected]",
    "phone": "+61 400 123 456",
    "address1": "123 Main St",
    "address2": null,
    "city": "Sydney",
    "state": "NSW",
    "postcode": "2000",
    "country": "AU"                   # ISO-3166 alpha-2
  },
  "items": [
    {
      "sku": "WIDGET-BLUE-M",
      "description": "Blue Widget — Medium",
      "quantity": 2,
      "unit_price": "19.99",
      "currency": "AUD"
    }
  }]
}

Request fields

FieldTypeNotes
order_numberstringreqThe ERP's order number. Store it — the ERP will search on it.
referencestringreqThe ERP's unique fulfillment reference. Combine with the idempotency key as your dedup anchor.
warehouse_idstringreqOne of your warehouse IDs from GET /warehouses.
shipping_method_id
or shipping_method
stringrecPrefer a mapped method ID; accept a free-text name as a fallback.
ship_toobjectreqFull destination address. Return a 422 with the offending field if it's undeliverable.
items[]arrayreqsku + quantity required; unit_price/currency recommended for customs & value.
signature_required, insurance, gift_message, packing_notemixedoptHandling flags. Omit rather than send empty when unused.

Response — 201 Created

Response body
{
  "id": "ord_8Kd92m",            # YOUR order id — the ERP stores this forever
  "order_number": "SO-100423",
  "reference": "F-100423-1",
  "status": "received",
  "warehouse_id": "wh_syd_1",
  "created_at": "2026-07-02T09:15:04Z",
  "shipments": []                  # populated as parcels ship
}
The golden rule of order IDs

Return your real, permanent order ID in the create response — the same ID you'll use in shipment payloads, webhooks, and the cancel endpoint. Never return a placeholder or a value that changes later. Every reconciliation, cancellation, and support lookup the ERP ever does hangs off this ID.

GET/v1/orders/{id}ERP → you

Retrieve a single order with its current status and any shipments so far. Also support GET /v1/orders?updated_since=…&cursor=… for bulk reconciliation.

POST/v1/orders/{id}/cancelERP → you

Cancel an order that hasn't shipped. Return 200 with "status": "cancelled" if you caught it in time, or 409 Conflict if it already shipped — so the ERP knows to stop and reconcile rather than assume success.

200 — cancelled in time

{ "id": "ord_8Kd92m",
  "status": "cancelled" }

409 — too late

{ "error": {
   "code": "already_shipped",
   "message": "Order has shipped." }}
PATCH/v1/orders/{id}ERP → you · amend before pick

Order details change constantly — a customer corrects their address, adds an item, or upgrades to express after the order is placed but before the box is packed. A single amend endpoint lets the ERP push only what changed and keep the same order ID, instead of cancelling and re-creating. Send just the changed fields; for line items, send the full desired items[] set — it replaces the existing lines (so removing a line = omit it, reduce a quantity = send the new number).

Request body — PATCH /v1/orders/{id}
{
  "shipping_method_id": "sm_auspost_express",   # upgraded to express
  "ship_to": {
    "name": "Jane Doe",
    "address1": "456 New Street",          # corrected address
    "address2": "Unit 5",
    "city": "Sydney", "state": "NSW",
    "postcode": "2010", "country": "AU"
  },
  "items": [                               # full desired line set — replaces existing
    { "sku": "WIDGET-BLUE-M", "quantity": 1 },   # qty 2 → 1
    { "sku": "WIDGET-RED-S",  "quantity": 3 }    # new line added
  ]
}

Include an If-Match: "<version>" header carrying the order's current version so two concurrent edits can't silently clobber each other — return 409 if the version has already moved on. Fields you don't send are left untouched.

What can be amended — while the order hasn't been picked

ChangeHow
Shipping addressSend a new ship_toyes
Shipping method / service upgradeSend a new shipping_method_idyes
Add / remove a line, change quantitySend the full desired items[]yes
Handling flags (signature, gift message, packing note)Send the changed field(s)yes
Source warehousePrefer cancel + recreate (it forces re-allocation)avoid

Response — 200 OK if in time, 409 if too late

200 — amended

{ "id": "ord_8Kd92m",
  "status": "received",
  "version": "4",            # bumped
  "updated_at": "2026-07-02T10:02:00Z" }

409 — already being picked

{ "error": {
   "code": "too_late_to_amend",
   "message": "Order is being picked; cancel to change it.",
   "order_status": "processing" }}
Why a real amend endpoint — not cancel-and-recreate

Without PATCH, every address typo or last-minute line change forces the ERP to cancel the order and create a brand new one. That changes the order ID (breaking every downstream reference the ERP, the sales channel and the customer already hold), risks a double-ship if the cancel races the pick, and churns your warehouse queue with cancel/re-add noise. A PATCH the warehouse applies before picking keeps one order, one ID, one clean audit trail — and once picking starts, the honest 409 tells the ERP to fall back to cancel-and-recreate deliberately. This is the streamlined change path operations teams actually need, which is why we treat it as a launch requirement.

2 · Shipments — outbound (you → ERP) Must have

This is the flow that matters most, and the one most APIs get wrong. When goods leave your dock, the ERP needs the tracking number(s) — fast, cheaply, and one entry per parcel. Model this well and everything downstream (customer notifications, channel updates, cost reconciliation) just works.

GET/v1/shipments?updated_since={ts}&cursor={c}you → ERP · the key endpoint

Return shipments created or updated since a timestamp, newest changes last, in stable order, paginated by cursor. A shipment belongs to an order and carries one tracking number per physical parcel.

Response — GET /v1/shipments
{
  "data": [
    {
      "id": "shp_a1B2c3",
      "order_id": "ord_8Kd92m",           # the order this fulfils
      "order_number": "SO-100423",
      "status": "shipped",
      "warehouse_id": "wh_syd_1",
      "shipped_at": "2026-07-02T22:04:11Z",
      "created_at": "2026-07-02T22:04:11Z",
      "updated_at": "2026-07-02T22:04:11Z",
      "cost": { "amount": "12.40", "currency": "AUD" },
      "voided": false,
      "parcels": [                          # ← one entry per physical box
        {
          "tracking_number": "ABC123456789AU",
          "tracking_url": "https://auspost.com.au/track/ABC123456789AU",
          "carrier": "auspost",
          "service": "Express Post",
          "weight": { "value": 1.2, "unit": "kg" },
          "dimensions": { "length": 30, "width": 20, "height": 10, "unit": "cm" }
        },
        {
          "tracking_number": "ABC123456790AU",       # same order, 2nd box, own tracking #
          "tracking_url": "https://auspost.com.au/track/ABC123456790AU",
          "carrier": "auspost",
          "service": "Express Post"
        }
      ],
      "items": [                            # what physically shipped, for per-line fulfilment
        { "sku": "WIDGET-BLUE-M", "quantity": 2 }
      ]
    }
  ],
  "pagination": { "next_cursor": null, "has_more": false }
}

Shipment fields

FieldTypeNotes
idstringreqYour shipment ID — stable, unique. The ERP's dedup key.
order_id / order_numberstringreqLinks back to the order. Include both.
statusenumreqshipped, later in_transit, delivered, voided (see appendix).
shipped_atdatetimereqWhen it left. UTC.
costmoneyrecActual freight cost. The ERP folds this into landed cost — omitting it understates margin.
parcels[]arrayreqOne object per box, each with its own tracking_number, carrier, service, optional weight/dims.
items[]arrayrecWhat shipped (sku + quantity). Lets the ERP mark per-line fulfilment without a second call.
voidedboolrecTrue if the label was voided. Don't delete the row — flag it, so the ERP can reconcile.
Why "one parcel per tracking number" — not a joined string

An order that ships in two boxes has two tracking numbers. If you return "tracking": "ABC…AU, ABC…AU" as one string, every consumer has to parse it, the customer gets one confusing notification, and per-parcel weight/cost is lost. Returning a parcels[] array is barely more work for you and saves every partner from writing fragile splitting code. We model shipments exactly this way internally — an array is the shape that fits.

Why shipments are separate from orders

Because it makes polling cheap. If shipment data is only reachable by re-fetching each order, the ERP has to re-scan every open order forever, asking "did anything change?". With a dedicated GET /shipments?updated_since=…, the ERP says "give me everything that shipped since 22:04" and gets exactly that — a bounded, incremental query. That is why the separate resource matters.

3 · Inventory — outbound (you → ERP) Must have

Stock the ERP can trust, broken out per warehouse. A SKU isn't a single number — it's on the shelf in Sydney and Melbourne in different quantities, some available, some already allocated to open orders.

GET/v1/inventory?updated_since={ts}&cursor={c}you → ERP

Return stock levels per SKU. Each SKU carries a warehouses[] breakdown so the ERP can decide sourcing and surface accurate availability per channel.

Response — GET /v1/inventory
{
  "data": [
    {
      "sku": "WIDGET-BLUE-M",
      "updated_at": "2026-07-02T22:05:00Z",
      "warehouses": [                     # ← per-warehouse breakout
        {
          "warehouse_id": "wh_syd_1",
          "on_hand": 120,              # physically present
          "available": 96,             # sellable now (on_hand − allocated)
          "allocated": 24,             # reserved for open orders
          "incoming": 0,               # inbound / on a PO to you
          "backordered": 0
        },
        {
          "warehouse_id": "wh_mel_1",
          "on_hand": 40, "available": 40, "allocated": 0, "incoming": 30, "backordered": 5
        }
      ]
    }
  ],
  "pagination": { "next_cursor": null, "has_more": false }
}
QuantityDefinition
on_handPhysically in the warehouse right now.
availableSellable — on_hand minus what's already allocated to open orders. This is the number the ERP shows as "in stock".
allocatedReserved for orders received but not yet shipped.
incomingInbound to you (on a purchase order / ASN). Optional but valuable for planning.
backorderedDemand exceeding stock. Optional.
At minimum

If you can only expose two numbers per warehouse, make them on_hand and available. Everything else is upside. Support ?sku= and ?warehouse_id= filters so the ERP can spot-check a single item cheaply.

4 · Warehouses & shipping methods Must have

Two small, slow-changing lists the ERP fetches occasionally and maps against once. They let orders reference stable IDs instead of guessing at free-text.

GET/v1/warehouses
Response
{ "data": [{
  "id": "wh_syd_1",
  "name": "Sydney DC",
  "code": "SYD",
  "address": {
    "address1": "1 Freight Rd",
    "city": "Sydney", "state": "NSW",
    "postcode": "2000", "country": "AU"
  }
}]}
GET/v1/shipping-methods
Response
{ "data": [{
  "id": "sm_auspost_express",
  "name": "Express Post",
  "carrier": "auspost",
  "carrier_name": "Australia Post",
  "service_code": "EXP"
},{
  "id": "sm_auspost_parcel",
  "name": "Parcel Post",
  "carrier": "auspost", "service_code": "PP"
}]}

The ERP maps its own warehouses and shipping options to these IDs once, then references them on every order. Stable IDs here save a class of "unknown method" errors later.

5 · Advanced flows — the full inventory lifecycle Nice to have

The three core flows cover the order → ship → stock loop. As the integration matures, these let the ERP manage the whole inventory lifecycle through you — receiving inbound stock, reconciling adjustments, and handling returns. Each reuses the exact same conventions (updated_since polling, cursors, UTC, one error envelope), so they're incremental to build and none is required to launch.

Nice to have  5a · Inbound shipments & ASNs — ERP → you, then you → ERP

The ERP notifies you that stock is on its way (an Advanced Shipment Notice); you receive it and report what actually arrived — including over-, under- and damaged-delivery discrepancies. This closes the loop on replenishment.

POST/v1/inbound-shipmentsERP → you · create ASN
Request — expected stock
{
  "reference": "ASN-5567",            # ERP's PO / ASN reference
  "warehouse_id": "wh_syd_1",
  "expected_at": "2026-07-08",
  "supplier": "Acme Manufacturing",
  "items": [
    { "sku": "WIDGET-BLUE-M", "expected_quantity": 500 },
    { "sku": "WIDGET-RED-S",  "expected_quantity": 300 }
  ]
}
# → { "id": "ib_9932", "status": "expected" }
GET/v1/inbound-shipments?updated_since={ts}you → ERP · poll receipts

Once you receive the goods, the record updates with the received quantities and any discrepancy:

Response — what actually arrived
{ "data": [{
  "id": "ib_9932",
  "reference": "ASN-5567",
  "status": "received",            # expected → partially_received → received
  "received_at": "2026-07-08T03:12:00Z",
  "warehouse_id": "wh_syd_1",
  "items": [
    { "sku": "WIDGET-BLUE-M", "expected_quantity": 500, "received_quantity": 498, "discrepancy": -2 },
    { "sku": "WIDGET-RED-S",  "expected_quantity": 300, "received_quantity": 300, "discrepancy": 0 }
  ]
}]}
Report discrepancies explicitly

The signed discrepancy is the whole point — the ERP reconciles the purchase order against what physically landed, not just "it arrived." Received stock also flows into the §3 inventory on_hand, so the two stay consistent.

Nice to have  5b · Inventory adjustments — you → ERP

Not every stock change comes from an order. Damage, cycle-count corrections, found or lost stock — the ERP needs the reason a number moved, not just the new total, to keep its own ledger honest and to audit shrinkage.

GET/v1/inventory-adjustments?updated_since={ts}you → ERP
Response — signed deltas with a reason
{ "data": [{
  "id": "adj_4471",
  "sku": "WIDGET-BLUE-M",
  "warehouse_id": "wh_syd_1",
  "quantity_delta": -3,              # signed: negative = removed
  "reason": "damaged",              # damaged | cycle_count | lost | found | expired
  "note": "Water damage, pallet 12",
  "adjusted_at": "2026-07-02T05:00:00Z"
}]}
A delta + reason beats a new absolute number

Sending a signed quantity_delta with a reason code lets the ERP attribute the movement (and total up shrinkage by cause) rather than guessing why a count changed. Pair with the optional inventory.adjusted webhook for near-real-time reconciliation.

Nice to have  5c · Customer returns / RMAs — you → ERP (optionally ERP → you)

When goods come back to your warehouse, the ERP needs to know what returned, in what condition, and whether it went back on the shelf — so it can refund, restock, or write off. Optionally, the ERP pre-authorizes an expected return via POST /v1/returns (an RMA); either way you report what you receive.

GET/v1/returns?updated_since={ts}you → ERP
Response — returns received, per-item condition
{ "data": [{
  "id": "ret_2201",
  "order_id": "ord_8Kd92m",          # the original order
  "order_number": "SO-100423",
  "rma": "RMA-100423",
  "status": "received",            # expected → received → processed
  "received_at": "2026-07-10T22:00:00Z",
  "warehouse_id": "wh_syd_1",
  "items": [
    { "sku": "WIDGET-BLUE-M", "quantity": 1, "condition": "resellable", "restocked": true },
    { "sku": "WIDGET-RED-S",  "quantity": 1, "condition": "damaged",    "restocked": false }
  ]
}]}
Per-item condition drives the refund decision

A condition + restocked flag per line lets the ERP decide refund vs. write-off and keeps availability accurate — a resellable restock feeds straight back into §3 inventory available. Pair with the optional return.received webhook.

Advanced-flow endpoints at a glance

FlowEndpointDirectionOptional webhook
Inbound / ASNPOST /inbound-shipments · GET /inbound-shipmentsERP → you, then you → ERPinbound.received
Inventory adjustmentsGET /inventory-adjustmentsyou → ERPinventory.adjusted
Customer returnsGET /returns · POST /returns (RMA)you → ERP (opt. ERP → you)return.received

§ Efficient incremental polling Must have

Polling done right is quiet and cheap. The pattern is a high-water mark: the client remembers the last change it saw and asks only for what's newer.

The loop

  1. Client stores a cursor/timestamp of the last shipment it processed — say 2026-07-02T22:04:11Z.
  2. It calls GET /shipments?updated_since=2026-07-02T22:04:11Z.
  3. It walks pages via next_cursor until has_more is false.
  4. It advances its high-water mark to the newest updated_at it saw, and sleeps until the next tick.
Make this possible — three requirements

1. An updated_since (or created_since) filter on /shipments, /orders and /inventory.  2. A stable sort (by updated_at ascending) so paging never skips or repeats a row.  3. An opaque next_cursor. With these three, a partner writes one poller and it scales to any volume.

The anti-pattern we want to avoid

Two of the systems we integrate with have no incremental filter on their shipment list. The result: to stay current, the client must re-walk the entire order history on every cycle, or lean entirely on webhooks with no reliable backstop. It's slow, it's expensive, and it doesn't scale as your client base grows. A single updated_since parameter is the difference. Please include it.

Recommended default cadence: poll /shipments every 2–5 minutes, /inventory every 15–30 minutes. If you add webhooks (next section), these polls become a cheap safety net rather than the primary mechanism.

§ Webhooks — optional, recommended Nice to have

Webhooks turn minutes of polling latency into seconds by pushing events to the ERP as they happen. They are an optimisation on top of polling — never a replacement for it. Build the polling API first; add webhooks when you're ready.

Event catalog

EventFires whenPayload
shipment.createdA parcel ships (the important one)The full shipment object from §2
shipment.updatedTracking status changes; label voidedThe updated shipment object
order.cancelledAn order is cancelled at your endThe order object
inventory.updatedStock crosses a threshold (optional)The SKU inventory object from §3
inbound.receivedAn inbound shipment / ASN is receivedThe inbound-shipment object from §5a
inventory.adjustedStock is adjusted outside an orderThe adjustment object from §5b
return.receivedA customer return arrives at the warehouseThe return object from §5c
Send the full object

Put the complete resource in the webhook body (as above), not just an ID. A "thin" webhook that forces the client to call back for the data doubles the round-trips and the failure modes. We consume both styles today; full-payload is markedly simpler for everyone.

Delivery payload

POST to the partner's registered URL
{
  "event_id": "evt_01J8XQ…",       # unique & stable — the client's dedup key
  "event_type": "shipment.created",
  "created_at": "2026-07-02T22:04:12Z",
  "data": { /* the full shipment object from §2 */ }
}

Signing — HMAC-SHA256 over the raw body

Sign every webhook so the ERP can prove it came from you and wasn't tampered with. Send a signature header computed over the exact raw request bytes plus a timestamp:

Signature header
X-Webhook-Signature: t=1782043452,v1=5d41402abc4b2a76b9719d911017c592…

# where v1 = hex( HMAC_SHA256( secret, "{t}.{raw_request_body}" ) )
Two rules that prevent every webhook-security bug we've hit

Sign the raw bytes, verified as received — not a re-serialized copy of the parsed JSON. Re-encoding reorders keys and re-escapes characters, so the client's recomputed HMAC won't match and every delivery 401s. Include the timestamp t in the signed string and let clients reject deliveries older than ~5 minutes — that stops replay attacks. This is the exact scheme mature providers use, and it verifies cleanly on our side.

Reliability — retries & idempotency

  • Retry with backoff on any non-2xx (e.g. after 10s, 1m, 5m, 30m, then hourly for a day). Networks blip; a single delivery attempt loses events.
  • Stable event_id so the client can dedup — retries and at-least-once delivery mean the same event can arrive twice. The client stores seen IDs; you just keep the ID constant across retries.
  • Deliveries can arrive out of order. The client should treat webhooks as "something changed, here's the latest state," and always be able to fall back to a poll.

Subscription management

Let a partner register their endpoint via API (or your dashboard). Keep it minimal:

POST /v1/webhooks
{
  "url": "https://erp.example.com/webhooks/wms/wh_9f2a…",
  "events": [ "shipment.created", "shipment.updated", "order.cancelled" ]
}
# → returns { "id": "whk_…", "secret": "whsec_…" }  ← the HMAC secret, shown once

Pair with GET /v1/webhooks and DELETE /v1/webhooks/{id}. Generate the signing secret server-side and reveal it once on creation.

§ MVP & rollout plan

You don't need all of this to go live. Here's the smallest surface that unblocks a full integration — then the polish, in priority order.

Must have

Phase 1 · Launch

The minimum surface that lets any ERP run a full, safe integration. Ship all of this and you're live.
  • POST /ordersCreate a fulfillment orderWith an Idempotency-Key that returns the same order on retry, and a real order ID in the response.
  • GET /orders/{id}Read an order's status & shipmentsPlus GET /orders?updated_since=… for bulk reconciliation.
  • POST /orders/{id}/cancelCancel before shipReturns 409 if it already shipped, so the ERP reconciles instead of assuming.
  • PATCH /orders/{id}Amend an order before pickPush address fixes, line/quantity changes and method upgrades in one call — same order ID. Returns 409 once picking starts. Order changes are routine; a streamlined amend path is essential.
  • GET /shipmentsThe key endpointCursor-paginated, updated_since filter, parcels[] with one tracking number each, shipping cost, shipped line items.
  • GET /inventoryPer-warehouse stockon_hand + available at minimum, with an updated_since filter.
  • GET /warehouses · /shipping-methodsThe two mapping listsStable IDs the ERP references on every order.
  • sandboxA working test environmentWhere a partner can create a test order and poll a test shipment back — identical shapes to production.
Nice to have

Phase 2 · Later

Pure upside — lower latency and less ops load. None of it blocks an integration; add it once Phase 1 is solid.
  • webhooksPush events instead of pollingshipment.created first — HMAC-signed over raw bytes, with retries and a stable event_id. Layered on top of polling, never instead of it.
  • GET /inventoryRicher inventory fieldsallocated, incoming, backordered — beyond the required two.
  • GET /shipmentsDelivery status transitionsin_transitdelivered surfaced via shipment.updated.
  • inbound-shipmentsInbound receiving & ASNsExpected-vs-received reconciliation on replenishment. §5a
  • inventory-adjustmentsInventory adjustmentsSigned deltas + reason codes for damage, cycle counts, shrinkage. §5b
  • returnsCustomer returns / RMAsPer-item condition & restock flag for refund-or-write-off. §5c
Read this split as priorities, not phases you must finish in order

Everything in the Must have column is genuinely required for a working integration — skip any one and the loop breaks (no idempotency → double-ships; no updated_since → polling doesn't scale; no per-parcel tracking → broken customer notifications). Everything in the Nice to have column is real value but strictly optional — a partner can integrate fully without a single item from it. Build left column first, in full; reach for the right column when you're ready.

How to work with an integrating ERP

The fastest path is a short discovery call, a sandbox to scope the integration against, and fast, specific feedback on the contract as it firms up. An ERP partner (SKU included) will often contribute engineering time alongside your IT team where it accelerates delivery — the goal is a clean, modern interface that serves every one of your clients for years, not a one-off. Small, quick feedback loops keep the build off your launch critical path.

§ Appendix — status vocabularies

Small, closed enumerations the ERP can map with confidence. Pick names and don't change them.

Order status

receivedAccepted, queued
processingBeing picked/packed
partially_shippedSome parcels out
shippedFully shipped
on_holdPaused (stock, address)
backorderedAwaiting stock
cancelledCancelled before ship

Shipment status

shippedLeft the warehouse
in_transitWith the carrier
out_for_deliveryOn the last leg
deliveredReceived by customer
exceptionCarrier problem
voidedLabel cancelled
One closing principle

When in doubt, model what physically happens in your warehouse and expose it plainly: an order is intent, a parcel is a real box with a real tracking number, stock lives in a specific location. An API that mirrors reality is one that every partner — us included — can integrate with quickly and trust in production.