Skip to main content

Submitting carrier invoices via API

If you’re an API-integrated TMS, you can push carrier invoices to Upwell programmatically instead of forwarding them by email. An invoice submitted this way gets identical processing to one received over email: shipment/carrier/ matching, the audit, and AI classification of any documents you attach. This guide walks the end-to-end flow. For the full request/response schema of each endpoint, see the auto-generated API Reference — this page focuses on the workflow and the things that aren’t obvious from the schema alone.
This flow reuses existing endpoints — there’s no separate “carrier invoice API” to learn. The same POST /api/rest/carrier_invoices and POST /api/rest/generate-upload-presigned-url endpoints power the structured submission and its document uploads.

Prerequisites

  • An API key included on every request (Authorization: YOUR_API_KEY — a Bearer prefix also works). See Authentication.
  • Optionally, the Upwell carrierId / shipmentId if you already know the match. You don’t need them — Upwell matches the invoice to a shipment, carrier, and bill from the references you send (carrierProNumber, billOfLadingNumber, customerPoNumber, customerReferenceNumber).
Your API key is bound to an integration. Upwell uses that binding to stamp every invoice you submit as API-sourced — you don’t (and can’t) set the source yourself. See the warning under Step 1.

The flow

1

Create the carrier invoice

POST /api/rest/carrier_invoices with the invoice’s structured data wrapped in input. Store the id from the response.
2

Attach documents (optional)

For each PDF (the invoice scan, BOL, POD, …) request a presigned URL, then PUT the bytes to it.
3

Let it process — then read the outcome

Upwell matches the invoice and runs the exception audit asynchronously (seconds). You learn the outcome by polling or by webhook — see Knowing when a carrier invoice is processed.

Step 1 — Create the carrier invoice

You send a bare invoice: the header fields plus whatever references help Upwell match it. Upwell fills in the matched shipment/carrier/bill and the provenance during processing.
  • Don’t send sourceSystem — Upwell always stamps source_system = "API" (and your integration) server-side.
  • Do send sourceSystemId — set it to your stable external id for this invoice (e.g. your TMS’s invoice id). It’s how Upwell de-duplicates re-posts, though not synchronously — see Re-submitting for the actual mechanics before you rely on it. Omit it only if you have no external id to key on.
  • balance and status are the only required fields. Send invoiceNumber and totalAmount when you have them (your values are authoritative), but they’re optional — anything you leave blank is filled from the documents you attach in Step 2. For a new invoice, balance equals the invoice total; use status: "RECEIVED" when you’re sending the invoice data, or "AWAITING_INVOICE" if the invoice document will follow (it auto-promotes to RECEIVED once that document arrives and parses).
  • Money is integer cents. $2,847.50284750.
  • The create response does not echo a client id — store the returned id as your handle.

Response

The matched shipmentId / billId / carrierId are null at this instant — matching runs a moment later (see Step 3). Note that exceptions is a comma-joined string (not an array); on a brand-new row it reflects the un-matched state and settles after processing.

Fields

Send these inside input:
Document content populates the invoice. Any header field you leave blank — invoice number, amounts, currency, dates, references — and the line items, remit-to / factor, and carrier are filled from the documents you attach in Step 2, after they’re classified and parsed. Consumer-supplied values are authoritative: a field you set is never overwritten, and a genuine disagreement (e.g. a different total) is raised as an exception rather than silently changed. Line items aren’t settable directly on create — they come from the documents.This also holds for late-arriving documents: fields fill in whenever a document finishes parsing, even if it’s attached after creation — or after the invoice has been approved. On an already-approved invoice, blanks are still filled in place, but the status and any resolved exceptions are left untouched.
See Integration patterns for the cross-cutting rules: integer-cents money, the input wrapper, and the presigned-upload header gotcha.

Re-submitting

