Reference

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

POST/api/v1/webhooksScope: write:webhooks

Register a URL to receive webhook deliveries for the specified event types.

Request body

  • urlstringrequired
    Your HTTPS endpoint. Must respond with a 2xx status within 30 seconds. http:// is not accepted in live mode.
  • eventsarrayrequired
    List of event types to subscribe to. Use ["*"] to subscribe to every event.
  • descriptionstring
    Optional human label shown in the dashboard.

Response (201 Created)

JSON
{
  "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

The 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.purchasedevent
    Fires when a label transitions from paying to purchased. Payload includes the label PDF URL + tracking number. Most production integrations subscribe to this and use it instead of polling.
  • shipment.failedevent
    Fires when a buy fails (payment declined, carrier rejected, etc.). Payload includes errorMessage.
  • tracker.updatedevent
    Fires on every new carrier tracking scan. Payload includes the latest event + the full label state.
  • shipment.deliveredevent
    Convenience filter — fires once, when tracker.status flips to delivered.
  • shipment.exceptionevent
    Convenience filter — fires when the carrier flags a problem. Lets you alert your customer or open a support ticket.
  • shipment.return_to_senderevent
    Convenience filter — fires once when the carrier marks the package as returning.
  • shipment.voidedevent
    Fires 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.

JSON
{
  "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-Eventheader
    Same as data.type — for quick routing.
  • X-Shiponline-Event-Idheader
    Same as data.id — for dedup. Persist this and reject duplicates.
  • X-Shiponline-Signatureheader
    HMAC-SHA256 of the raw request body, computed with your signing secret. See the verification example below.
  • X-Shiponline-Timestampheader
    Unix 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

Express, Next.js, and Flask all parse the JSON body by default, which mutates it. Your HMAC won't match if you sign the re-serialised form. Always read the raw body for signature verification, then parse it for handling.

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-Id header.
  • 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.status field to make decisions, not arrival order.

List, fetch, update + delete subscriptions

GET/api/v1/webhooksScope: read:webhooks

List your active (non-revoked) webhook subscriptions, newest first.

GET/api/v1/webhooks/{id}Scope: read:webhooks

Fetch a single subscription. Signing secret is NEVER returned - that's surfaced only at creation time.

PATCH/api/v1/webhooks/{id}Scope: write:webhooks

Pause/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
curl -X PATCH https://shiponline.app/api/v1/webhooks/whe_AAA... \
  -H "Authorization: Bearer shp_live_..." \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'
DELETE/api/v1/webhooks/{id}Scope: write:webhooks

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

Stuck or shipping production?

We read every developer email.

API questions, OAuth quirks, custom-flow integration help — email support@shiponline.app and a human replies, usually within a few hours.

  • No card to start
  • Labels in 60 seconds
  • Up to 70% off retail