Webhooks

AdminUpdated Sep 24, 2026

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

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 right away and return the result.

POST

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

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

data payload

order.created

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

{ order, source }

order.merged

An incoming message or manual create matches an existing order's orderNumber and is merged into it.

{ order, source }

order.updated

PATCH /api/orders/:id changes at least one field. A PATCH that changes nothing fires no event.

{ order, patch }

order.shipped

An incoming shipping message moves an order to shipped for the first time, a PATCH adds a tracking number to an ordered order, or a carrier lookup first sees scans on an ordered order.

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

order.delivered

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

{ order, source } from intake, or { order, carrier } from tracking

tracking.refreshed

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

{ order, carrier, delivered, eventCount }

share.created

A share card is created.

{ share }

Field notes:

  • order is the full order object after the change.

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

  • patch contains the fields the PATCH actually applied, plus status when adding a tracking number moved the order to shipped and the updated userEdited list. 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 is 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, and POST /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, and DELETE /api/orders/:id.

Create a subscription

POST /api/webhook-subscriptions

Body field

Type

Required

Notes

url

string

Yes

Must be http:// or https://, at most 2,048 characters. The hostname is resolved, and the URL is rejected if it doesn't resolve or if any address it resolves to is private or internal. See Private addresses are refused.

eventTypes

string[]

Yes

A non-empty list of the event types above. Duplicates are removed. An unknown type returns 400.

active

boolean

No

Defaults to true.

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: secret is 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

id

whsub_…

url

Where deliveries are sent.

prefix

First 14 characters of the secret.

eventTypes

The events this subscription receives.

active

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

failureCount

Consecutive failed deliveries. Reset to 0 by a successful delivery, by PATCH with active: true, or by a secret rotation.

lastFiredAt

Time of the most recent delivery attempt, successful or not.

lastError

Error from the most recent failure, such as HTTP 500 or a network error.

createdAt

Creation time.

needsSecretRotation

true when the subscription has no stored signing secret and gets no deliveries until you rotate it. See Subscriptions that need a new secret.

Delivery format

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, e.g. order.shipped.

X-Shippified-Event-Id

The envelope id.

X-Shippified-Delivery

Random hex ID for this delivery attempt. Not sent on test deliveries.

X-Shippified-Signature

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

X-Shippified-Test

true. Sent only on deliveries from the test endpoint.

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

id

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

type

The event type.

createdAt

When the event was emitted (ISO 8601 UTC).

userId

The workspace owner.

data

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:

  1. Read the raw body bytes before parsing JSON. Parsing and re-serializing changes the bytes, and the check will fail.

  2. Compute sha256= + hex(HMAC_SHA256(key = secret, message = rawBody)).

  3. Compare the result with X-Shippified-Signature using a constant-time comparison.

  4. Reject the request with 401 if 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 "", 204

TypeScript 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 whose createdAt is 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 2xx status. Redirects are not followed: a 3xx response 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.shipped 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. Fix the receiver, then turn it back on with PATCH {"active": true}, which also resets failureCount to 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 on from) 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 failureCount or 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

eventType

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

error

Missing or invalid 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

Invalid eventTypes

400

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

Invalid test eventType

400

Invalid eventType for test

Subscription cap reached

429

Subscription limit reached (25).

Unknown ID

404

Subscription not found

Server can't encrypt signing secrets

503

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

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 (outputWebhook on POST and PATCH /api/bots),

  • the workspace-level Discord webhooks in settings (outputWebhook and updatesWebhook on POST /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

Was this page helpful?
Webhooks