Legacy This page is reference for integrations already built on the Order Submit API. It remains supported. New integrations use the WHCC API.

Webhook Security

Version 1.1 · 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 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:

1
2
3
   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:

1
2
3
4
   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 — 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) — 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 — 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 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 — POST …/test answers 409 no_signing_secret — 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 — see Why "one or more". Keep all of them.

  • Discard every other element. In particular, ignore any signature whose prefix is not v1.

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 — 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 — 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), including an event subscription created with destination.version: 1, the signed content is the entire form-encoded body — 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 — even one that looks equivalent — 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 — registered with POST /api/callback/create on the Order Submit API. Your consumer secret.
Event subscription — created with POST /event-subscriptions. That subscription's own signing secret, returned once in its 201 response — 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, 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 — 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 — 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 — guessing — 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. Your expected signatures are plural because one endpoint can serve more than one registration. A receiver with one registration and no rotation in progress checks exactly one pair — 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.

Preventing common attacks

Replay Attacks

To protect against replay attacks, 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, you should ignore all signatures that are not v1

Timing attacks

To protect against timing attacks, be sure to use a constant-time string comparison to compare the expected signature to the received signature.

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.