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

# Verifying signatures

> Check that every webhook request came from KYC and wasn't changed.

KYC signs every webhook request with the [Standard Webhooks](https://www.standardwebhooks.com/) scheme.
Verify the signature before you trust or process anything in the request.

You can use one of the Standard Webhooks libraries listed on that site, or the short code below.

## How the signature works

Three headers carry the signature:

| Header | Contents |
| - | - |
| `webhook-id` | The event ID. |
| `webhook-timestamp` | Unix time in seconds when this attempt was sent. |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 signature. |

To verify a request:

1. Take the **raw request body**, exactly as received. Don't parse and re-encode the JSON first, or the bytes will differ.
2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Take your signing secret, remove the `whsec_` prefix, and base64-decode the rest. The result is the HMAC key.
4. Compute HMAC-SHA256 of the signed content with that key, and base64-encode it.
5. Compare it with each signature in `webhook-signature`, using a constant-time comparison. The request is valid if any one matches.
6. Reject the request if `webhook-timestamp` is more than 5 minutes from your clock. This stops someone replaying an old request.

> [!NOTE]
>
> `webhook-signature` can hold two signatures, separated by a space. KYC sends both the new and the old
> signature for 24 hours after your signing secret is rotated, so you can update your secret without
> missing events.

## Code samples

```javascript Node.js theme={null}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// rawBody must be the unparsed request body (a Buffer or string).
export function verifyKycWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];

  if (!id || !timestamp || !signatures) {
    return false;
  }

  const sentAt = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);

  if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) {
    return false;
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.split(" ").some((signature) => {
    const [version, value] = signature.split(",");

    if (version !== "v1" || !value) {
      return false;
    }

    const received = Buffer.from(value, "base64");

    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

// Express: keep the raw body for this route.
// app.post("/kyc-webhooks", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyKycWebhook(process.env.KYC_WEBHOOK_SECRET, req.headers, req.body)) {
//     return res.sendStatus(400);
//   }
//   queueForProcessing(req.body);
//   res.sendStatus(204);
// });
```

```php PHP theme={null}
<?php

const KYC_WEBHOOK_TOLERANCE_SECONDS = 300;

function verifyKycWebhook(string $secret, string $id, string $timestamp, string $signatures, string $rawBody): bool
{
    if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > KYC_WEBHOOK_TOLERANCE_SECONDS) {
        return false;
    }

    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);

    if ($key === false) {
        return false;
    }

    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));

    foreach (explode(' ', $signatures) as $signature) {
        [$version, $value] = array_pad(explode(',', $signature, 2), 2, '');

        if ($version === 'v1' && hash_equals($expected, $value)) {
            return true;
        }
    }

    return false;
}

$valid = verifyKycWebhook(
    getenv('KYC_WEBHOOK_SECRET'),
    $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
    $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
    file_get_contents('php://input'),
);
```

## Test your code

Check your implementation against this example from the Standard Webhooks specification.
Skip the timestamp check for this test, since the timestamp is in the past.

| Input | Value |
| - | - |
| Secret | `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` |
| `webhook-id` | `msg_p5jXN8AQM9LWM0D4loKWxJek` |
| `webhook-timestamp` | `1614265330` |
| Raw body | `{"test": 2432232314}` |
| Expected `webhook-signature` | `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` |

Then ask KYC for a [ping](https://api-docs.kychospitality.com/api-reference/webhooks/webhook-ping.md) to your endpoint, signed with your own secret.

## Keep your secret safe

* Store the signing secret in a secrets manager or environment variable, never in source code.
* Never log it, or the raw `webhook-signature` header.
* If you think the secret has leaked, ask KYC to rotate it.


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