Skip to main content

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.

This is the beta webhook API, available in open beta. For the existing webhook system, see Legacy webhooks.

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:
HeaderMeaning
webhook-idA stable identifier for this delivery.
webhook-timestampUnix seconds when Quo signed the request.
webhook-signatureA space-separated list of v1,<base64-signature> entries.
The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw-body}, encoded as base64.
Verification must use the exact raw request body bytes Quo sent. If your middleware parses or rewrites the JSON body first, verification will fail.

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.
You can verify Quo webhook signatures with the svix library — it accepts Quo’s headers and whsec_... key format as-is. Install the library:
npm install svix
Then verify:
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:
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; you can also port the manual example above.