Idempotent upserts via sourceSystem + sourceSystemId
Every primary record (customers, carriers, shipments, invoices, bills) carries two fields:
sourceSystem — a constant string identifying you (e.g. "TMSEZ")
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.
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 — GET /api/rest/carrier_invoices with a where filter on sourceSystemId to find the surviving (non-deleted) row is the safe way to reconcile after a possible retry.
See the carrier-invoice submission guide for the full mechanics.
Required fields without server-side defaults
A few fields are NOT NULL in our schema and have no default — you need to provide them on insert.
Omitting these returns a 400 constraint-violation with the column name in the error message: