Webhooks
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Webhooks
Outbound webhooks send workspace events to an HTTPS endpoint you control. Each delivery is a signed JSON POST. Use them to push new orders and shipping updates into your own systems, or into tools such as n8n or Zapier, without polling.
Note: This page covers webhooks that Shippified sends. To send orders into Shippified with a Discord-format webhook, see Getting orders in.
Endpoints
Method | Path | Purpose |
|---|---|---|
|
| List subscriptions, newest first. Paginated. |
|
| Get one subscription. |
|
| Create a subscription. Returns the signing secret once. |
|
| Change |
|
| Delete a subscription. |
|
| Send a test event right away and return the result. |
|
| Issue a new signing secret, reset the failure counter, and reactivate. Returns the secret once. |
You can also manage subscriptions in the dashboard under Settings → Developer → Outbound webhooks.
Event types
Event | Fires when |
|
|---|---|---|
| A new order is created from any source: Discord, email, or |
|
| An incoming message or manual create matches an existing order's |
|
|
|
|
| An incoming shipping message moves an order to shipped for the first time, a |
|
| An incoming delivery message arrives for an order not yet marked delivered, or a carrier lookup first confirms delivery. |
|
| A live carrier lookup returns data, whether it came from the background tracker or from an API call. Cached answers don't fire it. |
|
| A share card is created. |
|
Field notes:
orderis the full order object after the change.sourceis the intake channel:discord,email, ormanual.patchcontains the fields thePATCHactually applied, plusstatuswhen adding a tracking number moved the order to shipped and the updateduserEditedlist. A cleared sale price appears as"salePriceCents": null.carrieris a carrier key:ups,fedex,usps,dhl, ordhl-express.deliveredis a boolean.eventCountis the number of carrier events.
One incoming message can produce more than one event. For example, a shipping email for a known order fires order.merged and then order.shipped.
Tracking events (tracking.refreshed, and order.shipped or order.delivered from a carrier) come from two places, and both use the same code path:
The background tracker. It checks in-flight orders on carriers that have live tracking on the instance, roughly every 30 minutes for orders placed in the last 30 days and twice a day for orders 30 to 90 days old. Older orders are not polled in the background.
API calls that run a live lookup:
GET /api/orders/:id/tracking(when the cache has expired),POST /api/orders/:id/tracking/refresh, andPOST /api/orders/sync-tracking.
order.shipped and order.delivered fire only on a real status change, so repeated lookups of the same package don't repeat them. Carriers without live tracking on the instance produce no tracking events. GET /api/carrier-status lists which carriers are live. See Orders → Tracking.
Note: These actions do not fire events: mailbox rebuilds and other history backfills,
POST /api/orders/reparse, andDELETE /api/orders/:id.
Create a subscription
POST /api/webhook-subscriptions
Body field | Type | Required | Notes |
|---|---|---|---|
| string | Yes | Must be |
| string[] | Yes | A non-empty list of the event types above. Duplicates are removed. An unknown type returns |
| boolean | No | Defaults to |
curl -s -X POST https://shippified.net/api/webhook-subscriptions \
-H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/shippified",
"eventTypes": ["order.created", "order.shipped", "order.delivered"]
}'Response 201 Created:
{
"id": "whsub_m1x2y3z4_k9d2q",
"userId": "usr_4f1c2a9b7e0d3c11",
"url": "https://hooks.example.com/shippified",
"prefix": "whsec_Q2x8v1Z",
"eventTypes": ["order.created", "order.shipped", "order.delivered"],
"active": true,
"failureCount": 0,
"createdAt": "2026-09-24T15:30:00.000Z",
"needsSecretRotation": false,
"secret": "whsec_Q2x8v1Z…"
}Important:
secretis returned only once, in this response and in the response to a secret rotation. The API never shows it again, so copy it into your receiver's configuration right away.prefix(whsec_plus 8 characters) is kept so you can tell subscriptions apart.
Shippified keeps the secret encrypted at rest on the subscription, so it can sign deliveries across server restarts and deploys. Neither the encrypted secret nor its hash is ever returned by the API. If the server cannot encrypt secrets (a deployment misconfiguration), create and rotate return 503.
Each account can have up to 25 subscriptions. Creating a 26th returns 429.
Subscription fields
Field | Description |
|---|---|
|
|
| Where deliveries are sent. |
| First 14 characters of the secret. |
| The events this subscription receives. |
|
|
| Consecutive failed deliveries. Reset to 0 by a successful delivery, by |
| Time of the most recent delivery attempt, successful or not. |
| Error from the most recent failure, such as |
| Creation time. |
|
|
Delivery format
Every delivery is an HTTP POST with a JSON body.
Headers
Header | Value |
|---|---|
|
|
|
|
| The event type, e.g. |
| The envelope |
| Random hex ID for this delivery attempt. Not sent on test deliveries. |
|
|
|
|
Envelope
{
"id": "evt_mq1a2b3c_0a1b2c3d",
"type": "order.shipped",
"createdAt": "2026-09-24T15:31:02.337Z",
"userId": "usr_4f1c2a9b7e0d3c11",
"data": {
"order": {
"id": "ord_mpvu9uim_aeh5t",
"status": "shipped",
"trackingNumber": "1Z999AA10123456784",
"carrier": "UPS",
"…": "…"
},
"source": "email"
}
}Field | Description |
|---|---|
| Unique event ID ( |
| The event type. |
| When the event was emitted (ISO 8601 UTC). |
| The workspace owner. |
| The event-specific payload listed in Event types. |
Verifying signatures
The signature is an HMAC-SHA256 of the exact raw request body:
Key: the full secret string, including the
whsec_prefix, as UTF-8 bytes. Do not decode it from base64.Output: hex-encoded and prefixed with
sha256=.
To verify a delivery:
Read the raw body bytes before parsing JSON. Parsing and re-serializing changes the bytes, and the check will fail.
Compute
sha256=+hex(HMAC_SHA256(key = secret, message = rawBody)).Compare the result with
X-Shippified-Signatureusing a constant-time comparison.Reject the request with
401if they don't match.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SHIPPIFIED_WEBHOOK_SECRET; // "whsec_..."
function isValidSignature(rawBody, header, secret) {
if (typeof header !== "string") return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use a raw body parser on this route so the exact bytes are available.
app.post("/shippified", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.get("X-Shippified-Signature");
if (!isValidSignature(req.body, signature, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate on event.id, then enqueue for processing.
console.log(event.type, event.id);
res.sendStatus(204);
});
app.listen(3000);Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SHIPPIFIED_WEBHOOK_SECRET"] # "whsec_..."
def is_valid_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/shippified")
def shippified_webhook():
raw_body = request.get_data() # exact bytes, read before any JSON parsing
if not is_valid_signature(raw_body, request.headers.get("X-Shippified-Signature"), SECRET):
abort(401)
event = request.get_json()
# Deduplicate on event["id"], then enqueue for processing.
print(event["type"], event["id"])
return "", 204TypeScript SDK helper
The TypeScript SDK includes a WebCrypto-based verifier that works in Node 18+, browsers, and edge runtimes:
import { verifyShippifiedSignature } from "shippified-sdk";
const ok = await verifyShippifiedSignature({
rawBody, // exact raw body string
header: request.headers.get("x-shippified-signature") ?? "",
secret: process.env.SHIPPIFIED_WEBHOOK_SECRET!,
});Note: The signature covers only the body. The signed payload has no timestamp, so the signature by itself does not prevent replays. Track envelope
ids you have already processed, and reject any whosecreatedAtis older than your tolerance window.
Delivery behaviour
Timeout. Each delivery must finish within 5 seconds. A slower response counts as a failure.
Success means any
2xxstatus. Redirects are not followed: a3xxresponse counts as a failure (HTTP 301,HTTP 302, …). Point the subscription at the final URL.No retries. Each event is delivered once per subscription. A failed delivery is not retried and not queued.
Order is not guaranteed. Deliveries run concurrently in the background, so a receiver can get
order.shippedbefore theorder.mergedthat came with it.Auto-pause. Each failure increments
failureCountand recordslastError. At 10 consecutive failures the subscription is set toactive: false. Fix the receiver, then turn it back on withPATCH {"active": true}, which also resetsfailureCountto 0.Address check at send time. The address the connection actually goes to is checked again when each delivery is sent, so a hostname that later starts resolving to a private address fails with a connection error instead of reaching it.
Important: Treat webhooks as notifications, not as a record you can rely on. Anything missed during an outage is gone. If you need every change, reconcile periodically with
GET /api/orders(for example, filter onfrom) and use webhooks to decide when to reconcile.
Subscriptions that need a new secret
Signing secrets are stored encrypted and survive restarts and deploys. Subscriptions created before Shippified started storing secrets have no stored secret, and they come back from the API with "needsSecretRotation": true:
Deliveries to them are held: events are skipped for that subscription (not queued), and the skips don't count toward
failureCountor auto-pause.The test endpoint returns
{ "ok": false, "error": "Signing secret unavailable — …" }.The dashboard shows a Rotate now prompt on the subscription.
To resume deliveries, call POST /api/webhook-subscriptions/:id/rotate-secret and put the new secret in your receiver. Rotation also reactivates the subscription with a clean counter.
If a stored secret can no longer be decrypted (for example after the server's encryption key changes), deliveries are skipped the same way and lastError explains that the secret must be rotated.
Update a subscription
PATCH /api/webhook-subscriptions/:id
Every field is optional. Only the fields you send are changed, and they are validated the same way as on create. Sending "active": true resets failureCount to 0, so a subscription you turn back on after an auto-pause starts with a clean counter.
curl -s -X PATCH https://shippified.net/api/webhook-subscriptions/whsub_m1x2y3z4_k9d2q \
-H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
-H "Content-Type: application/json" \
-d '{"active": true, "eventTypes": ["order.created", "order.merged", "order.delivered"]}'Returns the updated subscription without secret.
Test a subscription
POST /api/webhook-subscriptions/:id/test
Sends a test event to the subscription's URL right away and returns the result. Tests do not change failureCount or lastError.
Body field | Type | Notes |
|---|---|---|
| string | Optional. Defaults to the subscription's first event type. |
curl -s -X POST https://shippified.net/api/webhook-subscriptions/whsub_m1x2y3z4_k9d2q/test \
-H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
-H "Content-Type: application/json" \
-d '{"eventType": "order.shipped"}'{ "ok": true, "status": 204 }The test delivery uses the normal envelope and signature, sends X-Shippified-Test: true, and has an id starting with evt_test_. Its payload is "data": { "test": true }, not a real order, so make your handler ignore test events or accept them explicitly. A failed test returns 200 with { "ok": false, "status"?: <code>, "error": "…" }.
Rotate the signing secret
POST /api/webhook-subscriptions/:id/rotate-secret
curl -s -X POST https://shippified.net/api/webhook-subscriptions/whsub_m1x2y3z4_k9d2q/rotate-secret \
-H "Authorization: Bearer $SHIPPIFIED_API_KEY"Returns the subscription with a new secret, which is shown once. Rotation also resets failureCount, clears lastError, sets active: true, and clears needsSecretRotation, so it is the one-step recovery for a paused subscription or one that needs a new secret. The old secret stops working immediately. There is no overlap period. To avoid rejecting valid deliveries, have your receiver accept both the old and new secrets briefly while you switch over.
Delete a subscription
DELETE /api/webhook-subscriptions/:id returns 200 {"ok": true} or 404. Deletion is immediate and cannot be undone.
Errors
Situation | Status |
|
|---|---|---|
Missing or invalid |
|
|
Invalid |
|
|
Invalid test |
|
|
Subscription cap reached |
|
|
Unknown ID |
|
|
Server can't encrypt signing secrets |
|
|
Private addresses are refused
Shippified makes HTTP requests to URLs you give it, so it refuses any URL whose hostname resolves to a private or internal address. The same check applies to:
webhook subscription URLs (create and
PATCH),bot output webhooks (
outputWebhookonPOSTandPATCH /api/bots),the workspace-level Discord webhooks in settings (
outputWebhookandupdatesWebhookonPOST /api/settings).
Refused ranges include loopback (127.0.0.0/8, ::1), private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7), link-local (169.254.0.0/16, fe80::/10), carrier-grade NAT (100.64.0.0/10), documentation, benchmarking, multicast, and reserved ranges, and IPv6 forms that embed one of these IPv4 addresses. Every address the hostname resolves to is checked, and the check runs again when each request is sent. Bot and settings errors are prefixed with the field name, for example outputWebhook: url cannot point at private / loopback addresses.
Public feed mirror
This is not a subscription, but it is the other place Shippified posts your orders. If your workspace has public stats turned on, each checkout that comes in through a Discord bot can be mirrored to Shippified's community feed channel. The mirrored post contains only the item name, store, price, and product image, and it is always posted under the name Shippified. Order numbers, tracking numbers, profile and customer names, emails, addresses, and your bot's name are never included. Turn public stats off to stop mirroring.
Related
Discord bot guide: Shippified's own Discord notifications, which are separate from outbound webhooks.