> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flowpayroll.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify signatures

> Check that a delivery came from Flow Payroll and was not changed on the way, with your endpoint's public key (recommended) or its signing secret, reject replays, and rotate without missing a delivery.

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](https://www.standardwebhooks.com) 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](#verify-with-the-public-key-v1a).
* **`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-signing-secret-v1).

You only need to check one of them.

<Warning>
  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.
</Warning>

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

<Steps>
  <Step title="Read the three headers">
    `webhook-id`, `webhook-timestamp` and `webhook-signature`.
  </Step>

  <Step title="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`](/api-reference/webhooks/best-practices#handle-duplicates) stops a repeat inside that window too. Keep your server's clock right with NTP (Network Time Protocol), which most servers run by default.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

The official Standard Webhooks libraries check `v1` only and skip `v1a` entries, so use your language's ed25519 support, as below.

<CodeGroup>
  ```javascript Node.js theme={null}
  // Built-in crypto, no package needed.
  import { createPublicKey, verify } from "node:crypto"

  // The endpoint's publicKey, for example whpk_jIEdhT6g5KvNSUn1KOiDuDQTX9V1cEhCxOh9pvUgTcc=
  const publicKey = createPublicKey({
    key: {
      kty: "OKP",
      crv: "Ed25519",
      x: Buffer.from(process.env.FLOW_WEBHOOK_PUBLIC_KEY.slice("whpk_".length), "base64").toString("base64url"),
    },
    format: "jwk",
  })

  // rawBody is the request body as a Buffer, exactly as received.
  export function isValid(headers, rawBody) {
    const id = headers["webhook-id"] ?? ""
    const timestamp = headers["webhook-timestamp"] ?? ""
    if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false

    const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, "utf8"), rawBody])
    return (headers["webhook-signature"] ?? "")
      .split(" ")
      .filter((entry) => entry.startsWith("v1a,"))
      .some((entry) => verify(null, message, publicKey, Buffer.from(entry.slice("v1a,".length), "base64")))
  }
  ```

  ```python Python theme={null}
  # pip install cryptography
  import base64
  import os
  import time

  from cryptography.exceptions import InvalidSignature
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

  # The endpoint's publicKey, for example whpk_jIEdhT6g5KvNSUn1KOiDuDQTX9V1cEhCxOh9pvUgTcc=
  public_key = Ed25519PublicKey.from_public_bytes(
      base64.b64decode(os.environ["FLOW_WEBHOOK_PUBLIC_KEY"].removeprefix("whpk_"))
  )


  def is_valid(headers, raw_body: bytes) -> bool:
      timestamp = headers.get("webhook-timestamp", "")
      if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
          return False

      message = f'{headers.get("webhook-id", "")}.{timestamp}.'.encode() + raw_body
      for entry in headers.get("webhook-signature", "").split(" "):
          version, _, signature = entry.partition(",")
          if version != "v1a":
              continue
          try:
              public_key.verify(base64.b64decode(signature), message)
              return True
          except (InvalidSignature, ValueError):
              continue
      return False
  ```

  ```csharp C# theme={null}
  // dotnet add package BouncyCastle.Cryptography
  using System.Text;
  using Org.BouncyCastle.Crypto.Parameters;
  using Org.BouncyCastle.Crypto.Signers;

  public static class FlowWebhookPublicKey
  {
      private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);

      /// <summary>True when the delivery carries a v1a signature from your endpoint's key, made in the last five minutes.</summary>
      public static bool IsValid(string publicKey, string id, string timestamp, string signatureHeader, byte[] rawBody)
      {
          if (!long.TryParse(timestamp, out var seconds)) return false;
          var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(seconds);
          if (age.Duration() > Tolerance) return false;

          var key = new Ed25519PublicKeyParameters(Convert.FromBase64String(publicKey["whpk_".Length..]));
          var message = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(rawBody).ToArray();

          foreach (var entry in signatureHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries))
          {
              var parts = entry.Split(',', 2);
              if (parts.Length != 2 || parts[0] != "v1a") continue;
              byte[] signature;
              try
              {
                  signature = Convert.FromBase64String(parts[1]);
              }
              catch (FormatException)
              {
                  continue;
              }

              var verifier = new Ed25519Signer();
              verifier.Init(false, key);
              verifier.BlockUpdate(message, 0, message.Length);
              if (verifier.VerifySignature(signature)) return true;
          }
          return false;
      }
  }

  // In your handler, read the body as bytes before anything parses it:
  // FlowWebhookPublicKey.IsValid(publicKey, Request.Headers["webhook-id"], Request.Headers["webhook-timestamp"],
  //                              Request.Headers["webhook-signature"], rawBody)
  ```

  ```php PHP theme={null}
  <?php
  // No package needed: sodium is bundled with PHP 7.2 and later.

  // $headers uses lower-case names; $rawBody is the request body exactly as received.
  function flow_webhook_public_key_is_valid(array $headers, string $rawBody): bool
  {
      $id = $headers['webhook-id'] ?? '';
      $timestamp = $headers['webhook-timestamp'] ?? '';
      if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) return false;

      // The endpoint's publicKey, for example whpk_jIEdhT6g5KvNSUn1KOiDuDQTX9V1cEhCxOh9pvUgTcc=
      $publicKey = base64_decode(substr(getenv('FLOW_WEBHOOK_PUBLIC_KEY'), strlen('whpk_')), true);
      if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) return false;

      $message = "{$id}.{$timestamp}." . $rawBody;
      foreach (explode(' ', $headers['webhook-signature'] ?? '') as $entry) {
          [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
          if ($version !== 'v1a') continue;
          $signature = base64_decode($signature, true);
          if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) continue;
          if (sodium_crypto_sign_verify_detached($signature, $message, $publicKey)) return true;
      }
      return false;
  }

  // In your handler, read the body before anything parses it:
  // $headers = array_change_key_case(getallheaders(), CASE_LOWER);
  // flow_webhook_public_key_is_valid($headers, file_get_contents('php://input'))
  ```
</CodeGroup>

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

<CodeGroup>
  ```javascript Node.js theme={null}
  // npm install standardwebhooks
  import express from "express"
  import { Webhook } from "standardwebhooks"

  const webhook = new Webhook(process.env.FLOW_WEBHOOK_SECRET)
  const app = express()

  app.post("/webhooks/flow-payroll", express.raw({ type: "application/json" }), (req, res) => {
    let event
    try {
      event = webhook.verify(req.body.toString("utf8"), req.headers)
    } catch {
      return res.status(400).send("Invalid signature")
    }
    res.sendStatus(204)
    queueForProcessing(event)
  })
  ```

  ```python Python theme={null}
  # pip install standardwebhooks
  import os
  from flask import Flask, request
  from standardwebhooks.webhooks import Webhook

  webhook = Webhook(os.environ["FLOW_WEBHOOK_SECRET"])
  app = Flask(__name__)

  @app.post("/webhooks/flow-payroll")
  def receive():
      try:
          event = webhook.verify(request.get_data(as_text=True), dict(request.headers))
      except Exception:
          return "Invalid signature", 400
      queue_for_processing(event)
      return "", 204
  ```

  ```csharp C# theme={null}
  // No package needed.
  using System.Security.Cryptography;
  using System.Text;

  public static class FlowWebhook
  {
      private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);

      /// <summary>True when the delivery was signed with your secret in the last five minutes.</summary>
      public static bool IsValid(string secret, string id, string timestamp, string signatureHeader, byte[] rawBody)
      {
          if (!long.TryParse(timestamp, out var seconds)) return false;
          var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(seconds);
          if (age.Duration() > Tolerance) return false;

          var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret["whsec_".Length..] : secret);
          var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(rawBody).ToArray());

          foreach (var entry in signatureHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries))
          {
              var parts = entry.Split(',', 2);
              if (parts.Length != 2 || parts[0] != "v1") continue;
              try
              {
                  if (CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(parts[1]), expected)) return true;
              }
              catch (FormatException)
              {
              }
          }
          return false;
      }
  }

  // In your handler, read the body as bytes before anything parses it:
  // FlowWebhook.IsValid(secret, Request.Headers["webhook-id"], Request.Headers["webhook-timestamp"],
  //                     Request.Headers["webhook-signature"], rawBody)
  ```

  ```php PHP theme={null}
  <?php
  // No package needed.

  // $headers uses lower-case names; $rawBody is the request body exactly as received.
  function flow_webhook_is_valid(array $headers, string $rawBody): bool
  {
      $id = $headers['webhook-id'] ?? '';
      $timestamp = $headers['webhook-timestamp'] ?? '';
      if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) return false;

      $key = base64_decode(preg_replace('/^whsec_/', '', getenv('FLOW_WEBHOOK_SECRET')), true);
      if ($key === false) return false;
      $expected = hash_hmac('sha256', "{$id}.{$timestamp}." . $rawBody, $key, true);

      foreach (explode(' ', $headers['webhook-signature'] ?? '') as $entry) {
          [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
          if ($version !== 'v1') continue;
          $signature = base64_decode($signature, true);
          if ($signature !== false && hash_equals($expected, $signature)) return true;
      }
      return false;
  }

  // In your handler, read the body before anything parses it:
  // $headers = array_change_key_case(getallheaders(), CASE_LOWER);
  // flow_webhook_is_valid($headers, file_get_contents('php://input'))
  ```
</CodeGroup>

Libraries for Go, Java, Kotlin, Ruby, PHP, Rust and Elixir are listed on [standardwebhooks.com](https://www.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.

```text Test vector theme={null}
webhook-id: evt_01J9ZQ8X3M4N7P9R2T6V8W0Y1Z
webhook-timestamp: 1790000000
webhook-signature: v1,J8SXTIgoO0j6FZRSeD9oqv6KqtQ+TMCcKe7KImfpcTw= v1a,bVABD85Xr0M87t1bvbZiMBFIZULWoDBa+ZBQ50mylX6YfbmLujnn5/tJ3NDNDJmgbHbWBvcR7J/eD8mb0Z5rDw==
signing secret: whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
public key: whpk_T9CZzNR9eJPf6ewkQU7LDZtUICMqrTDZHEZb4zy+ZcQ=
body: {"id":"evt_01J9ZQ8X3M4N7P9R2T6V8W0Y1Z","type":"webhook.test","timestamp":"2026-09-21T14:13:20.000Z","apiVersion":"2026-10-01","organisationId":"org-test-1","customerId":"customer-test-1","livemode":false,"data":{"endpointId":"ep_test_0001","message":"This is a test event from FlowPayroll. If you can read this, your endpoint is set up correctly."},"metadata":{"resourceUrl":null,"correlationId":null,"documentUrl":null,"payloadStyle":"Full","payloadDowngraded":false,"dateFormat":"yyyy-MM-dd","dateTimeFormat":"yyyy-MM-ddTHH:mm:ss.fffZ"}}
```

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.

<Note>
  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.
</Note>

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.