Webhooks
Subscribe to label and tracking events. Every delivery is signed with HMAC-SHA256 so you can verify authenticity, and a delivery includes replay-protection headers.
Subscribe to events
/api/v1/webhooksScope: write:webhooksRegister a URL to receive webhook deliveries for the specified event types.
Request body
urlstringrequiredYour HTTPS endpoint. Must respond with a 2xx status within 30 seconds. http:// is not accepted in live mode.eventsarrayrequiredList of event types to subscribe to. Use["*"]to subscribe to every event.descriptionstringOptional human label shown in the dashboard.
Response (201 Created)
{
"id": "whk_EEE...",
"url": "https://api.your-app.com/shipments/webhook",
"events": ["shipment.purchased", "shipment.delivered", "shipment.exception"],
"signingSecret": "whsec_8a3f...c91b",
"createdAt": "2026-06-26T12:14:00Z"
}Signing secret shown once
signingSecret is returned only on creation. Store it securely — you need it to verify every incoming delivery. Lost the secret? Rotate the subscription (delete + recreate) to get a new one.Event types
shipment.purchasedeventFires when a label transitions frompayingtopurchased. Payload includes the label PDF URL + tracking number. Most production integrations subscribe to this and use it instead of polling.shipment.failedeventFires when a buy fails (payment declined, carrier rejected, etc.). Payload includes errorMessage.tracker.updatedeventFires on every new carrier tracking scan. Payload includes the latest event + the full label state.shipment.deliveredeventConvenience filter — fires once, when tracker.status flips to delivered.shipment.exceptioneventConvenience filter — fires when the carrier flags a problem. Lets you alert your customer or open a support ticket.shipment.return_to_sendereventConvenience filter — fires once when the carrier marks the package as returning.shipment.voidedeventFires when a label refund is confirmed by the carrier.
Delivery payload
Every delivery is a JSON POST to your URL with the following envelope. The data field contains the affected resource in the same shape the GET endpoint would return.
{
"id": "evt_FFF...",
"type": "shipment.purchased",
"apiVersion": "v1",
"createdAt": "2026-06-26T12:14:00Z",
"data": {
"id": "lbl_CCC...",
"status": "purchased",
"carrier": "USPS",
"service": "Priority",
"trackingNumber": "9405511...",
"labelUrl": "https://shiponline.app/labels/lbl_CCC.pdf",
"chargeAmountCents": 828,
"platformFeeCents": 8
}
}Delivery headers
X-Shiponline-EventheaderSame as data.type — for quick routing.X-Shiponline-Event-IdheaderSame asdata.id— for dedup. Persist this and reject duplicates.X-Shiponline-SignatureheaderHMAC-SHA256 of the raw request body, computed with your signing secret. See the verification example below.X-Shiponline-TimestampheaderUnix timestamp (seconds) when the delivery was sent. Use it for replay-window enforcement (we recommend rejecting events older than 5 minutes).
Signature verification
Every delivery is signed with HMAC-SHA256. Verify the signature before processing the event — otherwise an attacker who guesses your webhook URL can forge deliveries.
import crypto from "node:crypto";
const SIGNING_SECRET = process.env.SHIPONLINE_WEBHOOK_SECRET;
export async function POST(req) {
const rawBody = await req.text(); // MUST use raw body, not JSON-parsed
const signature = req.headers.get("X-Shiponline-Signature");
const timestamp = req.headers.get("X-Shiponline-Timestamp");
// 1. Reject ancient events (replay protection)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return new Response("stale", { status: 401 });
}
// 2. HMAC the timestamp + body
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// 3. Constant-time compare
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(rawBody);
// ... handle the event ...
return new Response("ok");
}Read the raw body before parsing
Delivery semantics
- Retries:if your endpoint returns a non-2xx status (or times out at 30s), we retry with exponential backoff: 30s, 5min, 30min, 2h, 6h, 24h. After 6 failed attempts the subscription is auto-disabled and you'll see it in the dashboard.
- At-least-once delivery: we may deliver the same event multiple times if a previous attempt looked like it failed but actually succeeded. Dedupe on the
X-Shiponline-Event-Idheader. - Ordering: events are delivered in roughly the order they happened, but a slow handler can cause later-occurring events to arrive first when retries kick in. Use the
data.statusfield to make decisions, not arrival order.
List, fetch, update + delete subscriptions
/api/v1/webhooksScope: read:webhooksList your active (non-revoked) webhook subscriptions, newest first.
/api/v1/webhooks/{id}Scope: read:webhooksFetch a single subscription. Signing secret is NEVER returned - that's surfaced only at creation time.
/api/v1/webhooks/{id}Scope: write:webhooksPause/resume a subscription via enabled, edit description, or change event subscriptions. URL + signing secret are immutable - rotate by deleting + recreating.
Body accepts a partial: any combination of enabled (boolean), description (string or null), and events (array, 1-20 entries from the list above). Returns the updated subscription in the same shape as GET.
curl -X PATCH https://shiponline.app/api/v1/webhooks/whe_AAA... \
-H "Authorization: Bearer shp_live_..." \
-H "Content-Type: application/json" \
-d '{"enabled": false}'/api/v1/webhooks/{id}Scope: write:webhooksDelete a subscription. Disables delivery immediately. Soft-delete on the backend (revokedAt set, row preserved for delivery-log audit), but the subscription is invisible to subsequent GET calls. Use the same id - URL + signing secret are gone, so re-installing requires POST /webhooks again.