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

# Verify Webhook Signatures

> Authenticate Unifystays webhook requests with the raw body, timestamp, and HMAC-SHA256 signature.

Every webhook request includes these headers:

| Header                          | Value                                                             |
| ------------------------------- | ----------------------------------------------------------------- |
| `X-UnifyStay-Webhook-Id`        | Stable event ID, such as `evt_...`.                               |
| `X-UnifyStay-Webhook-Timestamp` | Unix timestamp in seconds.                                        |
| `X-UnifyStay-Webhook-Signature` | Comma-separated timestamp and signatures, such as `t=...,v1=...`. |

The signature is the lowercase hexadecimal HMAC-SHA256 digest of:

```text theme={null}
{timestamp}.{exact_raw_request_body}
```

<Warning>
  Verify the exact raw request bytes. Parsing JSON and serializing it again can
  change whitespace or key ordering and invalidate the signature.
</Warning>

## Node.js Example

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyUnifystaysWebhook(
  rawBody: Buffer,
  timestampHeader: string,
  signatureHeader: string,
  secrets: string[],
) {
  const timestamp = Number(timestampHeader);
  if (!Number.isInteger(timestamp)) return false;

  // Reject requests more than five minutes away from the server clock.
  const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (age > 300) return false;

  const received = signatureHeader
    .split(",")
    .map((part) => part.trim().split("=", 2))
    .filter(
      ([version, value]) =>
        (version === "v1" || version === "v0") && /^[a-f0-9]{64}$/.test(value),
    )
    .map(([, value]) => Buffer.from(value, "hex"));

  const signedPayload = `${timestamp}.${rawBody.toString("utf8")}`;
  return secrets.some((secret) => {
    const expected = createHmac("sha256", secret)
      .update(signedPayload, "utf8")
      .digest();
    return received.some(
      (candidate) =>
        candidate.length === expected.length &&
        timingSafeEqual(candidate, expected),
    );
  });
}
```

Pass the current secret in `secrets`. During a planned rotation, temporarily
pass both the new and previous secrets until the overlap expires.

## Rotation Behavior

When you rotate a secret, the new secret is active immediately and the previous
secret remains valid for 24 hours. During that overlap the signature header is:

```text theme={null}
t=1786177800,v1=<new-secret-signature>,v0=<previous-secret-signature>
```

Deploy verification for the new secret before removing the old one. After the
`previous_secret_valid_until` timestamp, remove the previous secret from your
receiver.

## Safe Processing Order

1. Read the raw request body.
2. Validate the timestamp and signature.
3. Parse the JSON.
4. Deduplicate by the event `id`.
5. Persist or enqueue the event.
6. Return a `2xx` response.

Never perform irreversible booking work before signature verification and
deduplication succeed.
