Skip to main content

Outbound webhooks

Upwell can POST a JSON event to a URL you host whenever something changes — a carrier invoice is created, matched to a shipment, approved, and so on. Webhooks are the push counterpart to polling.

Subscribing

Webhook subscriptions are configured in the Upwell dashboard (there’s no public REST endpoint to create one). A subscription has:
  • url — your HTTPS receiver.
  • triggers — the list of event types you want delivered.
  • authSettings — how Upwell proves the delivery came from Upwell (see Authenticating deliveries).
  • retryAttempts — how many times Upwell retries a failed delivery (0–10).

Carrier-invoice triggers

TriggerFires when
create.carrier_invoiceA carrier invoice is created.
update.carrier_invoiceAny field/status change on a carrier invoice.
update.carrier_invoice.shipment_updated / create.carrier_invoice.shipment_updatedThe invoice was matched/linked to a shipment (fires during ingestion).
update.carrier_invoice.status.APPROVEDThe invoice reached APPROVED (review lifecycle).
update.carrier_invoice.status.EXCEPTIONThe invoice hit an exception status (review lifecycle).
create.carrier_document / update.carrier_document / delete.carrier_documentA document was added, (re)classified, or removed.
“Ingestion done” (matched + audited) is best observed via *.shipment_updated or the generic update.carrier_invoice. The *.status.APPROVED / *.status.EXCEPTION triggers reflect the later review lifecycle. See Knowing when a carrier invoice is processed.

The delivery envelope

Every delivery is a single JSON object — the event row. The fields you’ll read:
FieldDescription
triggerThe trigger string, e.g. update.carrier_invoice.status.APPROVED. Branch on this.
tableThe resource table, e.g. carrier_invoices.
operationINSERT, UPDATE, or DELETE.
resourceIdThe id of the affected carrier invoice. Use it to correlate (and to fetch a canonical representation via GET /carrier_invoices/{resourceId}).
payloadThe event body — its shape depends on the trigger (see below).
statusThe delivery status of this webhook event (e.g. SUCCESS), not the invoice status. Don’t confuse the two.
idThe delivery’s own id. Use it to dedupe retries.
Plus delivery bookkeeping (tenantId, retries, createdAt, processedAt, …).

The payload shape varies by trigger

There is no single payload shape. Status-change events deliver a mapped object nested under carrierInvoice; row-level events (*.shipment_updated) deliver raw new/old column snapshots. The robust pattern is to read resourceId from the envelope and call GET /api/rest/carrier_invoices/{resourceId} for a canonical representation — then the payload is just a hint about what changed.
Status-change events (update.carrier_invoice.status.APPROVED / .EXCEPTION) — payload.carrierInvoice with the invoice and its matched relations nested:
{
  "trigger": "update.carrier_invoice.status.APPROVED",
  "resourceId": "cari_f68wIfYF",
  "payload": {
    "carrierInvoice": {
      "id": "cari_f68wIfYF",
      "status": "APPROVED",
      "invoiceNumber": "INV-99821",
      "totalAmount": 220000,
      "currency": "USD",
      "carrierProNumber": "SH-100015",
      "issueDate": "2026-06-29",
      "shipment": { "id": "shi_...", "shipmentNumber": "SH-100015" },
      "bill": {
        "id": "bil_...", "proNumber": "SH-100015", "totalAmount": 914917,
        "carrier": { "id": "car_...", "name": "AAA FREIGHT INC" },
        "vendor": null
      }
    }
  }
}
Row-level events (update.carrier_invoice.shipment_updated) — raw new/old snapshots (snake_case columns):
{
  "trigger": "update.carrier_invoice.shipment_updated",
  "resourceId": "cari_dM-zp1Fk",
  "payload": {
    "new": {
      "id": "cari_dM-zp1Fk", "status": "RECEIVED", "shipment_id": "shi_...",
      "carrier_id": "car_...", "bill_id": "bil_...", "source_system": "API",
      "integration_id": "int_...", "invoice_number": "LIVE-WH-1782703087", "...": "..."
    },
    "old": { "...": "..." }
  }
}
Note new.source_system = "API" and new.integration_id are stamped by Upwell — confirming the invoice was API-submitted. source_system_id is null for API submissions.

Authenticating deliveries

You choose how Upwell authenticates to your receiver when you create the subscription. Three options:
Upwell sends Authorization: Basic base64(username:password) using the credentials you configure.
No auth header. Rely on URL secrecy / network controls. Not recommended for production.
Upwell always sends Content-Type: application/json and a User-Agent of UpwellWebhookService/<version> (https://www.upwell.com).

Reliability

  • Deliveries that return 5xx are retried, up to the subscription’s retryAttempts.
  • Each delivery has a 15-second timeout.
  • Return a 2xx quickly; do slow work asynchronously.
  • Build an idempotent receiver: dedupe on the delivery id (or resourceId + trigger).

Example receiver

import express from 'express';
const app = express();
app.use(express.json());

app.post('/webhooks/upwell', (req, res) => {
  if (req.get('X-Upwell-Token') !== process.env.UPWELL_WEBHOOK_TOKEN)
    return res.status(401).send('unauthorized');

  const event = req.body; // the envelope
  // The invoice status lives in the (trigger-specific) payload, NOT event.status.
  const invoice =
    event.payload?.carrierInvoice ?? event.payload?.new ?? null;

  switch (event.trigger) {
    case 'update.carrier_invoice.status.APPROVED':
      onApproved(event.resourceId, invoice);
      break;
    case 'update.carrier_invoice.status.EXCEPTION':
      onException(event.resourceId, invoice);
      break;
    case 'update.carrier_invoice.shipment_updated':
      onMatched(event.resourceId, invoice);
      break;
  }
  res.json({ ok: true }); // ack fast; 5xx would be retried
});

See it end-to-end

The sample TMS app implements this receiver alongside a polling fallback, and walks the full submit → attach → process flow. Start from the submission guide.