Skip to main content

Knowing when a carrier invoice is processed

After you submit a carrier invoice, Upwell works on it asynchronously. This page explains how to learn when that work is done.
There’s no single “processed” flag. You read the existing fields on the invoice — its status, its exceptions, and the matched shipmentId — plus the type on each attached document. You can read them by polling the GET endpoints or by receiving webhooks. Most production integrations use webhooks for timeliness and polling as a reconciliation backstop.

Two phases of processing

It helps to separate two things:
  1. Ingestion (automatic, seconds after create). Upwell stamps provenance (source_system = "API"), matches the invoice to a shipment/carrier/bill from your references, and runs the exception audit. The status usually stays RECEIVED through this phase — ingestion populates shipmentId/billId/carrierId and updates exceptions, it doesn’t move the status.
  2. Review & approval (later; may involve your rules or a human). The invoice moves toward APPROVED or an exception state. This is where status changes.
So “is it done?” has two answers depending on what you’re waiting for. Both are observable from the same endpoints.

The signals

SignalWhereMeaning
shipmentId populatedGET /carrier_invoices/{id}Ingestion matched the invoice to a shipment.
updatedAt > createdAtGET /carrier_invoices/{id}Ingestion has run (Upwell touched the row after you created it).
exceptions (string)GET /carrier_invoices/{id}Comma-joined open audit findings, e.g. "INVOICE AMOUNT MISMATCH". Empty = clean.
statusGET /carrier_invoices/{id}The review/approval lifecycle (see table below).
document typeGET /carrier_invoices/{id}/documentsA document is parsed once type is no longer "UNKNOWN".

Status values

PhaseStatusesMeaning
In-flightRECEIVED, PROCESSING, UNDER_REVIEW, AWAITING_INVOICE, AWAITING_CARRIER_RESPONSE, CARRIER_RESPONDEDSubmitted / under review.
Done — clearAPPROVED, PART_PAID, PAIDApproved and progressing to / through payment.
Needs attentionEXCEPTION, CARRIER_EXCEPTION, DISPUTED, REJECTEDA carrier/audit issue needs resolving.
exceptions is a comma-joined string, not an array. Treat a non-empty string as “needs attention” even while status is still RECEIVED. And don’t judge exceptions until ingestion has run — a brand-new row always lists “no carrier / no matching shipment” findings that disappear once matching completes.

Approach 1 — Polling

Read the invoice and its documents on an interval. Wait for ingestion (the row’s updatedAt advances past createdAt), then read the outcome.
curl https://api.upwell.com/api/rest/carrier_invoices/cari_a1b2c3d4e5 \
  -H "Authorization: YOUR_API_KEY"
# -> { "carrierInvoice": { "id": "cari_a1b2c3d4e5", "status": "RECEIVED",
#       "shipmentId": "shi_...", "billId": "bil_...", "carrierId": "car_...",
#       "exceptions": "INVOICE AMOUNT MISMATCH", "createdAt": "...", "updatedAt": "...", ... } }

curl https://api.upwell.com/api/rest/carrier_invoices/cari_a1b2c3d4e5/documents \
  -H "Authorization: YOUR_API_KEY"
# -> { "carrierInvoiceDocumentsByCarrierInvoiceId":
#      [ { "id": "...", "documentId": "doc_...", "type": "CARRIER_INVOICE", "url": "https://...", ... } ] }
A minimal poller with backoff:
async function waitForIngestion(id, { timeoutMs = 60_000 } = {}) {
  let delay = 1_500;
  const deadline = Date.now() + timeoutMs;

  while (true) {
    const { carrierInvoice: inv } =
      await get(`/api/rest/carrier_invoices/${id}`);
    const { carrierInvoiceDocumentsByCarrierInvoiceId: docs = [] } =
      await get(`/api/rest/carrier_invoices/${id}/documents`);

    const ingested = inv.updatedAt !== inv.createdAt;   // Upwell has processed it
    const hasExceptions = !!(inv.exceptions || '').trim();
    const unclassified = docs.filter((d) => !d.type || d.type === 'UNKNOWN');

    if (ingested) {
      if (hasExceptions) return { outcome: 'NEEDS_ATTENTION', inv, docs };
      if (unclassified.length === 0) return { outcome: 'PROCESSED', inv, docs };
    }
    if (Date.now() + delay >= deadline)
      // Give up gracefully. A doc still UNKNOWN here is in manual review, not "failed".
      return { outcome: 'TIMEOUT', inv, docs, unclassified };

    await new Promise((r) => setTimeout(r, delay));
    delay = Math.min(delay * 2, 12_000); // exponential backoff
  }
}
Start around a 1–2 s interval and back off exponentially. Ingestion usually settles within seconds; document classification can take longer for large multi-page PDFs.
The documents endpoint exposes the document’s type, not its internal classification status. A document still UNKNOWN past your timeout is ambiguous between “still classifying” and “couldn’t be classified” — treat it as needs manual review rather than a hard failure, and don’t block your pipeline on it.

Approach 2 — Webhooks

Subscribe once and let Upwell push the changes to you. Configure subscriptions in the Upwell dashboard (see Outbound webhooks for the model, payload shape, and auth).
TriggerFires when
create.carrier_invoiceThe invoice is created.
update.carrier_invoiceAny field/status change — fires when ingestion enriches the row.
update.carrier_invoice.shipment_updatedThe invoice was matched/linked to a shipment.
update.carrier_invoice.status.APPROVEDThe invoice reached APPROVED (review lifecycle).
update.carrier_invoice.status.EXCEPTIONThe invoice hit an exception status (review lifecycle).
update.carrier_document / create.carrier_documentA document was (re)classified or attached.
The only per-status webhooks are APPROVED and EXCEPTION — and those reflect the review lifecycle, not ingestion. To know that ingestion completed (matched + audited), listen to update.carrier_invoice / update.carrier_invoice.shipment_updated, or poll. Other status transitions (UNDER_REVIEW, PAID, …) don’t each get their own trigger.
// Express-style receiver — see /api-guides/webhooks for auth + the full envelope.
app.post('/webhooks/upwell', (req, res) => {
  const event = req.body; // { trigger, resourceId, payload, ... }
  switch (event.trigger) {
    case 'update.carrier_invoice.shipment_updated':
      onMatched(event.resourceId, event.payload);
      break;
    case 'update.carrier_invoice.status.APPROVED':
      onApproved(event.resourceId, event.payload);
      break;
    case 'update.carrier_invoice.status.EXCEPTION':
      onException(event.resourceId, event.payload);
      break;
  }
  res.json({ ok: true });
});

Which should I use?

Webhooks

Best when you can host an HTTPS endpoint. Near-real-time, no polling load.

Polling

Best when you can’t receive inbound requests, or as a backstop. Read GET /carrier_invoices/{id} (+ /documents) with backoff.
Webhook delivery retries on 5xx and can be delayed under load. A robust integration uses webhooks for timeliness and reconciles with a periodic poll so a missed delivery never leaves an invoice stuck in your system. The demo TMS app implements both.