POST /api/rest/carrier_invoices is a plain insert — it does not check for an existing sourceSystemId before creating the row, and it always returns a new id, even on a re-post. De-duplication happens afterward, asynchronously, once the background enrichment pass runs on the new row: if it finds another live invoice already holding the same sourceSystemId for your tenant, it soft-deletes the newer row (the one you just posted) and leaves the original untouched. The end state is one live invoice, but getting there is not synchronous, and it’s not scoped the way you might expect:
  • The key is (tenant, sourceSystemId) — not (your integration, sourceSystemId). Two different integrations on the same tenant that happen to submit the same external id string will collide with each other’s invoices, not just their own.
  • There’s a real window (typically seconds) where the duplicate is live under its own id before the background pass soft-deletes it. If a request times out on your end and you retry, both the original and the retry may briefly exist as separate live rows — and it’s the newer one that gets soft-deleted, which may or may not be the id your client actually has in hand.
Don’t treat the id from a retried POST as automatically correct. If you’re not certain whether an earlier submit with the same sourceSystemId already succeeded, look up the surviving row instead of trusting the response you just got:
A soft-deleted duplicate is excluded from this result automatically, so whatever comes back is the row that survived. If you omit sourceSystemId, there’s no key to de-duplicate on, so a re-post does create a permanent duplicate. Either way, store the returned id on first submit and use PUT /api/rest/carrier_invoices/{id} for later corrections — just be prepared to reconcile via the lookup above if you’re re-submitting after an uncertain prior attempt.

Bulk create

POST /api/rest/bulk-carrier-invoices accepts { "inputs": [...] } — an array of the same objects you’d send individually to /api/rest/carrier_invoices. Every row goes through the same asynchronous enrichment and sourceSystemId de-duplication described above, independently per row — there’s no batch-level atomicity guarantee beyond what a single row gets.

Step 2 — Attach documents

Documents go through the standard two-step presigned-upload flow. Use it for every carrier document — the invoice scan, BOLs, PODs, and so on.
1

Request a presigned URL

POST /api/rest/generate-upload-presigned-url with associationType: "CARRIER_INVOICE" and associationId set to the invoice id from Step 1. The response contains only uploadUrl and documentId.
2

PUT the bytes

Upload the raw file to uploadUrl. Send Content-Type; don’t add other headers (see the tip below).
The presigned URL is signed for host-only headers. You can send Content-Type on the PUT, but avoid adding headers that aren’t in X-Amz-SignedHeaders. Wrapping the body in a Blob with an explicit type can cause some Node fetch implementations to add headers that break the signature — passing an ArrayBuffer is the safe path. Full upload mechanics: Document conventions.
The (sourceSystem, sourceSystemId) pair on a document must be both set or both null — see Document conventions.

Document types and AI classification

If you know the document’s type, pass it (CARRIER_INVOICE, NOTICE_OF_ASSIGNMENT, BILL_OF_LADING, PROOF_OF_DELIVERY, …) and it’s used as-is. If you don’t — or you’re uploading a combined PDF — pass documentType: "UNKNOWN"; when AI classification is enabled for your tenant, Upwell splits and classifies the upload, assigning each resulting document a real type. The document stays UNKNOWN until classification completes. See Knowing when a carrier invoice is processed.
If the carrier factors their receivables, attach the NOTICE_OF_ASSIGNMENT (the factoring notice). The invoice’s remit-to / factor is populated only from parsed documents — the NOTICE_OF_ASSIGNMENT, or factoring details printed on the invoice document itself. If neither carries a factoring signal, remit-to stays empty and remit-to checks can’t run.

Step 3 — Let it process

Once the invoice exists, Upwell processes it asynchronously (typically within seconds): it stamps source_system = "API", matches the invoice to a shipment/carrier/bill from your references, and runs the exception audit. You don’t call anything for this — you observe the result.

Next: Knowing when it's processed

Poll the invoice, or subscribe to webhooks, to learn when matching, the exception audit, and document classification are done.

Recording carrier payments

Once a carrier invoice is approved, a carrier payment line item records that a payment was made against it — the AP counterpart to a customer payment’s line items on the AR side. POST /api/rest/carrier_payment_line_items (bulk variant: POST /api/rest/bulk-carrier-payment-line-items, same { "inputs": [...] } shape as elsewhere) creates one.
A carrier payment line item’s real parent is technically a bill payment (blp_) via billPaymentId, not billId. billPaymentId is accepted on create/update, but it is not included in the response body, and it is rarely populated in practice — billId is the link that carrier payment line items are reliably keyed on. Treat billId as the field to build against; don’t assume billPaymentId will be populated or plan a workflow around reading it back.