🪝Webhooks

Subscribe your own HTTPS endpoint to order, tracking and share events, verify each delivery's signature, and handle failures.

Written for
Developers receiving events from Shippified
Applies to
All plans
AdminUpdated Sep 26, 2026

Outbound webhooks send workspace events to an endpoint you control. Each delivery is a JSON POST signed with a secret only you and Shippified know. Use them to push new orders and shipping updates into your own systems, or into tools such as n8n or Zapier, instead of polling.

This page is about webhooks Shippified sends you. To send orders into Shippified with a Discord-format webhook, see Getting orders in.

Endpoints

Method

Path

Purpose

GET

/api/webhook-subscriptions

List subscriptions, newest first (paginated).

GET

/api/webhook-subscriptions/:id

Get one subscription.

POST

/api/webhook-subscriptions

Create a subscription. Returns the signing secret once.

PATCH

/api/webhook-subscriptions/:id

Change url, eventTypes or active.

DELETE

/api/webhook-subscriptions/:id

Delete a subscription.

POST

/api/webhook-subscriptions/:id/test

Send a test event now and return the result.

POST

/api/webhook-subscriptions/:id/rotate-secret

Issue a new secret, reset failures and reactivate. Returns the secret once.

Event types

Event

Fires when

data

order.created

A new order is created from any source: Discord, email or POST /api/orders.

{ order, source }

order.merged

A message or manual create matches an existing order's number and is merged into it.

{ order, source }

order.updated

PATCH /api/orders/:id changes at least one field.

{ order, patch }

order.shipped

A shipping message arrives for an order not yet shipped, a PATCH adds a tracking number to an ordered order, or a carrier lookup first sees scans on an ordered order.

{ order, source } from messages and PATCH (source is manual), or { order, carrier } from tracking

order.delivered

A delivery message arrives for an order not yet delivered, or a carrier lookup first confirms delivery.

{ order, source } or { order, carrier }

tracking.refreshed

A live carrier lookup returns data, from the background tracker or an API call. Cached answers don't fire it.

{ order, carrier, delivered, eventCount }

share.created

A share card is created.

{ share }

  • order is the full order object after the change.

  • source is the channel: discord, email or manual.

  • patch lists the fields the PATCH applied, plus status when a tracking number moved the order to shipped, and the updated userEdited. A cleared sale price appears as "salePriceCents": null.

  • carrier is a carrier key: ups, fedex, usps, dhl or dhl-express. delivered is a boolean; eventCount the number of carrier scans.

One message can produce several events: a shipping email for a known order fires order.merged and then order.shipped. Repeated tracking lookups don't repeat order.shipped or order.delivered; they fire only on a real status change.

These never fire events: mailbox rebuilds and other history backfills, POST /api/orders/reparse and DELETE /api/orders/:id.

Create a subscription

You can do this in the dashboard or through the API.

  1. Open the Developer tab

    Go to Settings → Developer and scroll to Outbound webhooks.

  2. Enter your endpoint

    Type your endpoint's URL into Receiver URL, for example https://hooks.example.com/shippified.

  3. Pick events

    Under Event types, tick the events you want. Order created, Order shipped and Order delivered are ticked by default. The dashboard can't change them after creation; use PATCH (see Pause, resume and edit) or create a new subscription.

  4. Create it and save the secret

    Click Create subscription. The signing secret (whsec_…) is shown once: copy it into your receiver's configuration before leaving the page.

The Outbound webhooks card with a receiver URL field, event-type checkboxes and one subscription listed with Test, Pause and Rotate buttons

Body field

Type

Required

Notes

url

string

Yes

http:// or https://, up to 2,048 characters. The hostname must resolve, and every address it resolves to must be public. See Private addresses are refused.

eventTypes

string[]

Yes

A non-empty list of the event types above. Duplicates are removed.

active

boolean

No

Defaults to true.

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…"
}

secret is returned only here and in the response to a rotation. Store it now. Anyone who has it can forge deliveries your receiver will accept.

