> ## Documentation Index
> Fetch the complete documentation index at: https://docs.upwell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Knowing when a carrier invoice is processed

> Determine processing state with the existing GET endpoints (polling) or outbound webhooks. No special status field required.

# Knowing when a carrier invoice is processed

After you [submit a carrier invoice](/api-guides/carrier-invoice-submission), Upwell works on it asynchronously. This page explains how to learn when that work is done.

<Note>
  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.
</Note>

## 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

| Signal                    | Where                                  | Meaning                                                                            |
| ------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- |
| `shipmentId` populated    | `GET /carrier_invoices/{id}`           | Ingestion matched the invoice to a shipment.                                       |
| `updatedAt` > `createdAt` | `GET /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. |
| `status`                  | `GET /carrier_invoices/{id}`           | The review/approval lifecycle (see table below).                                   |
| document `type`           | `GET /carrier_invoices/{id}/documents` | A document is parsed once `type` is no longer `"UNKNOWN"`.                         |

### Status values

| Phase           | Statuses                                                                                                       | Meaning                                        |
| --------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| In-flight       | `RECEIVED`, `PROCESSING`, `UNDER_REVIEW`, `AWAITING_INVOICE`, `AWAITING_CARRIER_RESPONSE`, `CARRIER_RESPONDED` | Submitted / under review.                      |
| Done — clear    | `APPROVED`, `PART_PAID`, `PAID`                                                                                | Approved and progressing to / through payment. |
| Needs attention | `EXCEPTION`, `CARRIER_EXCEPTION`, `DISPUTED`, `REJECTED`                                                       | A carrier/audit issue needs resolving.         |

<Warning>
  `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.
</Warning>

## 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.

```bash theme={null}
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:

```javascript theme={null}
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
  }
}
```

<Tip>
  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.
</Tip>

<Warning>
  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.
</Warning>

## Approach 2 — Webhooks

Subscribe once and let Upwell push the changes to you. Configure subscriptions in the Upwell dashboard (see [Outbound webhooks](/api-guides/webhooks) for the model, payload shape, and auth).

| Trigger                                               | Fires when                                                       |
| ----------------------------------------------------- | ---------------------------------------------------------------- |
| `create.carrier_invoice`                              | The invoice is created.                                          |
| `update.carrier_invoice`                              | Any field/status change — fires when ingestion enriches the row. |
| `update.carrier_invoice.shipment_updated`             | The invoice was matched/linked to a shipment.                    |
| `update.carrier_invoice.status.APPROVED`              | The invoice reached `APPROVED` (review lifecycle).               |
| `update.carrier_invoice.status.EXCEPTION`             | The invoice hit an exception status (review lifecycle).          |
| `update.carrier_document` / `create.carrier_document` | A document was (re)classified or attached.                       |

<Warning>
  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.
</Warning>

```javascript theme={null}
// 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?

<CardGroup cols={2}>
  <Card title="Webhooks" icon="bolt">
    Best when you can host an HTTPS endpoint. Near-real-time, no polling load.
  </Card>

  <Card title="Polling" icon="arrows-rotate">
    Best when you can't receive inbound requests, or as a backstop. Read `GET /carrier_invoices/{id}` (+ `/documents`) with backoff.
  </Card>
</CardGroup>

<Note>
  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](/api-guides/webhooks) implements both.
</Note>
