Skip to main content
Anyone can send a POST to your webhook URL. Before you act on a delivery, check its signature: it proves the request came from Flow Payroll, that the body was not changed on the way, and that it is recent. Without this check, someone could send you a fake “payslip finalised” and have your system act on it. Flow Payroll signs every delivery the way the Standard Webhooks specification describes, in two ways at once:
  • v1a (recommended): ed25519 with your endpoint’s key pair. Flow Payroll holds the private key and gives you the public key (whpk_…), which can check a signature but never make one. Your receiver holds nothing that can sign, so even if its configuration leaks, nobody can use it to forge a delivery. See Verify with the public key.
  • v1: HMAC-SHA256 with your endpoint’s signing secret (whsec_…). You and Flow Payroll both hold the secret, so anyone who gets hold of your copy can sign a fake delivery. Use it if your platform has no ed25519 support or you want an official library to do the check. See Verify with the signing secret.
You only need to check one of them.
Verify against the raw request body, byte for byte, before any JSON parsing. A framework that parses and re-serialises the body changes it, and the signature then fails.

Verify with the public key (v1a)

Every delivery carries a v1a entry: an ed25519 signature over the string {webhook-id}.{webhook-timestamp}.{raw body}, made with a private key only Flow Payroll holds. You check it with your endpoint’s public key, the whpk_… value returned as publicKey when you create the endpoint, as secret.publicKey when you rotate its secret, and on every signing secret from GET /endpoints/{endpointId}/secrets. The public key is not a secret, so you can keep it in plain configuration and read it again at any time.
1

Read the three headers

webhook-id, webhook-timestamp and webhook-signature.
2

Reject old deliveries

If webhook-timestamp is more than five minutes away from your clock, reject the request. This means a captured delivery can only be replayed within those five minutes, and recording each webhook-id stops a repeat inside that window too. Keep your server’s clock right with NTP (Network Time Protocol), which most servers run by default.
3

Build the signed content and decode the key

Join {webhook-id}.{webhook-timestamp}. and the raw body into one sequence of bytes. Take the public key without its whpk_ prefix and base64-decode it (base64 is a standard way of writing binary data as text; every language can decode it) to get the 32-byte ed25519 public key.
4

Check each v1a entry

webhook-signature holds space-separated entries: every v1,{signature} first, then every v1a,{signature}. For each v1a entry, base64-decode what follows v1a, to get a 64-byte signature and verify it over the signed content with the public key. Accept the delivery if any v1a entry verifies, and ignore the v1 entries.
The official Standard Webhooks libraries check v1 only and skip v1a entries, so use your language’s ed25519 support, as below.
.NET 8 has no ed25519 in its standard library, which is why the C# example uses BouncyCastle (version 2.7.0 or later). In other languages, use the ed25519 support in the standard library or a well-known cryptography package.

Verify with the signing secret (v1)

If your platform cannot check ed25519, check the v1 entry instead, with your endpoint’s signing secret (the whsec_… value returned as signingSecret when you created the endpoint or rotated its secret). Keep the secret in a secrets manager: anyone who holds it can sign a delivery.

Use an official library

Each library takes your signing secret, the raw body and the request headers. It throws if the signature is wrong or the delivery is more than five minutes old.
Libraries for Go, Java, Kotlin, Ruby, PHP, Rust and Elixir are listed on standardwebhooks.com. They all work the same way.

Verify by hand

The steps are the same as for the public key, except the last two:
  1. Take the secret without its whsec_ prefix and base64-decode it to get the key. Compute the HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body} with that key, and base64-encode the result. HMAC-SHA256 is the standard way to sign a message with a shared secret, and every language has it built in.
  2. Accept the delivery if any v1 entry in webhook-signature matches yours, and ignore the v1a entries. Compare with a constant-time function, never ==, so the comparison does not leak timing.
Reject deliveries whose webhook-timestamp is more than five minutes from your clock, as for the public key.

Test vector

Check your implementation against these values before you point it at real deliveries. They were produced by Flow Payroll’s own signer, and they are for testing only: the secret and key were made for this page, never sign anything Flow Payroll sends, and must never be used in any environment.
Test vector
The body is everything after body: on its line, with no line break at the end. A correct implementation, given these headers and body:
  • computes exactly the v1 entry above from the signing secret, and accepts the delivery;
  • accepts the v1a entry with the public key;
  • rejects the delivery if any byte of the body changes.
The timestamp is in the past, so switch off the five-minute check (or fix your clock at webhook-timestamp) while you check the vector. With the check switched on, a correct implementation rejects it as too old.

Rotate a signing secret

Rotate if a secret may have leaked, or on a regular schedule if your security policy asks for one. Each rotation issues a new secret and a new key pair together. Rotation never interrupts deliveries.
  1. POST /endpoints/{endpointId}/secrets. The response carries the new secret.publicKey and the new signingSecret, which is shown once.
  2. For the next 24 hours, every delivery is signed with both the old and the new secret and key, so webhook-signature carries two v1 entries and two v1a entries. A receiver still using the old public key or secret keeps accepting deliveries, and so does one already using the new one.
  3. Deploy the new public key, or the new secret, to your receiver at any point in those 24 hours.
  4. Optionally, once you have switched, revoke the old one straight away with DELETE /endpoints/{endpointId}/secrets/{secretId}. The old secret and its key stop signing from the next delivery.
After 24 hours the old secret and its key stop signing on their own. GET /endpoints/{endpointId}/secrets shows each secret as Active, Expiring (still signing, with its expiresDate) or Revoked, with its publicKey. An endpoint always keeps at least one active secret, so the last one cannot be revoked: rotate first.
A signing secret does not expire and needs no refreshing, unlike an access token. It is a key both sides hold, not a credential you present, so it only changes when you rotate it.

If verification fails

  • Every delivery fails. You are almost certainly verifying a re-serialised body. Read the raw bytes before your framework parses JSON.
  • v1a never matches. Check you decoded the public key without its whpk_ prefix and the signature without its v1a, prefix, and that you used the public key of a secret that is still Active or Expiring.
  • Deliveries fail after a rotation. Check you deployed the public key or secret for the right endpoint: each endpoint has its own.
  • Deliveries fail with a timestamp error. Your server’s clock has drifted. Synchronise it with NTP.
  • You are not sure your code is right. Run it against the test vector first.
  • You lost the public key. Read it again from GET /endpoints/{endpointId}/secrets.
  • You lost the signing secret. It cannot be shown again. Rotate to get a new one.