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

# Validate webhook signatures

> Verify signed webhook deliveries from Quo.

<Note>
  This is the beta webhook API, available in open beta. For the existing webhook system, see [Legacy webhooks](/mdx/guides/webhooks).
</Note>

## Overview

Quo signs every webhook delivery. Before you trust a payload, verify it by checking the signed headers against the **raw request body bytes**.

Each request includes three headers:

| Header              | Meaning                                                    |
| ------------------- | ---------------------------------------------------------- |
| `webhook-id`        | A stable identifier for this delivery.                     |
| `webhook-timestamp` | Unix seconds when Quo signed the request.                  |
| `webhook-signature` | A space-separated list of `v1,<base64-signature>` entries. |

The signature is HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw-body}`, encoded as base64.

<Warning>
  Verification must use the exact raw request body bytes Quo sent. If your middleware parses or rewrites the JSON body first, verification will fail.
</Warning>

## Webhook key format

When you create a webhook, Quo returns a `key` value prefixed with `whsec_`. Store it exactly as returned.

* The `whsec_` prefix is part of the canonical format; SDK-based verification accepts it as-is.
* If you verify manually, strip `whsec_` and base64-decode the remainder to get the HMAC key bytes.

## SDK verification (recommended)

You can verify Quo webhook signatures with the [`svix`](https://www.npmjs.com/package/svix) library — it accepts Quo's headers and `whsec_...` key format as-is.

Install the library:

```bash theme={null}
npm install svix
```

Then verify:

```ts theme={null}
import { Webhook } from 'svix'

const secret = process.env.QUO_WEBHOOK_KEY ?? '' // whsec_...

const getHeader = (value: string | string[] | undefined, name: string): string => {
  if (typeof value === 'undefined') {
    throw new Error(`Missing required header: ${name}`)
  }

  if (Array.isArray(value)) {
    throw new Error(`Expected a single ${name} header value`)
  }

  return value
}

const headers = {
  'webhook-id': getHeader(request.headers['webhook-id'], 'webhook-id'),
  'webhook-timestamp': getHeader(request.headers['webhook-timestamp'], 'webhook-timestamp'),
  'webhook-signature': getHeader(request.headers['webhook-signature'], 'webhook-signature'),
}

const webhook = new Webhook(secret)

// Throws on error, returns the verified content on success.
const verified = webhook.verify(rawBody, headers)
```

## Manual verification

If you prefer not to add an SDK dependency, verify with Node's built-in `crypto`:

```ts theme={null}
import crypto from 'node:crypto'

const secret = process.env.QUO_WEBHOOK_KEY ?? '' // whsec_...
const MAX_AGE_SECONDS = 5 * 60

const secretBase64 = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret
const secretBytes = Buffer.from(secretBase64, 'base64')

const webhookId = request.headers['webhook-id']
const webhookTimestamp = request.headers['webhook-timestamp']
const webhookSignature = request.headers['webhook-signature']

if (!webhookId || !webhookTimestamp || !webhookSignature) {
  throw new Error('Missing required webhook headers')
}

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

if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > MAX_AGE_SECONDS) {
  throw new Error('Invalid or stale webhook timestamp')
}

const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`

const expectedSignature = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64')

const providedSignatures = webhookSignature
  .split(' ')
  .map((entry) => entry.trim())
  .filter(Boolean)
  .map((entry) => {
    const [version, signature] = entry.split(',')
    return version === 'v1' ? signature : undefined
  })
  .filter((signature): signature is string => Boolean(signature))

const isValid = providedSignatures.some((signature) => {
  const left = Buffer.from(signature)
  const right = Buffer.from(expectedSignature)

  return left.length === right.length && crypto.timingSafeEqual(left, right)
})

if (!isValid) {
  throw new Error('Invalid webhook signature')
}
```

## Implementation notes

* Always verify using the raw request body, before parsing or transforming it.
* Reject deliveries whose `webhook-timestamp` is more than a few minutes off from the current time to protect against replay.
* Store the webhook key (`whsec_...`) exactly as Quo returns it; do not trim, rewrap, or lowercase it.

## Other languages

Any library that supports Quo's signing scheme — HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw-body}` with the `webhook-*` header set and a `whsec_...` base64 secret — can verify Quo webhooks. The Svix SDKs are a convenient off-the-shelf option across [several languages](https://docs.svix.com/receiving/verifying-payloads/how); you can also port the manual example above.
