> Part of [WHCC Developer Documentation](https://www.whcc.com/developer/llms.txt)

# Event Subscriptions

Version 1.7 &middot; 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#create) the subscription and store the [signing secret](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/event-subscriptions/#local-testing).
3. [Send a test event](https://www.whcc.com/developer/docs/api/event-subscriptions/#test) to check the handler.
4. [Enable](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/fulfillment/#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-subscriptions`](https://www.whcc.com/developer/docs/api/event-subscriptions/#create) | Create a subscription |
| `GET` | [`/event-subscriptions`](https://www.whcc.com/developer/docs/api/event-subscriptions/#list) | List your subscriptions |
| `GET` | [`/event-subscriptions/{id}`](https://www.whcc.com/developer/docs/api/event-subscriptions/#retrieve) | Read one subscription |
| `PUT` | [`/event-subscriptions/{id}`](https://www.whcc.com/developer/docs/api/event-subscriptions/#update) | Replace a subscription's URL, event types, and description |
| `POST` | [`/event-subscriptions/{id}/enable`](https://www.whcc.com/developer/docs/api/event-subscriptions/#enable) | Start delivery |
| `POST` | [`/event-subscriptions/{id}/disable`](https://www.whcc.com/developer/docs/api/event-subscriptions/#disable) | Stop delivery |
| `DELETE` | [`/event-subscriptions/{id}`](https://www.whcc.com/developer/docs/api/event-subscriptions/#delete) | Delete a subscription for good |
| `POST` | [`/event-subscriptions/{id}/test`](https://www.whcc.com/developer/docs/api/event-subscriptions/#test) | Queue a test event |
| `POST` | [`/event-subscriptions/{id}/rotate-secret`](https://www.whcc.com/developer/docs/api/event-subscriptions/#rotate) | Rotate the signing secret |
| `GET` | [`/event-subscriptions/event-types`](https://www.whcc.com/developer/docs/api/event-subscriptions/#event-types-api) | The event-type catalog |
| `GET` | [`/event-subscriptions/{id}/deliveries`](https://www.whcc.com/developer/docs/api/event-subscriptions/#deliveries) | The delivery log. Not available yet |

`POST /event-subscriptions/{id}/rotate-secret`[rotates the signing secret](https://www.whcc.com/developer/docs/api/event-subscriptions/#rotate) in place.

## The subscription

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

```json
{
	"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](https://www.whcc.com/developer/docs/api/event-subscriptions/#version-1). |
| `eventTypes` | string[] | The [event types](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/event-subscriptions/#lifecycle). |
| `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](https://www.whcc.com/developer/docs/api/event-subscriptions/#signing-secret). |

## Status

| `status` | Receives events | How it gets there | `statusDetail` |
| --- | --- | --- | --- |
| `"disabled"` | No | [Create](https://www.whcc.com/developer/docs/api/event-subscriptions/#create), or [disable](https://www.whcc.com/developer/docs/api/event-subscriptions/#disable) | `null` after create, `reason: "user"` after disable |
| `"active"` | Yes | [Enable](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/event-subscriptions/#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`](https://www.whcc.com/developer/docs/api/event-subscriptions/#list) 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`](https://www.whcc.com/developer/docs/api/event-subscriptions/#idempotency) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#uri-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](https://www.whcc.com/developer/docs/api/event-subscriptions/#version-1). |
| `eventTypes` | No | An array of [event types](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/event-subscriptions/#signing-secret). |
| `acknowledgeIncompleteCoverage` | No | `true` allows [`order.accepted` without `order.rejected`](https://www.whcc.com/developer/docs/api/event-subscriptions/#coverage). 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

```shell
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`

```json
{
	"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](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/event-subscriptions/#verify) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#idempotency) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#rotate).

### 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.

```shell
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:

```plaintext
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](https://www.whcc.com/developer/docs/api/event-subscriptions/#signing-secret) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#version-1) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#event-types-api) with `available: false` and can't be selected by name. At version 2, subscribe to `order.rejected` instead. A [version 1](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-v1-format/index.html.md) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#coverage) 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"](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-security/#multiple-signatures).
- **Test sends:**[`POST /event-subscriptions/{id}/test`](https://www.whcc.com/developer/docs/api/event-subscriptions/#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`](https://www.whcc.com/developer/docs/api/event-subscriptions/#test).
- **No verification step:** like every subscription, a version 1 subscription is created `disabled` and receives nothing until you [enable](https://www.whcc.com/developer/docs/api/event-subscriptions/#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:

```json
{
	"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](https://www.whcc.com/developer/docs/api/event-subscriptions/#update) 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.

```json
{
	"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.

```shell
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](https://www.whcc.com/developer/docs/api/event-subscriptions/#coverage).

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

```shell
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](https://www.whcc.com/developer/docs/api/event-subscriptions/#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.

```shell
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](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/event-subscriptions/#retries).

## 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`.

```shell
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" }'
```

```json
{
	"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](https://www.whcc.com/developer/docs/api/event-subscriptions/#auto-disable).** 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.

```json
{
	"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](https://www.whcc.com/developer/docs/api/event-subscriptions/#verify) describes. A version 2 subscription receives JSON, which the rest of this section describes. A [version 1](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/responses/#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:

```json
{
	"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:

```json
{
	"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 `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.

## 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](https://www.whcc.com/developer/docs/api/submission-lifecycle/#idempotency)).

### 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`](https://www.whcc.com/developer/docs/api/event-subscriptions/#list) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#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:

```shell
cloudflared tunnel --url http://localhost:8080
```

It prints a random `https://….trycloudflare.com` address. [Create](https://www.whcc.com/developer/docs/api/event-subscriptions/#create) a subscription with that address and your handler's path as the `callbackUri`, [enable](https://www.whcc.com/developer/docs/api/event-subscriptions/#enable) it, then submit an order or [send a test event](https://www.whcc.com/developer/docs/api/event-subscriptions/#test).

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](https://www.whcc.com/developer/docs/api/event-subscriptions/#auto-disable) 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#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](https://www.whcc.com/developer/docs/api/event-subscriptions/#delivered-payload).

## Errors

Refusals are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)`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`:

```json
{
	"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](https://www.whcc.com/developer/docs/api/fulfillment/#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`](https://www.whcc.com/developer/docs/api/event-subscriptions/#coverage). 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](https://www.whcc.com/developer/docs/api/event-subscriptions/#lost-response) |

> ## What's Next
>
> To read an order's status directly, see [Orders & Fulfillment](https://www.whcc.com/developer/docs/api/fulfillment/index.html.md).