Webhooks
Be told the moment an order or parcel changes, instead of polling for it.
Register a webhook endpoint
/v1/webhook_endpointsTell 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
httpsURL that acceptsPOST. Private, local and internal addresses are refused. eventsstring[]- Defaults to
["tracking.updated"]— the only event today.
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}/testsends a sample update now — withX-Connex-Test: trueandorderRefTEST-ORDER-001— and tells you whether your receiver accepted it. The quickest way to check your handler.GET /v1/webhook_endpointslists your endpoints withstatus,lastDeliveryAt,lastSuccessAt,lastErrorandfailureCount— the place to look when deliveries stop arriving.DELETE /v1/webhook_endpoints/{id}stops sending to it.
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" }'# 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
scopestringshipment(default when absent) ororder.fastShipIdintegerrequired- The store’s CONNEX account id.
orderRefstringrequired- The order's reference — the same key as
refsin Get tracking. statusstringrequired- Parcel status. On an
orderupdate it may be omitted; useorderStatus. orderStatusstring- The order's status. Always present on an
orderupdate. 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.
# 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
httpsonly — the secret travels in a header. - Respond
401to a bad or missing token; CONNEX will not retry it as a delivery failure. - Validate each update before storing it:
orderRefis 1–100 characters ofA–Z a–z 0–9 . _ : -, andupdatedAtis 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.
// 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,
});
}