Shippified keeps the secret encrypted so it can sign deliveries across restarts and deploys; neither the encrypted value nor a hash is ever returned. Each account can have 25 subscriptions.

Subscription fields

Field

Description

id

whsub_…

url

Where deliveries go.

prefix

The first 14 characters of the secret (whsec_ + 8), to tell subscriptions apart.

eventTypes

Events this subscription receives.

active

false means paused. Events that fire while paused are dropped, not queued.

failureCount

Consecutive failed deliveries. Reset by a success, by PATCH with active: true, or by a rotation.

lastFiredAt

Time of the latest delivery attempt, successful or not.

lastError

Error from the latest failure, such as HTTP 500, timeout or a network error.

createdAt

Creation time.

needsSecretRotation

true when no signing secret is stored. See Subscriptions that need a new secret.

What a delivery looks like

Every delivery is an HTTP POST with a JSON body.

Headers

Header

Value

Content-Type

application/json

User-Agent

shippified-webhooks/1.0

X-Shippified-Event

The event type, such as order.shipped.

X-Shippified-Event-Id

The envelope id.

X-Shippified-Delivery

A random ID for this attempt. Not sent on tests.

X-Shippified-Signature

sha256=<hex HMAC-SHA256 of the raw body>

X-Shippified-Test

true, only on deliveries from the test endpoint.

Body

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

id

Unique event ID (evt_…). Every subscription that receives the event gets the same id. Use it to deduplicate.

type

The event type.

createdAt

When the event happened (ISO 8601, UTC).

userId

The workspace owner.

data

The event's payload from Event types.

Verify the signature

The signature is an HMAC-SHA256 of the exact raw body:

  • Key: the whole secret string, whsec_ prefix included, as UTF-8 bytes. Don't base64-decode it.

  • Output: lowercase hex, prefixed with sha256=.

  1. Read the raw body

    Get the body bytes before parsing JSON. Parsing and re-serialising changes the bytes and the check fails.

  2. Compute the expected value

    "sha256=" + hex(HMAC_SHA256(key = secret, message = rawBody))

  3. Compare in constant time

    Compare with the X-Shippified-Signature header using a timing-safe comparison.

  4. Reject mismatches

    Answer 401 and do nothing else. Answer 2xx quickly for valid deliveries, and do slow work afterwards.

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);
}

