Webhooks reference

Subscribe to event notifications, verify signatures, and inspect deliveries.

Last updated May 8, 2026

Webhooks let you receive event notifications instead of polling. You register an HTTPS URL, subscribe to one or more event types, and we POST a JSON payload to that URL when an event fires. Each delivery is signed with HMAC-SHA256 so you can verify it came from us.

#What webhooks are

A webhook is a one-way HTTP POST from us to a URL you control. When something happens on your account — an order ships, an invite is redeemed — we serialize the event as JSON and POST it to your subscribed URL. You verify the signature, parse the body, and act on it.

Delivery is fire-and-forget: we send once, with a 10-second timeout, and we don't retry. Plan for that — see Delivery transport below.

#Subscribable events

Eight event types are subscribable.

| Event | When it fires | | --- | --- | | order.created | A new fulfillment order is created — by campaign redemption, by POST /v1/orders/create, or by a sync job that imports new orders from a fulfillment provider. | | order.shipped | An order's status transitions to SHIPPED. | | order.delivered | Delivery is confirmed for an order. | | order.cancelled | An order is cancelled. | | order.returned | A return is detected for an order. | | invite.viewed | An invite recipient lands on the redemption page. | | invite.cart_updated | An invite recipient adds, updates, or removes a cart item on the redemption page. The specific action is in eventDetails.eventType (CART_ADD, CART_UPDATE, CART_REMOVE). | | invite.redeemed | An invite recipient completes their order. |

There is also a webhook.test event, but you cannot subscribe to it. It is emitted only by POST /v1/webhooks/{id}/test (see Testing below) and is sent to that single webhook regardless of its subscribed event list. Use it to verify your endpoint setup.

#Subscription management endpoints

| Method | Path | Description | | --- | --- | --- | | GET | /v1/webhooks/events | List the eight subscribable event types with descriptions. | | GET | /v1/webhooks | List the account's webhook subscriptions. Secrets are not returned. | | POST | /v1/webhooks | Create a subscription. Returns the signing secret (shown once). | | PUT | /v1/webhooks/{id} | Update url, events, or active. | | DELETE | /v1/webhooks/{id} | Permanently delete a subscription. | | POST | /v1/webhooks/{id}/test | Send a webhook.test event to the subscription's URL. | | POST | /v1/webhooks/{id}/rotate-secret | Generate a new signing secret. The previous secret is invalidated immediately. |

#Authentication

Standard Bearer auth — see Authentication and API keys.

#URL requirements

The URL you register on POST /v1/webhooks and PUT /v1/webhooks/{id} is validated server-side. It must:

  • Be a syntactically valid URL.
  • Use HTTPS. http:// is rejected.
  • Not be localhost, 127.0.0.1, 0.0.0.0, or ::1.
  • Not be in private/internal IP space: 10.0.0.0/8, 172.16.0.0/12 (i.e. 172.16 through 172.31), 192.168.0.0/16, or link-local 169.254.0.0/16.

A URL that fails validation returns 400 Bad Request with the message Webhook URL must use HTTPS and cannot point to localhost or internal/private addresses.

#Listing event types

GET /v1/webhooks/events

Returns the catalog of subscribable events with descriptions and category. No account-scoped data — the response is the same for every caller.

curl -X GET 'https://api.merch.com/v1/webhooks/events' \
  -H 'Authorization: Bearer <your_api_key>'

#Subscribing

POST /v1/webhooks

Body:

{
  "url": "https://example.com/webhooks/merch",
  "events": ["order.created", "order.shipped"]
}

Both fields are required. events must be a non-empty array of valid event names.

Success returns 201:

{
  "id": "<webhook_id>",
  "url": "https://example.com/webhooks/merch",
  "events": ["order.created", "order.shipped"],
  "active": true,
  "secret": "<your_signing_secret>",
  "createdAt": "2026-05-08T16:42:00.000Z",
  "updatedAt": "2026-05-08T16:42:00.000Z"
}
curl -X POST 'https://api.merch.com/v1/webhooks' \
  -H 'Authorization: Bearer <your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://example.com/webhooks/merch",
    "events": ["order.created", "order.shipped"]
  }'

