Event Subscriptions

Version 1.7 · Updated 2026-10-09

An event subscription tells WHCC to POST order events to a URL you choose. You create and manage subscriptions yourself with your API key. An account can hold several, each with its own URL, event selection, and signing secret.

Setting one up takes four steps:

  1. Create the subscription and store the signing secret from the response. It isn't shown again.

  2. Deploy a handler that verifies the WHCC-Signature header with that secret. While you develop, a tunnel can stand in for a deployed endpoint. See Testing from your own machine.

  3. Send a test event to check the handler.

  4. Enable the subscription. A new subscription is created disabled and receives nothing until you do.

Base URL

https://apis-stage.whcc.com/api/v1/fulfillment

Sandbox. The examples on this page use this host.

https://apis.whcc.com/api/v1/fulfillment

Production. Subscriptions here receive events for real orders.

Every path on this page is relative to that prefix, so /event-subscriptions means https://apis-stage.whcc.com/api/v1/fulfillment/event-subscriptions. Requests and responses are application/json.

Authentication

Send your API key as a bearer token. It's the same key, with the same apikey scope, as the rest of the WHCC API. A key without the scope returns 403. See Refusals.

You never send an account identifier. WHCC resolves your account from the key, and a request body that names an account is refused with 400.

Operations

Method Path What it does
POST/event-subscriptionsCreate a subscription
GET/event-subscriptionsList your subscriptions
GET/event-subscriptions/{id}Read one subscription
PUT/event-subscriptions/{id}Replace a subscription's URL, event types, and description
POST/event-subscriptions/{id}/enableStart delivery
POST/event-subscriptions/{id}/disableStop delivery
DELETE/event-subscriptions/{id}Delete a subscription for good
POST/event-subscriptions/{id}/testQueue a test event
POST/event-subscriptions/{id}/rotate-secretRotate the signing secret
GET/event-subscriptions/event-typesThe event-type catalog
GET/event-subscriptions/{id}/deliveriesThe delivery log. Not available yet

POST /event-subscriptions/{id}/rotate-secret rotates the signing secret in place.

The subscription

Create, retrieve, replace, enable, and disable all return this shape. List returns it inside items.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
	"id": "SUB_7Kq2mZ1x9PdR4t",
	"accountNo": 1114,
	"destination": {
		"type": "webhook",
		"callbackUri": "https://path.to.your/webhook/endpoint",
		"version": 2
	},
	"eventTypes": ["order.accepted", "order.rejected", "order.shipped"],
	"description": "Production endpoint",
	"status": "disabled",
	"statusDetail": null,
	"lastDeliveryError": null,
	"createdAt": "2026-09-14T10:12:04Z",
	"modifiedAt": "2026-09-14T10:12:04Z"
}
Field Type Description
id string The subscription's identifier and the {id} in every path. It's opaque and case-sensitive, so store it exactly as returned.
accountNo integer The account the subscription belongs to, resolved from your key. Read-only.
destination.type string Always "webhook" today. Treat it as an open set when reading: a value your client doesn't recognize must not fail the response.
destination.callbackUri string The URL deliveries are POSTed to.
destination.version integer The payload version the subscription receives: 2 (JSON) or 1 (form-encoded). See Version 1 subscriptions.
eventTypes string[] The event types the subscription receives, or the single entry ["*"] for all of them.
description string or null Your own label, up to 255 characters. WHCC doesn't interpret it.
status string "disabled", "active", or "suspended". Only an "active" subscription receives events. Treat it as an open set too: branch on "active" and treat anything else as not delivering. See Status.
statusDetail object or null null on a new or active subscription. Otherwise { "reason", "since" }, where reason is "user" when you disabled it and "deliveryFailures" when WHCC suspended it, and since is an ISO 8601 UTC time.
lastDeliveryError object or null null, or { "at", "statusCode", "reason" } for the most recent failed delivery attempt.
createdAt, modifiedAt string ISO 8601, always UTC.
signingSecret string Present on the create response only. Every other response leaves the field out. See The signing secret.

Status

status Receives events How it gets there statusDetail
"disabled" No Create, or disable null after create, reason: "user" after disable
"active" Yes Enable, from either other status null
"suspended" No WHCC, after 20 consecutive failed delivery attempts. No request of yours can set it reason: "deliveryFailures"