// express.raw keeps the exact bytes for this route.
app.post("/shippified", express.raw({ type: "application/json" }), (req, res) => {
  if (!isValidSignature(req.body, req.get("X-Shippified-Signature"), SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Deduplicate on event.id, then queue the work.
  console.log(event.type, event.id);
  res.sendStatus(204);
});

app.listen(3000);

The signature covers only the body and there's no timestamp in the signed data, so a signature alone doesn't stop replays. Remember the event ids you've processed, and drop events whose createdAt is older than you're willing to accept.

Delivery rules

  • Timeout: each delivery must finish within 5 seconds, or it counts as failed (lastError: "timeout").

  • Success is any 2xx. Redirects aren't followed: 3xx is a failure (HTTP 301, HTTP 302…). Use the final URL.

  • No retries. Each event is sent once per subscription. A failed delivery isn't retried or queued.

  • No ordering guarantee. Deliveries run concurrently, so order.shipped can arrive before the order.merged that came with it.

  • Auto-pause. Each failure increments failureCount and records lastError. At 10 consecutive failures the subscription is set to active: false; the dashboard shows "Paused — auto-paused after 10 consecutive failures. Fix your receiver, then resume."

  • Address check when sending. The address each connection actually goes to is checked again at send time, so a hostname that later starts resolving to a private address fails instead of reaching it.

Treat webhooks as notifications, not a complete record: anything missed during an outage is gone. If you need every change, reconcile periodically with GET /api/orders (for example with from) and use webhooks to know when to look.

Test a subscription

POST /api/webhook-subscriptions/:id/test sends a test event right now and returns the result. Tests don't change failureCount or lastError. In the dashboard, click Test on the subscription; it sends the subscription's first event type and shows "Receiver returned 204." or "Failed: …".

Body field

Notes

eventType

Optional. Defaults to the subscription's first event type. Must be a valid event type, or 400 {"error": "Invalid eventType for test"}.

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 }

A test uses the normal envelope and signature, adds X-Shippified-Test: true, has an id starting evt_test_, and carries "data": { "test": true } instead of a real order. Make your handler ignore or accept test events explicitly. A failed test still returns 200, with { "ok": false, "status"?: <code>, "error": "…" }.

Pause, resume and edit

PATCH /api/webhook-subscriptions/:id changes only the fields you send, validated as on create. Sending "active": true also resets failureCount to 0, so a subscription you turn back on after an auto-pause starts clean. The dashboard's Pause / Resume button does the same.

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.

Rotate the signing secret

POST /api/webhook-subscriptions/:id/rotate-secret returns the subscription with a new secret, shown once. It also resets failureCount, clears lastError, sets active: true and clears needsSecretRotation, so it's the one-step recovery for a paused subscription too. In the dashboard, click Rotate and confirm Rotate secret.

curl -s -X POST https://shippified.net/api/webhook-subscriptions/whsub_m1x2y3z4_k9d2q/rotate-secret \
  -H "Authorization: Bearer $SHIPPIFIED_API_KEY"

The old secret stops working immediately; there's no overlap. To avoid rejecting good deliveries, let your receiver accept both the old and new secret for a moment while you switch.

Subscriptions that need a new secret

Subscriptions created before Shippified started storing secrets have none, and come back with "needsSecretRotation": true:

  • Their deliveries are held: events are skipped for that subscription (not queued), and the skips don't count toward auto-pause.

  • The test endpoint returns { "ok": false, "error": "Signing secret unavailable — this subscription predates stored secrets. Rotate the secret to resume deliveries." }.

  • The dashboard says "Deliveries are on hold: this subscription's signing secret needs to be regenerated." with a Rotate secret now button.

Rotate the secret and put the new one in your receiver. If a stored secret ever can't be decrypted on the server, deliveries are skipped the same way and lastError says to rotate.

Delete a subscription

DELETE /api/webhook-subscriptions/:id returns 200 {"ok": true} or 404. It's immediate. In the dashboard the bin button asks "Delete this webhook subscription?" and deletes on Delete subscription.

Errors

Situation

Status

error

Bad url

400

url is required, url is malformed, url must be http(s), url exceeds 2048 characters, url hostname does not resolve, url cannot point at private / loopback addresses

Bad eventTypes

400

eventTypes must be a non-empty array, eventTypes entries must be strings, Unknown event type: <type>

Bad test eventType

400

Invalid eventType for test

26th subscription

429

Subscription limit reached (25).

Unknown ID

404

Subscription not found

Server can't encrypt secrets

503

Webhook signing is unavailable: … (create and rotate only)

Private addresses are refused

Shippified makes 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 (outputWebhook on /api/bots) and the workspace Discord webhooks in settings (outputWebhook, updatesWebhook on POST /api/settings).

Refused: 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 those IPv4 addresses. Every resolved address is checked, and 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.

Troubleshooting

Test says "Failed: getaddrinfo ENOTFOUND …"

The receiver's hostname doesn't resolve from Shippified's server. Check the spelling and that the host is public.

My receiver rejects every signature

You're probably hashing parsed-and-re-serialised JSON. Hash the raw body bytes. Also check you use the whole secret including whsec_, and that you updated it after the last rotation.

Deliveries stopped

Check active and lastError. After 10 failures in a row the subscription pauses itself; fix the receiver and resume it (or rotate). A 3xx answer counts as a failure, as does anything slower than 5 seconds.

The public feed

Not a subscription, but the other place your orders can be posted: 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 has only the item name, store, price and product image, always 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 it.

Was this page helpful?