#Listing subscriptions

GET /v1/webhooks

Returns all active and inactive subscriptions on the account. Secrets are not included.

{
  "webhooks": [
    {
      "id": "<webhook_id>",
      "url": "https://example.com/webhooks/merch",
      "events": ["order.created", "order.shipped"],
      "active": true,
      "createdAt": "2026-05-08T16:42:00.000Z",
      "updatedAt": "2026-05-08T16:42:00.000Z"
    }
  ]
}
curl -X GET 'https://api.merch.com/v1/webhooks' \
  -H 'Authorization: Bearer <your_api_key>'

#Updating

PUT /v1/webhooks/{id}

Body accepts any subset of url, events, and active. Fields you omit are unchanged.

{
  "url": "https://example.com/webhooks/merch-v2",
  "events": ["order.created", "order.shipped", "order.delivered"],
  "active": true
}

Returns 200 with the updated subscription (without secret).

curl -X PUT 'https://api.merch.com/v1/webhooks/<webhook_id>' \
  -H 'Authorization: Bearer <your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{ "active": false }'

#Deleting

DELETE /v1/webhooks/{id}

Permanently removes the subscription. There is no soft-delete and no recovery — create a new subscription if you need it back.

Returns 200:

{ "message": "Webhook deleted successfully" }
curl -X DELETE 'https://api.merch.com/v1/webhooks/<webhook_id>' \
  -H 'Authorization: Bearer <your_api_key>'

#Testing

POST /v1/webhooks/{id}/test

Sends a webhook.test event to the subscription's URL with a valid HMAC signature. Use this to validate your endpoint setup before going live, and as a quick smoke test after rotating a secret.

The webhook must be active. The body of the test delivery is documented under Payload — webhook.test.

Returns 200 with a delivery summary:

{
  "success": true,
  "statusCode": 200,
  "error": null
}

success reflects whether your endpoint accepted the request. If it returned a non-2xx, success is false, statusCode is the HTTP status your server returned (or null if the request never reached your server), and error is a short string describing the failure.

curl -X POST 'https://api.merch.com/v1/webhooks/<webhook_id>/test' \
  -H 'Authorization: Bearer <your_api_key>'

This is the only inspection surface for delivery — there is no separate logs API and no replay endpoint. If you want to confirm a real event was processed correctly, send a test event, observe your server-side logs, and compare against the live delivery's X-Webhook-Event header.

#Rotating the secret

POST /v1/webhooks/{id}/rotate-secret

Generates a new HMAC signing secret. The previous secret is invalidated immediately — there is no overlap window. Plan a brief switchover: rotate, capture the new secret from the response, deploy it to your verifier, and confirm with a test event.

Returns 200 with the subscription including the new secret (the only other place a secret is returned).

curl -X POST 'https://api.merch.com/v1/webhooks/<webhook_id>/rotate-secret' \
  -H 'Authorization: Bearer <your_api_key>'

#Delivery transport

  • Method: POST to your subscribed URL.
  • Content-Type: application/json.
  • Timeout: 10 seconds per attempt.
  • Retries: None. Delivery is fire-and-forget. If your endpoint is down, slow, or returns a non-2xx, the event is dropped. Failures are logged on our side but no retry is attempted.
  • Ordering: No ordering guarantees. When an event fires for an account with multiple matching subscriptions, deliveries fan out in parallel and may complete in any order.

Because there are no retries, your subscriber should:

  1. Acknowledge fast — return a 2xx as soon as you've durably enqueued the payload, then process asynchronously.
  2. Make processing idempotent. The same orderId may appear in multiple events (order.created, then order.shipped); the same event may legitimately re-fire after some operations.
  3. Treat webhooks as best-effort notifications. For state you must not lose (e.g. final order status), re-fetch via the REST API after a webhook tells you something interesting happened, or run a periodic reconciliation.

#Headers on every delivery

Content-Type:        application/json
X-Webhook-Signature: sha256=<hex>
X-Webhook-Timestamp: <unix seconds>
X-Webhook-Event:     <event name, e.g. order.shipped>

#Signature verification