When WHCC suspends a subscription

After 20 consecutive failed delivery attempts, WHCC suspends the subscription. It stops receiving events and stays that way until you enable it again. Nothing notifies you. You find out by reading the subscription: status is "suspended", statusDetail.since says when, and lastDeliveryError carries the status code and reason of the last failed attempt.

To recover, fix your endpoint, confirm it answers a POST with a 2xx, then call enable. Enable is the only way out of "suspended". It clears statusDetail and resets the failure count. Events from the time the subscription was suspended or disabled aren't sent later.

A silent endpoint looks the same as no orders. If your subscriptions run unattended, poll GET /event-subscriptions on a schedule and alert on any that is "suspended".

Creating a subscription

POST /event-subscriptions returns 201 Created with the subscription and its signing secret. Send an Idempotency-Key header so that a retry returns the same subscription and secret instead of creating a second one.

Field Required Description
destination.type Yes "webhook". Leaving it out returns 400. Any other value returns 422 (unsupported_destination_type).
destination.callbackUri Yes An absolute https URL of at most 2048 bytes, with no user:pass@ prefix. Anything else returns 422 (invalid_callback_uri). Leaving destination.callbackUri out altogether returns 400 (malformed_request). See Callback URL rules.
destination.version No 1 or 2. Defaults to 2. Version 1 requires a key that carries apikey:oas. A key without it returns 403 (insufficient_scope), so ask WHCC for a key with the scope. Any other value returns 422 (invalid_webhook_version). See Version 1 subscriptions.
eventTypes No An array of event types. Defaults to ["*"].
description No Up to 255 characters of free text.
signingSecret No Your own secret. Leave it out and WHCC generates one. An empty string returns 400. See The signing secret.
acknowledgeIncompleteCoverage No true allows order.accepted without order.rejected. It applies to this request only and is never stored or returned.

Unknown fields are refused with 400 (malformed_request), not ignored. That includes accountNo or any other account identifier, a top-level webhookVersion (the version lives in destination.version), and the read-only id, status, statusDetail, and createdAt.

Example Request

1
2
3
4
5
6
7
8
9
10
11
12
curl https://apis-stage.whcc.com/api/v1/fulfillment/event-subscriptions \
	-H "Authorization: Bearer <YOUR_API_KEY>" \
	-H "Content-Type: application/json" \
	-H "Idempotency-Key: $(uuidgen)" \
	-d '{
			"destination": {
				"type": "webhook",
				"callbackUri": "https://path.to.your/webhook/endpoint"
			},
			"eventTypes": ["order.accepted", "order.rejected", "order.shipped"],
			"description": "Production endpoint"
		}'

Example Response: 201 Created

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
	"id": "SUB_7Kq2mZ1x9PdR4t",
	"accountNo": 1114,
	"destination": {
		"type": "webhook",
		"callbackUri": "https://path.to.your/webhook/endpoint",
		"version": 2
	},
	"eventTypes": ["order.accepted", "order.rejected", "order.shipped"],
	"description": "Production endpoint",
	"signingSecret": "SsG9b0p0r-wzKrddbsaB17lZSBnKVkmA4TB5DgyCbzw",
	"status": "disabled",
	"statusDetail": null,
	"lastDeliveryError": null,
	"createdAt": "2026-09-14T10:12:04Z",
	"modifiedAt": "2026-09-14T10:12:04Z"
}

Store signingSecret before you do anything else. Then note that status is "disabled": nothing is delivered until you enable the subscription.

The signing secret

Each subscription has its own signing secret, and WHCC signs every delivery to that subscription with it. Verifying a delivery below shows how to check the signature with it.

If you supply one, it must be 32 to 255 printable ASCII characters with no whitespace. WHCC never trims, pads, or re-encodes it. A secret that breaks those rules returns 422 (invalid_signing_secret) and no subscription is created.

If you leave it out, WHCC generates 32 random bytes and encodes them as unpadded base64url. The result is always 43 characters from A–Z, a–z, 0–9, -, and _. Those 43 characters are the HMAC key, used as an exact string. Don't pass the value through a base64 library that re-pads it: 44 characters ending in = is a different key and won't match any signature.

