AnswerLine Start free

, Engineering · Tutorials

Verify webhook signatures: HMAC-SHA256 in Python, Node, Go

A webhook signature is a keyed hash the sender computes over the request, so the receiver can prove the request came from the holder of a shared secret and was not modified. Your webhook endpoint is a public URL that accepts unauthenticated POSTs by design: anyone who learns it can send a fake “completed task” and write whatever they want into your data. Verifying the signature, checking its timestamp and deduplicating by task make that endpoint safe to trust.

Below: the signing scheme, the verification steps, and code for Python (Flask and FastAPI), TypeScript (Node and Express) and Go. Each verification function below returns true for the known-good test vector in the next section and false when the timestamp, secret or body is wrong.

The signature scheme

Add webhook: {"url": "https://..."} to an async task and the finished task is POSTed to that URL with Content-Type: application/json. The body is the same JSON that GET /v1/async/task/{taskId} returns for the finished task: task (with status of COMPLETED or FAILED), credits, and response, which holds the engine’s result or the error. There is no separate event envelope.

Two headers matter:

Header Example Signed? Use it for
Webhook-Signature t=1700000000,v1=f347ca57… It is the signature Verification
Webhook-Id 1-4821 No Quoting a delivery to support; never for deduplication

The Webhook-Signature header has these parts, separated by commas:

The signed message is the decimal timestamp, a literal dot, and the body bytes exactly as sent. Nothing else is signed: not the URL, not Webhook-Id, not other headers.

A test vector to check your implementation

Use this to unit-test whatever you write:

secret:  whsec_test
body:    {"task":{"id":"t1"}}
header:  t=1700000000,v1=f347ca571c925c1899c5ed03d85cdf8ae2313d9353f1e86b66c15cd44acc3f2a

The body is exactly those 20 bytes, with no trailing newline. Your function must return true for it when “now” is 1700000000, and false when now is more than 300 seconds away, when the secret differs, or when a single byte of the body changes.

What verification proves

A valid signature proves two things: the sender held your signing secret, and the timestamp and body were not changed after signing. The timestamp check adds a third: the request was signed recently, so a captured delivery cannot be replayed hours later.

It does not prove the delivery is new. Within the tolerance window, the same signed request can arrive twice, whether from a legitimate retry, a redelivery you triggered, or a replay. So pair verification with deduplication by task.id; together they make a duplicate harmless, whatever its origin.

Step by step

  1. Read the raw body as bytes before any JSON parsing or body middleware touches it.
  2. Read the Webhook-Signature header. Split it on commas. Collect every t= value and every v1= value; ignore any part with another name.
  3. Reject malformed headers: anything other than exactly one t, or no v1 at all. Only accept v1 values that are 64 lowercase hex characters.
  4. Check the timestamp: reject when |now - t| > 300 seconds.
  5. Compute expected = HMAC_SHA256(key = secret, message = t + "." + raw body).
  6. Compare in constant time against each v1. Accept if any matches.
  7. Reject with a non-2xx status (400 or 401) on failure. Do not log the body or the header values of rejected requests at a level you ship to third-party log tools.
  8. Parse the verified bytes, skip test deliveries ("test": true), deduplicate by task.id, then store or enqueue.
  9. Answer 2xx within 15 seconds. Anything slower counts as a failed attempt.

Python: a stdlib verifier

No dependencies beyond the standard library. hmac.compare_digest gives the constant-time comparison.

import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300
_T = re.compile(r"t=(\d+)")
_V1 = re.compile(r"v1=([0-9a-f]{64})")


def verify_signature(raw_body: bytes, header: str | None, secret: str, now: float | None = None) -> bool:
    parts = (header or "").split(",")
    timestamps = [m.group(1) for m in map(_T.fullmatch, parts) if m]
    signatures = [m.group(1) for m in map(_V1.fullmatch, parts) if m]
    if len(timestamps) != 1 or not signatures:
        return False
    now = time.time() if now is None else now
    if abs(now - int(timestamps[0])) > TOLERANCE_SECONDS:
        return False
    message = timestamps[0].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)

The now parameter exists so you can test with the vector: verify_signature(b'{"task":{"id":"t1"}}', header, "whsec_test", now=1700000000) returns True.

Flask

request.get_data() returns the body bytes as received. Parse those same bytes after verifying.

import json
import os

from flask import Flask, request

from verify import verify_signature

app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"]


