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/bill matching, the exception 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, no Bearer prefix). 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 or sourceSystemId. Upwell stamps source_system = "API" (and your integration) server-side during processing. (Those two fields are yours to set on documents — see Step 2 — just not on the invoice itself.)
  • balance and status are required (along with invoiceNumber and totalAmount). For a new invoice, balance equals totalAmount; status is "RECEIVED".
  • Money is integer cents. $2,847.50284750.
  • The create response does not echo a client id — store the returned id as your handle.
curl -X POST https://api.upwell.com/api/rest/carrier_invoices \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "invoiceNumber": "INV-99821",
      "carrierName": "Acme Freight",
      "totalAmount": 284750,
      "balance": 284750,
      "status": "RECEIVED",
      "currency": "USD",
      "issueDate": "2026-06-20",
      "dueDate": "2026-07-20",
      "billOfLadingNumber": "BOL-77123",
      "carrierProNumber": "PRO-44120"
    }
  }'

Response

{
  "createCarrierInvoice": {
    "id": "cari_a1b2c3d4e5",
    "invoiceNumber": "INV-99821",
    "status": "RECEIVED",
    "balance": 284750,
    "currency": "USD",
    "shipmentId": null,
    "billId": null,
    "carrierId": null,
    "exceptions": "NO MATCHING SHIPMENT, INVOICE AMOUNT MISMATCH",
    "carrierInvoiceLineItems": []
  }
}
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:
FieldNotes
invoiceNumberRequired. The carrier’s invoice number.
totalAmountRequired. Integer cents.
balanceRequired. Outstanding amount in cents (= totalAmount for a new invoice).
statusRequired. Use "RECEIVED".
currencyISO code, e.g. "USD".
carrierNameCarrier name as printed — helps matching and avoids a spurious “no carrier name” finding.
carrierProNumber, billOfLadingNumber, customerPoNumber, customerReferenceNumberMatching references. carrierProNumber is the strongest.
shipmentId, carrierIdPin the match yourself if you already know it.
loadNumber, issueDate, dueDate, totalTaxAmount, metadata, referenceNumbersOptional.
Line items are not settable on create via the API (the carrierInvoiceLineItems insert isn’t exposed to API callers). Send the header totalAmount; Upwell derives line-level detail from the documents you attach.
See Integration patterns for the cross-cutting rules: integer-cents money, the input wrapper, and the presigned-upload header gotcha.

Re-submitting

There’s no server-side idempotency on create — re-posting the same invoice creates a new record. Store the returned id on first submit and use PUT /api/rest/carrier_invoices/{id} for any later corrections; guard against accidental re-submits on your side.

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).
// 1) ask Upwell for a presigned URL for a CARRIER_INVOICE document
const presign = await fetch(
  'https://api.upwell.com/api/rest/generate-upload-presigned-url',
  {
    method: 'POST',
    headers: { Authorization: 'YOUR_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      input: {
        associationType: 'CARRIER_INVOICE',
        associationId: carrierInvoiceId, // the id from Step 1
        documentType: 'CARRIER_INVOICE', // or BILL_OF_LADING, PROOF_OF_DELIVERY, UNKNOWN, …
        fileName: 'invoice-99821.pdf',
        mimeType: 'application/pdf',
        // On DOCUMENTS, sourceSystem + sourceSystemId ARE yours to set (both or neither):
        sourceSystem: 'YOURSYS',
        sourceSystemId: 'YOURSYS-CI-99821-DOC1',
      },
    }),
  },
).then((r) => r.json());

// the result is wrapped under `generateUploadPresignedUrl`; only these two fields are returned
const { uploadUrl, documentId } = presign.generateUploadPresignedUrl;

// 2) PUT the raw bytes (an ArrayBuffer, not a typed Blob)
await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/pdf' },
  body: fileArrayBuffer,
});
The presigned URL is signed for host-only headers. Send Content-Type on the PUT, but avoid extra headers that aren’t in X-Amz-SignedHeaders. Wrapping the body in a typed Blob can make some Node fetch implementations add headers that break the signature — pass an ArrayBuffer. The (sourceSystem, sourceSystemId) pair on a document must be both set or both null. See Integration patterns.

Document types and AI classification

If you know the document’s type, pass it (CARRIER_INVOICE, 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.

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.