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.
Verify with the public key (v1a)
Every delivery carries av1a 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.v1 only and skip v1a entries, so use your language’s ed25519 support, as below.
Verify with the signing secret (v1)
If your platform cannot check ed25519, check thev1 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.Verify by hand
The steps are the same as for the public key, except the last two:- 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. - Accept the delivery if any
v1entry inwebhook-signaturematches yours, and ignore thev1aentries. Compare with a constant-time function, never==, so the comparison does not leak timing.
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
body: on its line, with no line break at the end.
A correct implementation, given these headers and body:
- computes exactly the
v1entry above from the signing secret, and accepts the delivery; - accepts the
v1aentry with the public key; - rejects the delivery if any byte of the body changes.
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.POST /endpoints/{endpointId}/secrets. The response carries the newsecret.publicKeyand the newsigningSecret, which is shown once.- For the next 24 hours, every delivery is signed with both the old and the new secret and key, so
webhook-signaturecarries twov1entries and twov1aentries. A receiver still using the old public key or secret keeps accepting deliveries, and so does one already using the new one. - Deploy the new public key, or the new secret, to your receiver at any point in those 24 hours.
- 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.
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.
v1anever matches. Check you decoded the public key without itswhpk_prefix and the signature without itsv1a,prefix, and that you used the public key of a secret that is stillActiveorExpiring.- 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.