It is shown once. The secret appears in the 201 from create and in an idempotent replay of that create. No other response carries it. WHCC keeps the secret so that it can sign deliveries, which means this is not the hashed, unrecoverable storage your API key gets. If your security requirements say WHCC must never generate the secret, supply your own.

If you lose the secret, rotate it.

Rotating the signing secret

POST /event-subscriptions/{id}/rotate-secret mints a fresh secret and returns the subscription with it, shown once, like a create. The secret it replaces keeps signing for an overlap window, one hour unless you send expiresIn in seconds (one hour to 30 days). During the window every delivery carries two v1 values in WHCC-Signature, the fresh secret's first and the previous one's second, and either verifies. After the window only the fresh secret signs.

1
2
3
curl -X POST https://apis-stage.whcc.com/api/v1/fulfillment/event-subscriptions/{id}/rotate-secret \
	-H "Authorization: Bearer <YOUR_API_KEY>" \
	-H "Idempotency-Key: 7b3b1f7e-3c2d-4e8a-9f0a-2d5b6c7e8f90"

Send an Idempotency-Key. If the response is lost, the same key replays the secret this call minted; without one, the only recovery is another rotation after the window closes. A rotation while a window is still open is refused with 409 (rotation_in_progress), and the body's previousSecretExpiresAt says when you can rotate again.

The window is your time to install the new secret, not a guarantee for deliveries already in flight. A delivery is signed when it's first sent and can be retried for up to six and a half days, so one sent before you rotated can arrive after the window with only the old secret. If that matters, ask for a window that covers the retries (expiresIn: 559800 or more), or keep verifying with the old secret on your side until they're exhausted.

Verifying a delivery

Every delivery carries a WHCC-Signature header with a timestamp and one or more signatures, on one line:

1
WHCC-Signature: t=1591735205,v1=307D88AF1425DC58552C1A6EDFB4C95E3E989F5B878CAFFEFFA6D581578DC82A

To verify it:

  1. Split the header on ,, then split each element on its first =. The t element is the timestamp. Collect the value of every v1 element into a list: during a secret rotation there can be two. Ignore any other prefix. Don't parse the header into a map, which keeps only one v1.
  2. Build the signed string: the timestamp, the character ., then the raw request body exactly as received. Don't parse and re-serialize the body first; any change to whitespace or field order changes the signature.
  3. Compute HMAC-SHA256 over that string, keyed with the subscription's signingSecret used as an exact string (see above for why not to re-encode it). Encode the digest as hex.
  4. Compare your digest with each v1 value using a constant-time comparison, ignoring case: WHCC sends uppercase hex and most libraries produce lowercase. The delivery is genuine if any one matches.
  5. Reject a timestamp older than a tolerance you choose, to shut out replays. WHCC doesn't enforce one; pick something longer than the clock skew and network delay you expect.

A delivery that fails any of these steps should get a 4xx and no further processing; a 4xx is terminal, so WHCC won't retry it. If one endpoint serves more than one subscription, it holds more than one secret and the delivery doesn't say which applies, so try each.

Event types

Event type Delivered as Meaning
order.accepted Event: "Processed", Status: "Accepted" The order passed intake and was accepted for production.
order.rejected Event: "Processed", Status: "Rejected", with Errors[] The order was refused, usually because an image couldn't be read.
order.shipped Event: "Shipped" An order, or part of a split order, shipped.
order.cancelled Event: "Order Cancelled" The order was canceled.

Leaving eventTypes out stores ["*"], which means every type above that the subscription's version carries. It's evaluated at delivery time, so a subscription on ["*"] also receives types WHCC adds later.

Responses always use the dotted names. On input you can also send the legacy Event value where it identifies exactly one type, so ["Shipped"] is stored and returned as ["order.shipped"]. Compare against the dotted name in your code. A value WHCC doesn't recognize returns 422 (unknown_event_type), and the detail suggests the closest match.

order.image-download appears in the catalog with available: false and can't be selected by name. At version 2, subscribe to order.rejected instead. A version 1 subscription on ["*"] receives the image-download body.

Version 1 subscriptions