@app.post("/hooks/answers")
def answers():
    raw = request.get_data()  # bytes exactly as received
    if not verify_signature(raw, request.headers.get("Webhook-Signature"), SECRET):
        return "invalid signature", 400
    delivery = json.loads(raw)
    if delivery.get("test"):
        return "", 204  # dashboard test delivery: validly signed, never store it
    enqueue(delivery)  # durable write; slow work happens in a worker
    return "", 204

FastAPI

Take the Request and await request.body(). Do not verify a Pydantic model received as a parameter; it is already parsed.

import json
import os

from fastapi import FastAPI, Request, Response

from verify import verify_signature

app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"]


@app.post("/hooks/answers")
async def answers(request: Request) -> Response:
    raw = await request.body()  # read bytes; do not declare a Pydantic body parameter
    if not verify_signature(raw, request.headers.get("webhook-signature"), SECRET):
        return Response("invalid signature", status_code=400)
    delivery = json.loads(raw)
    if delivery.get("test"):
        return Response(status_code=204)
    await enqueue(delivery)
    return Response(status_code=204)

enqueue stands for your own durable write, such as an insert into an inbox table. The receiver design post covers that part.

TypeScript: Node with node:crypto

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifySignature(rawBody: Buffer, header: string | undefined, secret: string, now = Date.now() / 1000): boolean {
  const parts = (header ?? "").split(",");
  const timestamps = parts.filter((p) => /^t=\d+$/.test(p)).map((p) => p.slice(2));
  const signatures = parts.filter((p) => /^v1=[0-9a-f]{64}$/.test(p)).map((p) => Buffer.from(p.slice(3), "hex"));
  if (timestamps.length !== 1 || signatures.length === 0) return false;
  if (Math.abs(now - Number(timestamps[0])) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${timestamps[0]}.`).update(rawBody).digest();
  // Both sides are 32 bytes (the regex guarantees it), so timingSafeEqual cannot throw.
  return signatures.some((signature) => timingSafeEqual(signature, expected));
}

crypto.timingSafeEqual throws a RangeError when its two buffers differ in length. A verifier that passes an attacker-controlled v1 straight in turns a malformed header into an unhandled exception and a 500, which the sender treats as a failure and retries. Validating the format first, as above, avoids that.

Express

Mount express.raw() on the webhook route so req.body is a Buffer. If the app registers express.json() globally before this route, the body arrives already parsed and verification fails: register the webhook route first, or exclude it from the JSON parser.

import express from "express";
import { verifySignature } from "./verify";

const app = express();

app.post("/hooks/answers", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("webhook-signature"), process.env.WEBHOOK_SECRET!)) {
    return res.sendStatus(400);
  }
  const delivery = JSON.parse(req.body.toString("utf8"));
  if (delivery.test) return res.sendStatus(204);
  await enqueue(delivery); // durable write, then answer
  res.sendStatus(204);
});

The TypeScript SDK, @answerline/sdk on npm, exports the same check as verifyWebhook(body, header, secret, toleranceSeconds = 300). It returns a promise, uses WebCrypto’s constant-time verify, and runs on Node 18+, Deno, Bun and browsers. In Python, use the stdlib function above.

Go

hmac.Equal is constant-time. Read the body once with a size cap and verify those bytes.

package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

var (
	tPart  = regexp.MustCompile(`^t=([0-9]+)$`)
	v1Part = regexp.MustCompile(`^v1=([0-9a-f]{64})$`)
)

func verifySignature(rawBody []byte, header, secret string, now time.Time) bool {
	var timestamps []string
	var signatures [][]byte
	for _, part := range strings.Split(header, ",") {
		if m := tPart.FindStringSubmatch(part); m != nil {
			timestamps = append(timestamps, m[1])
		} else if m := v1Part.FindStringSubmatch(part); m != nil {
			sig, _ := hex.DecodeString(m[1])
			signatures = append(signatures, sig)
		}
	}
	if len(timestamps) != 1 || len(signatures) == 0 {
		return false
	}
	ts, err := strconv.ParseInt(timestamps[0], 10, 64)
	if err != nil {
		return false
	}
	if age := now.Unix() - ts; age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamps[0] + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, sig := range signatures {
		if hmac.Equal(sig, expected) {
			return true
		}
	}
	return false
}

func HandleAnswers(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	if !verifySignature(raw, r.Header.Get("Webhook-Signature"), os.Getenv("WEBHOOK_SECRET"), time.Now()) {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}
	// Parse raw, skip "test": true, write it somewhere durable, then answer.
	w.WriteHeader(http.StatusNoContent)
}

The 10 MiB cap is illustrative. Results that include markdown or many sources can be large, so size the cap to what your tasks return rather than copying a small default.

Secret rotation without failed deliveries

Rotate the secret on the Webhooks page of the dashboard whenever it may have leaked. The timing is:

A safe procedure:

  1. Rotate in the dashboard and copy the new secret into your secret store.
  2. Wait at least one minute.
  3. Deploy the receiver with the new secret. Deliveries still carry the old signature too, so instances that have not picked up the change keep verifying.
  4. Finish the rollout well inside the 24 hours.

If your deploys cannot be timed like that, let the receiver hold a list of secrets and accept a delivery when any secret matches any v1. Remove the old secret once the dashboard shows it has stopped signing. You cannot rotate again until then: a second rotation would stop the first secret signing at once, so the dashboard refuses it.

Redeliveries are signed with the secrets valid when they are sent, not when the original delivery was made.

Pitfalls that break verification

Verifying a re-serialized body

Parsing JSON and serializing it again changes the bytes: whitespace, key order, number formatting and Unicode escaping can all differ. In Python, json.dumps(json.loads(b'{"task":{"id":"t1"}}')) produces {"task": {"id": "t1"}}, with spaces, and no longer verifies. Hash the bytes you received.

Middleware that rewrites the body

Global JSON parsers, request loggers that replace the stream, and gateways that rewrite payloads all cause verification to fail on every request. If every delivery fails with a secret you know is correct, look here first.

Non-constant-time comparison

== or === on strings can return as soon as one character differs, which leaks how much of a guess was right through response timing. Use hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal, or WebCrypto verify.

Checking only the first v1

During rotation the header carries two signatures. Code that reads one value into a map (Object.fromEntries, a Python dict) keeps only one of them, and fails whenever it keeps the one for the secret you do not hold.

Skipping the timestamp

Without it, a delivery captured once verifies forever.

Clock skew

The check compares the sender’s clock with yours. A receiver whose clock drifts more than five minutes rejects every delivery. Run NTP on receiver hosts and containers. The tolerance is symmetric, because a fast sender clock is as possible as a slow receiver clock.

Deduplicating by Webhook-Id

It is the same on every retry and redelivery of a delivery, but it is not signed. Use task.id from the verified body.

Getting the order wrong

Storing before verifying lets forged data in. Answering non-2xx after the result is already stored causes a retry, which deduplication absorbs but which wastes work. Answering 2xx before the durable write loses the result if the process dies in between, and nothing will resend it. Verify, write durably, then answer 2xx.

Redirects

A 3xx counts as a failed attempt; redirects are not followed. Register the final URL, for example https:// with the exact path and trailing-slash convention your framework serves.

Hard-coding the secret

Load it from an environment variable or secret manager so rotation is a config change, not a code change.

A verification checklist

For what happens after verification (retries, ordering, reconciliation), read designing a webhook receiver that survives outages. To choose between webhooks, polling and synchronous calls, see sync calls, async tasks or webhooks, and for safe task creation, idempotency keys explained.

The full delivery format, retry schedule and dashboard tools are in the webhooks documentation.

Questions

How is a webhook delivery from this API signed?

Each delivery has a Webhook-Signature header of the form t=<unix seconds>,v1=<hex>. The v1 value is the hex HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with your account's signing secret (whsec_...).

Why does my signature check fail even with the right secret?

Almost always because the body was parsed and serialized again before hashing, or a framework consumed and replaced the raw bytes. Compute the HMAC over the exact bytes received, before any JSON parsing.

How old can a webhook timestamp be before I reject it?

The SDKs default to a tolerance of 300 seconds in either direction. Each attempt is signed when it is sent, so retries arrive with a fresh timestamp and are not rejected as stale.

What happens to signatures when I rotate the webhook secret?

Within a minute of rotating, deliveries carry two v1 values, one per secret, and the previous secret keeps signing for 24 hours. Accept a delivery if any v1 matches, and switch your receiver to the new secret inside that window.

Should I deduplicate deliveries by the Webhook-Id header?

No. Webhook-Id is not covered by the signature, so it can be altered in transit by anyone able to replay a request. Deduplicate by task.id, which is inside the signed body.

How do I recognize a test delivery from the dashboard?

Test deliveries carry "test": true at the top level of the body and are validly signed. Verify them like any other delivery, then return 2xx without storing the sample task.

Try it on your own prompts

100 free requests on any engine, no card. One POST returns the answer, sources and citations as JSON.

Keep reading