The sourceSystem/sourceSystemId external key, per-resource POST duplicate semantics (including the carrier-invoice async dedup), and the fields that are required with no default.
External keys and updates via sourceSystem + sourceSystemId
Every primary record (customers, carriers, shipments, invoices, bills) carries two fields:
sourceSystem — a constant string identifying you (e.g. "YOUR_TMS")
sourceSystemId — your own primary key for the record
Together this pair is your stable external handle into Upwell. Send both on every record. Upwell uses the pair so that webhooks and callbacks can reference your records by your IDs.For the first sync of a record, use POST. For updates, use PUT /<resource>/{id} with the Upwell id you stored on first sync — or PUT /<resource> (no id in the URL) with sourceSystem + sourceSystemId in the request body to update by your external key.For the public PUT /api/rest/shipments endpoint, a source pair with no match returns HTTP 200 with {"updateShipment":{"affectedRows":0,"returning":[]}}; it creates nothing. A matching update returns its affected-row count and updated records under updateShipment. Check affectedRows and use POST /api/rest/shipments to create a missing record: this PUT is update-only, not a create-or-update upsert.
There is no/by-source-system-id/{id} URL pattern — the by-external-key update goes to the base resource path, not a suffixed one: PUT /api/rest/carriers (not PUT /api/rest/carriers/by-source-system-id/{id}), with sourceSystem + sourceSystemId inside input.
POST is not retry-safe across the full surface. POST /api/rest/customers, POST /api/rest/shipments, and POST /api/rest/invoices will return a 400 Uniqueness violation if you re-post a record with an existing (sourceSystem, sourceSystemId) pair. Treat first-sync as POST and every subsequent change as PUT.POST /api/rest/carrier_invoices behaves differently, and less strictly than “no duplicate”: the insert always succeeds synchronously and returns a new id, even when you re-post a sourceSystemId that already exists. De-duplication happens afterward, asynchronously: a background enrichment step tries to stamp the new row’s identity and, on detecting the same (tenant, sourceSystemId) already live, soft-deletes the newer row and leaves the original standing. Two things follow:
The dedup key is (tenant, sourceSystemId) — it is not scoped by integration. Two different integrations on the same tenant that happen to reuse the same external id string will collide with each other, not just with themselves.
There’s a real window (seconds) where the duplicate is live under its own id before the background pass soft-deletes it. If your client never received the response to an earlier submit and retries, you may end up holding the id from the losing (soon-to-be-deleted) copy rather than the surviving original. Don’t treat the id from a retried POST as authoritative — reconcile with GET /api/rest/carrier_invoices, filtering where on bothsourceSystemId and deletedAt: {"_isNull": true}. The second condition is not optional: the list endpoint doesn’t exclude soft-deleted rows on its own, so without it the lookup can return the dead duplicate alongside the survivor. And because the dedup itself is asynchronous — and the timed-out original may still be in flight — a lookup made too early can return two live rows, or a single live row that later loses the dedup race to the original insert. Poll until the same single live row comes back across polls a few seconds apart before persisting its id.