Every delivery is signed with HMAC-SHA256 using your subscription's secret.

  • Algorithm: HMAC-SHA256.
  • Signed content: ${timestamp}.${rawBody} — the value of X-Webhook-Timestamp, then a literal period, then the raw request body as we sent it. Re-serializing the JSON on your end will not match — verify against the bytes you actually received.
  • Header value: literal prefix sha256= followed by the hex digest. Example: X-Webhook-Signature: sha256=abc123def456....

Verification recipe:

signed_content = X-Webhook-Timestamp + "." + raw_request_body
expected       = "sha256=" + hex( hmac_sha256(secret, signed_content) )
constant_time_compare(expected, X-Webhook-Signature)

Use a constant-time comparison to defeat timing attacks — most languages provide one (hmac.compare_digest, crypto.timingSafeEqual, etc.).

We also recommend rejecting requests whose X-Webhook-Timestamp is more than five minutes in the past or future. The server does not enforce this — it's your replay protection. Five minutes is a reasonable default; tighten if your clocks are well-synced.

A bash equivalent for one-off verification at the command line:

SECRET="<your_signing_secret>"
TIMESTAMP="$(printf '%s' "$X_WEBHOOK_TIMESTAMP")"
BODY="$(cat raw_body.json)"
EXPECTED="sha256=$(printf '%s.%s' "$TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | awk '{print $2}')"
echo "expected: $EXPECTED"
echo "actual:   $X_WEBHOOK_SIGNATURE"

If expected and actual differ, drop the request — do not process the body.

#Payload — order events

Body for order.created, order.shipped, order.delivered, order.cancelled, and order.returned:

{
  "event": "order.shipped",
  "data": {
    "orderId": "<order_id>",
    "orderNumber": "ORD-12345",
    "status": "SHIPPED",
    "campaignId": "<campaign_id>",
    "trackingNumber": "1Z999AA10123456784",
    "carrierCode": "ups",
    "shipDate": "2026-05-08T16:42:00.000Z",
    "products": [
      { "sku": "TEE-BLK-M", "quantity": 1 }
    ]
  }
}

trackingNumber, carrierCode, and shipDate are null for events that fire before shipment (e.g. order.created, order.cancelled).

#Payload — invite events

Body for invite.viewed, invite.cart_updated, and invite.redeemed:

{
  "event": "invite.cart_updated",
  "data": {
    "inviteId": "<invite_id>",
    "inviteNumber": "000123",
    "email": "[email protected]",
    "campaignId": "<campaign_id>",
    "campaignTitle": null,
    "eventDetails": {
      "eventType": "CART_ADD",
      "page": "redemption",
      "cartItems": [
        { "sku": "TEE-BLK-M", "quantity": 1 }
      ]
    }
  }
}

Notes:

  • campaignTitle is currently always null in dispatched payloads. Don't depend on a value here.
  • eventDetails shape varies by event:
    • invite.viewed — typically null or contains the page identifier.
    • invite.cart_updatedeventType is CART_ADD, CART_UPDATE, or CART_REMOVE. Includes page and cartItems.
    • invite.redeemedeventType is ORDER_COMPLETE. May include cartItems for the completed order.

inviteNumber and email may be null for invites that don't have those fields populated.

#Payload — webhook.test

Body for the webhook.test event, which is sent only by POST /v1/webhooks/{id}/test:

{
  "event": "webhook.test",
  "data": {
    "message": "This is a test webhook event. If you received this, your endpoint is configured correctly.",
    "timestamp": "2026-05-08T16:42:00.000Z"
  }
}

The headers and signing are identical to live events — your verifier should accept webhook.test deliveries the same way it accepts the rest.

#Errors on management endpoints

The management endpoints (subscribe, list, update, delete, test, rotate) emit:

  • 400 Bad Request — invalid URL (validation failure), invalid event name, duplicate URL on the account, or missing required fields on create. The body is { "message": "..." }.
  • 401 Unauthorized — missing, malformed, or invalid bearer token. See Authentication and API keys.
  • 500 Internal Server Error — unexpected error not surfaced as 400. Body is { "message": "Internal server error" }.

Ready to elevate your merch?

Custom design, production, campaigns, and global fulfillment — one partner, zero platform fees. Your custom proposal in 24 hours.