A subscription with destination.version: 1 receives the form-encoded Version 1 format instead of JSON. Creating one requires a key that carries apikey:oas, which WHCC attaches when it mints a key for an integration that needs it. A key without the scope receives 403 (insufficient_scope) and nothing is created. To get one, ask WHCC for a key with the scope. The version can't be changed after create.

Event type Arrives at version 1 as
order.accepted EventId: order.received
order.shipped EventId: order.shipped
None. It can't be named. The image-download body, EventId: image.upload. It arrives only on a subscription whose eventTypes is ["*"].

order.rejected and order.cancelled have no version 1 representation. Selecting either returns 422 (event_type_unavailable_for_version). The notification a version 2 subscription receives as order.rejected reaches a version 1 subscription on ["*"] as the image-download body (EventId: image.upload). order.cancelled produces no version 1 delivery at all. Selecting order.image-download by name returns 422 (unknown_event_type), and the detail says the body is delivered to a ["*"] subscription. Because order.rejected isn't carried at version 1, the coverage check doesn't apply and acknowledgeIncompleteCoverage isn't needed.

  • Signing: a version 1 subscription is signed with its own signingSecret, not a consumer secret. The signed content is the entire form-encoded body line, and the header can carry two v1= elements while a secret is being replaced. See Why "one or more".

  • Test sends: POST /event-subscriptions/{id}/test on a version 1 subscription delivers a form-encoded body for order.accepted, and for order.shipped where the environment allows it. See the limit on testing order.shipped.

  • No verification step: like every subscription, a version 1 subscription is created disabled and receives nothing until you enable it.

order.accepted without order.rejected

Selecting order.accepted without order.rejected is refused, because a rejected order would then produce no event and look the same as one still in progress:

1
2
3
4
5
6
7
8
{
	"type": "about:blank",
	"title": "Unprocessable Entity",
	"status": 422,
	"detail": "order.accepted without order.rejected means the outcome order.rejected reports produces no event. Add order.rejected, or set acknowledgeIncompleteCoverage: true.",
	"code": "incomplete_event_coverage",
	"missing": ["order.rejected"]
}

Add order.rejected, which is right in nearly every case, or send acknowledgeIncompleteCoverage: true with the same body. The acknowledgment isn't stored, so a later replace that still leaves the gap has to send it again. Only this one pairing is checked. order.rejected on its own is accepted, and order.shipped and order.cancelled are never part of the check.

Reading the catalog

GET /event-subscriptions/event-types returns the same catalog as an API response, so you can read it at runtime. It takes no parameters and is the same for every account.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
	"items": [
		{
			"eventType": "order.shipped",
			"wireEvent": "Shipped",
			"available": true,
			"versions": [1, 2],
			"seeAlso": null,
			"unavailableReason": null
		},
		{
			"eventType": "order.image-download",
			"wireEvent": "ImageDownload",
			"available": false,
			"versions": [1],
			"seeAlso": "order.rejected",
			"unavailableReason": "No V2 payload exists for this event..."
		}
	]
}

eventType

The dotted name you put in eventTypes.

wireEvent

The Event value the delivered body carries.

available

Whether the type can be selected. Check this first. An unavailable type can still list versions.

versions

The payload versions that carry the type. Selecting a type on a subscription whose version isn't listed returns 422 (event_type_unavailable_for_version).

seeAlso

The type to select instead, when there is one.

unavailableReason

Why an unavailable type can't be selected. null when available is true.

Listing and reading

GET /event-subscriptions returns every subscription on your account as { "items": [...] }. GET /event-subscriptions/{id} returns one. Neither includes signingSecret. The list ignores query parameters it doesn't know, unlike the orders list, which returns 400 for them.

1
2
curl https://apis-stage.whcc.com/api/v1/fulfillment/event-subscriptions \
	-H "Authorization: Bearer <YOUR_API_KEY>"

An {id} that isn't a subscription on your account returns 404 (subscription_not_found) on every route here. The answer is the same whether the subscription belongs to another account or doesn't exist.

Replacing a subscription

PUT /event-subscriptions/{id} replaces the subscription's URL, event types, and description, and returns 200 with the updated subscription. It never returns or changes the signing secret.

