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:
Create the subscription and store the signing secret from the response. It isn't shown again.
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.
Send a test event to check the handler.
Enable the subscription. A new subscription is created disabled and receives nothing until you do.
https://
Sandbox. The examples on this page use this host.
https://
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.
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.
| Method | Path | What it does |
|---|---|---|
POST | /event-subscriptions | Create a subscription |
GET | /event-subscriptions | List 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}/enable | Start delivery |
POST | /event-subscriptions/{id}/disable | Stop delivery |
DELETE | /event-subscriptions/{id} | Delete a subscription for good |
POST | /event-subscriptions/{id}/test | Queue a test event |
POST | /event-subscriptions/{id}/rotate-secret | Rotate the signing secret |
GET | /event-subscriptions/event-types | The event-type catalog |
GET | /event-subscriptions/{id}/deliveries | The delivery log. Not available yet |
POST /event-subscriptions/{id}/rotate-secret rotates the signing secret in place.
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 |
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" |
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".
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.
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"
}'
201 Created1
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.
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.
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.
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:
,, 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.., 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.signingSecret used as an exact string (see above for why not to re-encode it). Encode the digest as hex.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.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 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.
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.rejectedSelecting 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.
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.
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.
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.
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>"
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.
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.
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.
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 |
|---|---|
ConfirmationId | The 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. |
SequenceNumber | Distinguishes orders that share a ConfirmationId. A split produces suffixed values such as "1" and "1.2", each with its own OrderNumber and tracking. |
EntryId | WHCC's entry reference for the order, echoed on every delivery about it. |
EventType | What 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.
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 2xx | Done. Never retried. |
301, 302, 303 | Followed 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 4xx | Terminal. The delivery is not retried. |
Any 5xx, no response in 100 seconds, or a connection failure | Retried. |
In a future release 408 and 429 will become retryable. No date is set, so don't rely on either being retried yet.
Idempotency-KeySend 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).
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.
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.
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.
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.
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 |
|---|---|---|
400 | malformed_request | The 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 |
403 | none | The key doesn't carry the apikey scope |
403 | insufficient_scope | A create asked for destination.version: 1 and the key doesn't carry apikey:oas |
404 | subscription_not_found | {id} isn't a subscription on your account |
404 | confirmation_not_found | The confirmationId on a test isn't an order on your account |
409 | idempotency_key_conflict | The key was already used with a different body, with a request still in progress, or for a subscription that has since been deleted |
409 | no_signing_secret | The subscription named in a test has no signing secret |
422 | incomplete_event_coverage | order.accepted without order.rejected. Carries missing |
422 | unknown_event_type | An entry in eventTypes isn't an event type, or is order.image-download, which can't be named |
422 | event_type_unavailable_for_version | The event type isn't carried at the subscription's payload version, such as order.rejected or order.cancelled at version 1 |
422 | event_type_not_selected | The eventType on a test isn't one the subscription selects |
422 | event_type_unavailable_in_environment | The eventType on a test can't be fired in this environment |
422 | unsupported_destination_type | destination.type isn't "webhook" |
422 | invalid_callback_uri | destination.callbackUri is present but empty, not https, or too long |
422 | invalid_signing_secret | A supplied secret isn't 32 to 255 printable ASCII characters, or contains whitespace |
422 | invalid_webhook_version | destination.version isn't 1 or 2 |
422 | destination_version_immutable | A replace tried to change destination.version |
429 | rate_limited | Too many test events this hour. Retry-After says when to try again |
501 | webhook_version_unsupported | Retained in the published list of codes. This version of the API doesn't answer it |
503 | none | The request couldn't begin. Wait for Retry-After if it's present, then retry |
504 | none | A write timed out partway. On create, treat it as a lost response |
To read an order's status directly, see Orders & Fulfillment.