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

**Legacy** This page is reference for integrations already built on the Order Submit API. It remains supported. New integrations use the [WHCC API](https://www.whcc.com/developer/docs/quick-start/index.html.md).

# Webhook Security

Version 1.1 &middot; Updated 2026-10-09

WHCC signs every webhook request it sends to your endpoint by including a `WHCC-Signature` header.

> The string that gets signed is **different for V1 and V2** integrations. Using the wrong one is the most common cause of signature validation failing. Check [which version you're on](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-security/#version) before following Step 2 below.

## Signature verification

The `WHCC-Signature` header included in each webhook event contains a timestamp and one or more signatures. The timestamp is prefixed by `t=`
and each signature is prefixed by a version `v`, followed by an integer. Currently, the only valid version is `v1`.

You can see a sample signature below:

```shell
   WHCC-Signature:
   t=1591735205,
   v1=307D88AF1425DC58552C1A6EDFB4C95E3E989F5B878CAFFEFFA6D581578DC82A
```

> Newlines have been added to the above header for readability purposes only, an actual `WHCC-Signature` will be on a single line.

### Why "one or more"

Most of the time a header carries exactly one `v1` element. **Sometimes it carries two.** When a signing secret is being replaced, WHCC signs each delivery with **both** the new secret and the previous one for the length of the overlap window, so that a receiver holding either secret can still verify. Both signatures travel in the same header, as two `v1` elements:

```shell
   WHCC-Signature:
   t=1591735205,
   v1=307D88AF1425DC58552C1A6EDFB4C95E3E989F5B878CAFFEFFA6D581578DC82A,
   v1=9C1B4E77A0D25F38B6E0C4913A7F5D28E11B6A4F0C93D7E5824B1F6A3D09C7E4
```

> ## Write the parser for the plural case, not the singular one
>
> A receiver that keeps only one `v1` value verifies correctly today and then fails roughly half its deliveries for the duration of the next secret rotation &mdash; with nothing in the failure to explain why.
>
> **Collect every `v1` element into a list, and accept the delivery if any one of them matches.** Steps 1 and 4 below do this. Do not simplify the loop away because you have only ever observed one signature.

> The `v1` digest is **uppercase** hexadecimal. Most HMAC libraries produce lowercase, so compare case-insensitively (or normalise both sides) &mdash; a direct string comparison against a lowercase digest will fail on every request.
>
> Compare using a **constant-time** comparison function, not `==`, so that a mismatch cannot be located one byte at a time.
>
> **For consumer callbacks:** the signing key is your **consumer secret** &mdash; the same secret you pair with your consumer key to request an access token. There is no separate signing secret, and there is nothing to request before you can verify those signatures.
>
> If a consumer record carries an empty secret, the `WHCC-Signature` header is sent **empty** rather than omitted. Treat an empty signature as a verification failure, and contact WHCC support. This should not happen for an active integration, because the same secret is required to obtain an access token. An [event subscription](https://www.whcc.com/developer/docs/api/event-subscriptions/index.html.md) is created with its own signing secret, so it should not reach that state. If one ever carries none, WHCC refuses to deliver to it rather than signing with nothing &mdash; [`POST &hellip;/test`](https://www.whcc.com/developer/docs/api/event-subscriptions/#test) answers `409 no_signing_secret` &mdash; so an empty signature still should not reach you on that path.

### Step 1: Extract the timestamp and the signatures from the header

Split the header on the `,` character to get a list of elements. Then split **each** element on its **first**`=` character to get a prefix and a value. Splitting on the first `=` matters: a value may itself contain one, and splitting on every `=` corrupts it.

Then, walking the elements in order:

- The element whose prefix is `t` gives you the **timestamp**. There is exactly one.
- **Append the value of every element whose prefix is `v1` to a list.** There may be one, or there may be more than one &mdash; see [Why "one or more"](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-security/#multiple-signatures). Keep all of them.
- Discard every other element. In particular, [ignore any signature whose prefix is not `v1`](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-security/#downgrade).

> **Do not parse the header into a map keyed by prefix.** A map holds one value per key, so two `v1` elements collapse into one &mdash; and which one survives is decided by your language's map semantics, not by anything WHCC controls. It looks correct in every test written against a single-signature header, and it silently discards the signature you needed during a rotation.
>
> The right shape is a **single timestamp and a list of signatures**, not a dictionary.

### Step 2: Prepare the payload

The string to be hashed is assembled by concatenating:

1. The timestamp as a string
2. The character `.`
3. The **exact request body, as bytes, with no modification**

What that request body looks like depends on your webhook version:

#### V2 (JSON)

If your integration receives `Content-Type: application/json` requests, the request body is the JSON payload as a string &mdash; i.e. the raw JSON request body, exactly as received.

#### V1 (form-encoded)

If your integration receives `Content-Type: application/x-www-form-urlencoded` requests (see [V1 Webhook Format](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-v1-format/index.html.md)), including an [event subscription created with `destination.version: 1`](https://www.whcc.com/developer/docs/api/event-subscriptions/#version-1), the signed content is the **entire form-encoded body** &mdash; the full `Message=…&EntryId=…&json=…` string exactly as received, not the JSON payload's data or the decoded `json` field alone. Do not re-serialize or re-encode the body before hashing it; use the bytes as received.

> For both versions, do not parse and re-serialize the body before hashing. Any change to whitespace, field order, or encoding &mdash; even one that looks equivalent &mdash; produces a different signature.

### Step 3: Compute the expected signature

Compute an HMAC with the `SHA256` hash function. The message is the assembled string from the previous step. **Which key you use depends on how the webhook was registered**, and there are two possibilities:

| How the webhook was registered | The HMAC key |
| --- | --- |
| **Consumer callback** &mdash; registered with [`POST /api/callback/create`](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-register/index.html.md) on the Order Submit API. | Your **consumer secret**. |
| **Event subscription** &mdash; created with [`POST /event-subscriptions`](https://www.whcc.com/developer/docs/api/event-subscriptions/#create). | That subscription's own **signing secret**, returned once in its `201` response &mdash; **not** your consumer secret. |

> Using the consumer secret to verify an event subscription's deliveries fails every check, and produces exactly the same symptom as a payload assembled wrongly in Step 2. If you hold both kinds of registration pointing at the same endpoint you receive [each event twice](https://www.whcc.com/developer/docs/api/event-subscriptions/#duplicate-deliveries), once signed with each key, so your handler has to pick the right key per delivery rather than assume one secret for the endpoint.

You compute **one expected signature per secret you hold for the endpoint the delivery arrived on**. For most receivers that is one secret and therefore one expected signature.

> **If one endpoint serves more than one registration, it holds more than one secret &mdash; and the delivery does not tell you which applies.** A consumer callback and an event subscription on the same URL, or two subscriptions on the same URL, are all legitimate. At `version: 1` the two kinds of delivery are byte-identical, so there is no field to branch on. ⚠️ **Nor does `version: 2` give you one** &mdash; every version 2 delivery carries `EventType`, so it names the event rather than the registration and cannot tell a consumer callback from an event subscription either.
>
> Do not try to pick the key. **Compute an expected signature for every secret registered against that endpoint**, and hand the whole set to Step 4. The alternative &mdash; guessing &mdash; rejects valid deliveries whenever the guess is wrong. Giving each registration its own endpoint path avoids the problem entirely, and is worth doing if you can.

### Step 4: Compare the signatures

Compare **every** expected signature from Step 3 against **every**`v1` value you collected in Step 1. **The delivery is authentic if any pair matches.** Only once every pair has been checked and none matched should you reject the delivery.

Both plurals are real, and they have different causes. The signatures in the header are plural because [WHCC signs with both secrets during a rotation](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-security/#multiple-signatures). Your expected signatures are plural because [one endpoint can serve more than one registration](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-security/#secret). A receiver with one registration and no rotation in progress checks exactly one pair &mdash; write the loops anyway, because neither condition announces itself.

> Two things from the warning at the top of this page apply to **each element of the loop**, not just to the first one:
>
> Compare with a **constant-time** comparison function every time round the loop. It is easy to write the iteration with a plain `==` and an early exit; that reintroduces exactly the timing leak the constant-time comparison exists to prevent.
>
> Normalise case on **every** element. The `v1` digests are uppercase hexadecimal, and a lowercase expected signature fails against all of them equally.

Then check the timestamp: compute the difference between the current time and the `t` value from the header, and decide whether that difference is within your tolerance. A delivery whose signature matches but whose timestamp is outside your tolerance should be rejected as a possible [replay](https://www.whcc.com/developer/docs/legacy/order-submit-api/webhook-security/#replay).

## Preventing common attacks

### Replay Attacks

To protect against [replay attacks](https://en.wikipedia.org/wiki/Replay_attack), where an attacker intercepts a valid payload and its signature, then re-transmits them, you can set a tolerance for the timestamp included in the signature.

**WHCC does not publish a recommended tolerance, and does not enforce one.** The value is yours to choose. Pick one that is comfortably longer than the clock skew and network delay you expect, and short enough to be worth having.

### Downgrade Attacks

To protect against [downgrade attacks](https://en.wikipedia.org/wiki/Downgrade_attack), you should ignore all signatures that are not `v1`

### Timing attacks

To protect against [timing attacks](https://en.wikipedia.org/wiki/Timing_attack), be sure to use a constant-time string comparison to compare the expected signature to the received signature.