This is a replace, not a patch. Only destination is required, and a field you leave out is reset, not kept:

destination.callbackUri

Required, and checked exactly as on create.

destination.version

Can't be changed. Leave it out, or send the subscription's current value. A different value returns 422 (destination_version_immutable).

eventTypes

Left out, it becomes ["*"]: every event type, not the selection the subscription had.

description

Left out, it's cleared.

acknowledgeIncompleteCoverage

Needed again on every replace that leaves the coverage gap.

Read the subscription first and send back everything you mean to keep.

1
2
3
4
5
6
7
8
9
10
11
curl -X PUT https://apis-stage.whcc.com/api/v1/fulfillment/event-subscriptions/SUB_7Kq2mZ1x9PdR4t \
	-H "Authorization: Bearer <YOUR_API_KEY>" \
	-H "Content-Type: application/json" \
	-d '{
			"destination": {
				"type": "webhook",
				"callbackUri": "https://path.to.your/new/endpoint"
			},
			"eventTypes": ["order.shipped"],
			"description": "Production endpoint, relocated"
		}'

A body that names id, accountNo, status, or signingSecret returns 400. A replace doesn't change status either: fixing the URL of a suspended subscription leaves it suspended until you enable it.

Enabling and disabling

POST /event-subscriptions/{id}/enable sets status to "active", clears statusDetail, and starts delivery. It works from "disabled" and from "suspended", and enabling a subscription that is already active returns 200.

POST /event-subscriptions/{id}/disable sets status to "disabled" and stops delivery. The subscription and its secret are kept, so enable brings it back.

Neither takes a request body, and both return 200 with the updated subscription.

1
2
curl -X POST https://apis-stage.whcc.com/api/v1/fulfillment/event-subscriptions/SUB_7Kq2mZ1x9PdR4t/enable \
	-H "Authorization: Bearer <YOUR_API_KEY>"

Deleting a subscription

DELETE /event-subscriptions/{id} returns 204 No Content. The subscription and its signing secret are gone for good. Use disable if you only want to pause.

Deliveries already queued for a deleted subscription can still be attempted while they work through the retry schedule.

Sending a test event

POST /event-subscriptions/{id}/test queues one event and returns 202 Accepted. WHCC builds, signs, and delivers it through the same pipeline as a real event, so the body, headers, signature, and retries are the real ones. No order is produced, priced, shipped, or changed.

eventType

Required. One dotted event type. It must be one the subscription's eventTypes selects, since a real event of another type wouldn't arrive there either. Otherwise the request returns 422 (event_type_not_selected).

confirmationId

One of your own orders to build the event from, so the webhook carries its real ConfirmationId, EntryId, Reference, and order number. Leave it out and WHCC uses a synthetic order it keeps for your account. An order that isn't on your account returns 404 (confirmation_not_found).

Any other field returns 400.

1
2
3
4
curl https://apis-stage.whcc.com/api/v1/fulfillment/event-subscriptions/SUB_7Kq2mZ1x9PdR4t/test \
	-H "Authorization: Bearer <YOUR_API_KEY>" \
	-H "Content-Type: application/json" \
	-d '{ "eventType": "order.accepted" }'
1
2
3
4
5
6
{
	"notifyUid": 84512203,
	"eventType": "order.accepted",
	"subscriptionId": "SUB_7Kq2mZ1x9PdR4t",
	"orderNumber": 900041823
}

notifyUid identifies the notification. Quote it if you ask WHCC where a test event went. orderNumber is the order number the webhook will carry. It's null for order.rejected, and for an order that has no number yet.

What to know before you rely on it:

  • Every subscription on the account receives the event, not only {id}. The {id} decides whether the request is accepted. It doesn't filter who receives the event, and a disabled or suspended subscription receives it too. That is what lets you check a handler before you enable its subscription.

  • A 202 means queued, not delivered. Watch your endpoint for the result.

  • A test with confirmationId looks exactly like a real event, identifiers included, so a handler that has already processed that order's event of the same type may treat the test as a repeat. For a clean test, send one without confirmationId, or pick an order whose event your handler hasn't seen. A test without one carries the synthetic order, so you can recognize it by that order number.

  • Test events don't count toward suspension. A failing test can't suspend a subscription.

  • Tests are rate limited to 10 per account and 30 per source address in each clock hour. Refused requests count. A 429 carries Retry-After with the seconds until the hour rolls over.

  • order.shipped can't be tested in the sandbox or production. It returns 422 (event_type_unavailable_in_environment).

