“How do I verify a CaptchaKit webhook signature?”

How do I verify a CaptchaKit webhook signature?

Short answer

Read the X-CaptchaKit-Signature header, which looks like t=<unix-ms>,v1=<hex>. Compute an HMAC-SHA256 of the string t, a period, then the raw request body, using your endpoint secret, and compare it to v1 in constant time. Use the raw body bytes, not re-serialized JSON. Webhooks are a Business plan feature.

1Capture the rawbody2Split the header3Compute the HMAC4Compare inconstant time5Check thetimestamp

Steps

  1. 1

    Capture the raw body

    Read the request body as text before any JSON parsing.

  2. 2

    Split the header

    Get `t` (Unix milliseconds) and `v1` (hex) from `X-CaptchaKit-Signature`.

  3. 3

    Compute the HMAC

    HMAC-SHA256 with your endpoint secret over `t`, a period, then the raw body.

  4. 4

    Compare in constant time

    Use `crypto.timingSafeEqual` after checking both values have the same length.

  5. 5

    Check the timestamp

    Reject deliveries whose `t` is far from the current time.

Events and payload

A Business account can add webhook endpoints and subscribe each one to `verification.passed`, `verification.failed` and `game.solved`. Every delivery is a POST with a JSON body and these fields: `type`, `siteKey`, `gameId`, `success`, `timestamp`, and `failReason` on failures.

The `failReason` values are the same error strings that `POST /api/verify` returns, such as `Invalid or expired token` and `Token already used`. The secret for an endpoint is shown once, when you create it, so store it in an environment variable.

Verify in Node

This function mirrors the signing code. The five minute tolerance is our suggestion, not a value the service enforces, so tune it to your clock drift.

Express parses JSON by default, which changes the bytes. Use `express.raw` on the webhook route so you hold the exact body, then parse it yourself after the check passes.

import crypto from 'node:crypto';

export function verifyCaptchaKitSignature(rawBody, header, secret, toleranceMs = 5 * 60 * 1000) {
  const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() - Number(parts.t)) > toleranceMs) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

import express from 'express';

const app = express();

app.post('/captchakit-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const header = req.get('X-CaptchaKit-Signature') ?? '';
  if (!verifyCaptchaKitSignature(rawBody, header, process.env.CAPTCHAKIT_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(rawBody);
  // event.type, event.siteKey, event.gameId, event.success, event.timestamp
  res.sendStatus(200);
});

Delivery and auto-disable

Respond with a 2xx status quickly. A delivery has a 5 second timeout, and any non-2xx response or timeout counts as a failure. After 20 consecutive failures the endpoint is disabled automatically, and a successful delivery resets the count. Each event is sent once, so make your handler idempotent and log what you receive.

Questions

Which header carries the signature?

X-CaptchaKit-Signature, in the form t=<unix-ms>,v1=<hex>.

Which algorithm is used?

HMAC-SHA256 over the text t, a period, then the raw request body, keyed with the endpoint secret and hex encoded.

Why does my signature not match?

Most often the body was parsed and re-serialized before hashing. Hash the raw bytes exactly as received.

Do I need a Business plan?

Yes. Webhooks are part of the Business plan.

What happens if my endpoint is down?

Failed deliveries are counted, and the endpoint is disabled after 20 consecutive failures. Each event is sent once.

Should I check the timestamp?

Yes. The timestamp is part of the signed text, so checking that it is recent limits replay of a captured request.

Where do I get the endpoint secret?

It is shown once when you create the endpoint in your dashboard. Store it in an environment variable.

Sources

  1. CaptchaKit documentation (checked 2026-10-18)
  2. Node.js crypto documentation (checked 2026-10-18)
  3. Node.js crypto.createHmac (checked 2026-10-18)

Related

Protect your forms with a game

Every arcade CAPTCHA we make. Sign up, grab your embed code, protect your forms today.

Get started →Pay what you want. Start free.