Webhooks

Be told the moment an order or parcel changes, instead of polling for it.

Register a webhook endpoint

POST/v1/webhook_endpoints

Tell CONNEX where to send tracking.updated for your account's orders — Sale Page orders and orders you sent with Feed an order alike. Requires webhooks:write. An account can have up to 5 endpoints.

Body parameters

urlstringrequired
Public https URL that accepts POST. Private, local and internal addresses are refused.
eventsstring[]
Defaults to ["tracking.updated"] — the only event today.
The secret is shown once, in this response. Store it like a password — every delivery carries it as Authorization: Bearer whsec_…, which is how you know it came from CONNEX.

Test, list and delete

  • POST /v1/webhook_endpoints/{id}/test sends a sample update now — with X-Connex-Test: true and orderRef TEST-ORDER-001 — and tells you whether your receiver accepted it. The quickest way to check your handler.
  • GET /v1/webhook_endpoints lists your endpoints with status, lastDeliveryAt, lastSuccessAt, lastError and failureCount — the place to look when deliveries stop arriving.
  • DELETE /v1/webhook_endpoints/{id} stops sending to it.
Register
curl https://api.connexglobal.co/v1/webhook_endpoints \
  -H "Authorization: Bearer $CONNEX_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-app.com/webhooks/connex" }'
Test, list, deletebash
# Send a sample tracking.updated to it now
curl -X POST https://api.connexglobal.co/v1/webhook_endpoints/we_4c1d9a7b2e5f80a1c3d6/test \
  -H "Authorization: Bearer $CONNEX_ACCESS_TOKEN"
# → { "delivered": true, "status": 200, "error": null, "attempts": 1 }

# List your endpoints and how their deliveries are going
curl https://api.connexglobal.co/v1/webhook_endpoints \
  -H "Authorization: Bearer $CONNEX_ACCESS_TOKEN"

# Stop sending to one
curl -X DELETE https://api.connexglobal.co/v1/webhook_endpoints/we_4c1d9a7b2e5f80a1c3d6 \
  -H "Authorization: Bearer $CONNEX_ACCESS_TOKEN"

tracking.updated

Sent whenever an order's status or one of its parcels changes. The body is either a single update object or { "updates": [ … ] } with 1–100 updates, and is at most 256 KB. This is the same payload the CONNEX store sale pages receive.

An update has one of two scopes. A shipment update is one parcel's state. An order update is the order's own status and nothing else — it arrives before any parcel is booked, and when a cancellation means none ever will be.

Update fields

scopestring
shipment (default when absent) or order.
fastShipIdintegerrequired
The store’s CONNEX account id.
orderRefstringrequired
The order's reference — the same key as refs in Get tracking.
statusstringrequired
Parcel status. On an order update it may be omitted; use orderStatus.
orderStatusstring
The order's status. Always present on an order update.
fastshipShipmentIdinteger
CONNEX shipment id (shipment scope).
trackingNostring
CONNEX tracking number (shipment scope).
agentTrackingNostring
Last-mile carrier tracking number (shipment scope).
providerstring | null
Carrier name.
serviceAgentstring
Service code.
agentNamestring | null
Carrier service display name.
updatedAtstringrequired
ISO 8601 time the state changed at the source.

Ordering and duplicates

Deliveries can arrive more than once and out of order. Make your handler idempotent: key each parcel by fastshipShipmentId (falling back to trackingNo, then agentTrackingNo), key the order status by orderRef alone, and only apply an update whose updatedAt is newer than what you have stored.

Responding and retries

Return any 2xx within 10 seconds to acknowledge — do slow work after responding. A timeout, a 5xx, 408 or 429 is retried twice more (after 1 s and 4 s); any other 4xx is taken as your answer and not retried. Redirects are not followed. Each delivery carries an X-Connex-Delivery id.

After 50 deliveries in a row fail, the endpoint is disabled — status: "disabled" in the list — and register it again to resume. Updates you miss can always be recovered with Get tracking.

POST to your endpoint
# Test your receiver: send it exactly what CONNEX will send.
curl https://your-app.com/webhooks/connex \
  -H "Authorization: Bearer $CONNEX_WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "updates": [
      {
        "scope": "shipment",
        "fastShipId": 137971,
        "orderRef": "ORD-10432",
        "fastshipShipmentId": 884120,
        "trackingNo": "FS2604188412",
        "status": "OUT_FOR_DELIVERY",
        "orderStatus": "SHIPPED",
        "provider": "UPS",
        "updatedAt": "2026-10-24T08:15:00.000Z"
      }
    ]
  }'

# Expect 2xx. With a wrong secret, expect 401.

Receive & verify

Every delivery carries your endpoint's secret as Authorization: Bearer whsec_…. Reject any request whose token does not match, and compare in constant time — a plain === leaks the secret a byte at a time to anyone patient enough to measure your response times.

  • Serve the endpoint over https only — the secret travels in a header.
  • Respond 401 to a bad or missing token; CONNEX will not retry it as a delivery failure.
  • Validate each update before storing it: orderRef is 1–100 characters of A–Z a–z 0–9 . _ : -, and updatedAt is never in the future.
  • Responding with { ok, received, applied, stale } is optional but makes deliveries easy to debug from the CONNEX dashboard.

To rotate a secret, register a second endpoint at the same URL, accept both secrets until it is live, then delete the old endpoint.

Receiver
// app/webhooks/connex/route.ts
import { createHash, timingSafeEqual } from 'node:crypto';

function fromConnex(req: Request): boolean {
  const secret = process.env.CONNEX_WEBHOOK_SECRET ?? '';
  const header = req.headers.get('authorization') ?? '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : '';
  if (!secret || !token) return false;
  // Hash both sides so the comparison is constant-time
  // and the lengths always match.
  const a = createHash('sha256').update(token).digest();
  const b = createHash('sha256').update(secret).digest();
  return timingSafeEqual(a, b);
}

export async function POST(req: Request) {
  if (!fromConnex(req)) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const body = await req.json();
  const updates = Array.isArray(body?.updates) ? body.updates : [body];

  let applied = 0;
  for (const u of updates) {
    // Your upsert: only writes when u.updatedAt is newer
    // than the stored row for the same key.
    if (await saveIfNewer(u)) applied++;
  }
  return Response.json({
    ok: true,
    received: updates.length,
    applied,
    stale: updates.length - applied,
  });
}