One more refusal is specific to this route: 409 (no_signing_secret) means the subscription has no secret to sign with.

The delivery log

Not available yet. GET /event-subscriptions/{id}/deliveries isn't open to API keys and returns 403 today. This section describes how it will work.

The log lists every delivery attempt WHCC made to a subscription, newest first. It records attempts, not successes: a 500, a timeout, and a refused connection each get a row. Rows are kept for 30 days, so use the log for troubleshooting and keep your own record of events if you need one.

limit

Page size, 1 to 100. Defaults to 25.

after

A cursor from pagination.nextAfter. Returns older attempts. Can't be combined with before.

before

A cursor from pagination.nextBefore. Returns newer attempts.

Any other parameter, a limit out of range, a malformed cursor, or both cursors together returns 400. A well-formed cursor that doesn't match one of this subscription's attempts returns an empty page, not an error.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
	"items": [
		{
			"attemptedAt": "2026-09-30T14:03:22Z",
			"eventType": "order.shipped",
			"webhookVersion": 2,
			"outcome": "succeeded",
			"statusCode": 200,
			"latencyMs": 145,
			"attempt": 1,
			"willRetry": false,
			"reason": null
		},
		{
			"attemptedAt": "2026-09-30T13:41:17Z",
			"eventType": "order.shipped",
			"webhookVersion": 2,
			"outcome": "failed",
			"statusCode": 503,
			"latencyMs": null,
			"attempt": 3,
			"willRetry": false,
			"reason": "transport error"
		}
	],
	"pagination": {
		"limit": 25,
		"hasMore": true,
		"nextBefore": "MjIxOTQyMjc",
		"nextAfter": "MjIxOTQyMDE"
	}
}

attemptedAt

When the attempt was made, ISO 8601 UTC.

eventType

The dotted event type, or null if WHCC couldn't resolve one.

webhookVersion

The payload version the attempt was sent at.

outcome

"succeeded" for a 2xx, "failed" for anything else, including no response.

statusCode

The HTTP status the attempt ended on. A request that never reached you (DNS failure, refused connection, TLS failure, timeout) is recorded as 503 too, so read reason to tell the two apart.

latencyMs

Round-trip time in milliseconds, or null when nothing came back.

attempt

Which attempt in the retry schedule this was, counting from 1.

willRetry

Whether the notification will be sent again to any of its destinations. It isn't about this row alone: a notification can go to several subscriptions, so a row can show a succeeded attempt with willRetry: true because another destination failed.

reason

A short description of a failed attempt, such as "http 500", "timeout", or "transport error". null on success. New values can appear, so match loosely.

The log doesn't return the callback URL or the request and response bodies. A subscription with no attempts returns 200 with an empty items.

What a delivery looks like

Every delivery is a POST to your callbackUri, signed with the subscription's own secret as Verifying a delivery describes. A version 2 subscription receives JSON, which the rest of this section describes. A version 1 subscription receives the form-encoded body instead. Four fields appear on every version 2 body and together say what it is about:

Field Meaning
ConfirmationIdThe order the delivery belongs to. It is the orders[].confirmationId on the submission, which is how you match a delivery to a submission. Not unique per order: when an order is split, the resulting orders share it.
SequenceNumberDistinguishes orders that share a ConfirmationId. A split produces suffixed values such as "1" and "1.2", each with its own OrderNumber and tracking.
EntryIdWHCC's entry reference for the order, echoed on every delivery about it.
EventTypeWhat happened, as one of the dotted names above. Always the last field in the body. Branch on this, not on Event.

An accepted or rejected order arrives as Event: "Processed" with a Status. Accepted has an empty Errors array; rejected has a populated one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
	"Status": "Rejected",
	"Errors": [{
		"ErrorCode": "400.01",
		"Error": "Image hash value doesn't match, order canceled.",
		"AssetPath": "https://images.example-partner.com/27aa22c4-4cf4-44c8-b80a-ad1f80540443.jpg"
	}],
	"Event": "Processed",
	"ConfirmationId": "da99f6f0-2ba7-4bee-a348-8f5ba59fed77",
	"EntryId": "c2496f39-849d-4017-a9bf-a43c646b7f88",
	"Reference": "EP-Q-c2496f39",
	"SequenceNumber": "1",
	"EventType": "order.rejected"
}

A shipment arrives as Event: "Shipped" with ShippingInfo[], one entry per package. A multi-package order can arrive as one delivery with several entries or as several deliveries with one each, so expect one or more per order and process every entry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
	"ShippingInfo": [{
		"Carrier": "FedEx",
		"ShipDate": "2026-05-08T16:59:59-05:00",
		"TrackingNumber": "9261200011223344556677",
		"TrackingUrl": "https://www.fedex.com/fedextrack/?trknbr=9261200011223344556677",
		"Weight": 1.0
	}],
	"OrderNumber": 99000918,
	"Event": "Shipped",
	"ConfirmationId": "a501c238-e91d-4766-853e-598dc6851338",
	"EntryId": "prspctr-Zq8mWkTn3XcRpLFB6-301488-1",
	"Reference": "ETSY_COM:5183660044-198372640551",
	"SequenceNumber": "1",
	"EventType": "order.shipped"
}

Weight is a float, so a whole-pound shipment is 1.0, not 1. A cancellation arrives as Event: "Order Cancelled" with the identifying fields and OrderNumber, and nothing else.

Configure your parser to ignore fields it doesn't know. Delivery is at-least-once, so your handler has to tolerate repeats. Deduplicate on ConfirmationId, SequenceNumber, and event type together. ConfirmationId alone isn't enough, because the parts of a split order share it.

Timeout and retries

Your handler has 100 seconds to respond once WHCC opens the connection. Return a 2xx as soon as you've durably recorded the delivery, and do slow work afterwards; a handler still processing at the timeout is treated as failed and retried, so the same delivery arrives again.

Your response What WHCC does
Any 2xxDone. Never retried.
301, 302, 303Followed as a GET with no body, so your handler never sees the delivery. If that GET returns 2xx, the delivery is recorded as successful and lost. Register the final URL.
Any 4xxTerminal. The delivery is not retried.
Any 5xx, no response in 100 seconds, or a connection failureRetried.

In a future release 408 and 429 will become retryable. No date is set, so don't rely on either being retried yet.

Retrying a create with Idempotency-Key

Send an Idempotency-Key header on POST /event-subscriptions: 1 to 255 printable ASCII characters with no spaces. A UUID works. A key outside those rules returns 400.

Repeat the request with the same key and the same body, and WHCC returns the subscription it already created, with the original signing secret. No second subscription is made. The subscription comes back as it is now, so if you enabled or replaced it in between, the replay shows the current fields beside the original secret.

The same key returns 409 (idempotency_key_conflict) in three cases:

  • The body is different. Use a new key for a different request.

  • The first request hasn't finished. Retry the same call.

  • The subscription was deleted since. It isn't recreated. Create it again with a new key.

Keys are kept for 7 days. After that the same request is an ordinary create and makes a second subscription. These rules are this route's; the submission routes keep their keys for good (see Submission Lifecycle).

A create that timed out without a key

The subscription is saved before the 201 is sent. If a create without an Idempotency-Key times out or the connection drops, the subscription probably exists, with a secret you never saw and can't retrieve. Retrying makes a second one. A 504 on create means the same thing.

Call GET /event-subscriptions and find the one you can't account for. Match on eventTypes, description, and createdAt as well as callbackUri, since several subscriptions can share a URL. If more than one matches, don't delete anything. Once you've identified it, delete it and create a new one with a key. A stranded subscription is disabled and delivers nothing, so it won't show up as a problem on its own.

Callback URL rules

callbackUri must be an absolute https URL of at most 2048 bytes, reachable from the public internet. Register the final destination: don't use a URL that redirects, and don't count on http:// being upgraded. WHCC won't deliver to an endpoint that resolves to a private address.

Testing from your own machine

A handler running on localhost can't be a callbackUri, because WHCC can't reach it. While you develop, a tunnel gives your machine a public https address that forwards to a local port. For a handler you run day after day, use a tunnel with a fixed hostname, such as an ngrok reserved domain or a Cloudflare named tunnel, so the subscription keeps working between sessions. An event subscription is yours to create and delete, so for a one-off run a throwaway address is fine.

For a single test run in the sandbox, a Cloudflare quick tunnel is enough. It needs no account:

1
cloudflared tunnel --url http://localhost:8080

It prints a random https://….trycloudflare.com address. Create a subscription with that address and your handler's path as the callbackUri, enable it, then submit an order or send a test event.

Before you rely on one:

  • The address changes every run. A subscription left pointing at an old address fails every delivery, and after 20 failures WHCC suspends it. Delete the subscription when you stop the tunnel, and create a new one next time.

  • The tunnel provider relays every delivery. Each body carries the order's ConfirmationId, order number and Reference, and it passes through the provider's network on the way to you. Your API key doesn't: it goes only to the WHCC API. A tunnel is a development tool: use it against the sandbox, and point production subscriptions at your deployed endpoint.

  • Verify every delivery. Anyone who learns the tunnel address can post to it, so check WHCC-Signature with the subscription's signing secret exactly as you would on a deployed endpoint.

If you also have an Order Submit callback

This applies only to existing Editor API and Order Submit API integrations that registered a callback through the Order Submit API. An event subscription is separate from that callback. Creating, changing, or deleting one never affects the other, and each has its own signing key. If both point at the same URL, your endpoint receives each event twice, once per registration, so deduplicate as described above.

Errors

Refusals are RFC 9457 application/problem+json, the same shape as the rest of the fulfillment routes. Branch on code when there is one. The route-level 403 (a key without apikey, or the gateway), 503 and 504 carry none, so fall back to the status. A version 1 create from a key without apikey:oas is also a 403, but it carries insufficient_scope:

1
2
3
4
5
6
7
{
	"type": "about:blank",
	"title": "Unprocessable Entity",
	"status": 422,
	"detail": "destination.version must be 1 or 2.",
	"code": "invalid_webhook_version"
}

A one-field {"message":"Forbidden"} or {"message":"Unauthorized"} body comes from the gateway, before the request reached the service. Check the host and the key. See Refusals.

Status code Cause
400malformed_requestThe body couldn't be parsed, a required field is missing, a field this resource doesn't have was sent, signingSecret was empty, or the Idempotency-Key is invalid
403noneThe key doesn't carry the apikey scope
403insufficient_scopeA create asked for destination.version: 1 and the key doesn't carry apikey:oas
404subscription_not_found{id} isn't a subscription on your account
404confirmation_not_foundThe confirmationId on a test isn't an order on your account
409idempotency_key_conflictThe key was already used with a different body, with a request still in progress, or for a subscription that has since been deleted
409no_signing_secretThe subscription named in a test has no signing secret
422incomplete_event_coverageorder.accepted without order.rejected. Carries missing
422unknown_event_typeAn entry in eventTypes isn't an event type, or is order.image-download, which can't be named
422event_type_unavailable_for_versionThe event type isn't carried at the subscription's payload version, such as order.rejected or order.cancelled at version 1
422event_type_not_selectedThe eventType on a test isn't one the subscription selects
422event_type_unavailable_in_environmentThe eventType on a test can't be fired in this environment
422unsupported_destination_typedestination.type isn't "webhook"
422invalid_callback_uridestination.callbackUri is present but empty, not https, or too long
422invalid_signing_secretA supplied secret isn't 32 to 255 printable ASCII characters, or contains whitespace
422invalid_webhook_versiondestination.version isn't 1 or 2
422destination_version_immutableA replace tried to change destination.version
429rate_limitedToo many test events this hour. Retry-After says when to try again
501webhook_version_unsupportedRetained in the published list of codes. This version of the API doesn't answer it
503noneThe request couldn't begin. Wait for Retry-After if it's present, then retry
504noneA write timed out partway. On create, treat it as a lost response

What's Next

To read an order's status directly, see Orders & Fulfillment.

Back to Top 👆
Get in Touch

Interested in integrating with WHCC? Tell us more about what you’re looking for and we’ll be in touch.