Signature verification
Every webhook SimpleQ sends carries an x-simpleq-signature header containing an HMAC-SHA256 of the raw request body, keyed with your queue's signingSecret. Verify it on every request before processing — the signature is what proves the webhook actually came from SimpleQ, not from anyone who happened to learn your webhook URL.
This is the same verification model used by Stripe, GitHub, Slack, and most major webhook providers — so the pattern below should look familiar.
The header
x-simpleq-signature: sha256=<hex>The value is sha256= followed by the lowercase hex digest of HMAC-SHA256(rawBody, signingSecret). The signature is computed over the raw request body as transmitted, so verification reads the same raw bytes.
Where to find signingSecret
Each queue has its own signingSecret. That scoping is intentional: a worker only needs the secrets for the queue(s) it handles, and if one secret is ever exposed, only that queue is affected — every other queue's signatures stay valid and unbreakable. It also means different teams can own different queues without sharing credentials.
The secret is generated when the queue is created and shown to you once at that moment — capture it then. There are two paths:
- Dashboard — when you click Create queue in the dashboard, the next screen shows the signing secret with a Copy button and the message "copy it now — it won't be shown again." Copy it into your worker's environment (env var, secrets manager) before navigating away.
- API — the
POST /v1/queuesresponse includessigningSecretin the JSON body. Read it from the response and store it.
After that initial reveal, the secret is no longer surfaced by GET /v1/queues or GET /v1/queues/:id — it stays in your secrets store, where it can do its job without re-exposure.
In Node, use the SDK
The @simpleq/sdk SDK implements this verification — constant-time compare and raw-body handling included:
import { verifyWebhook } from '@simpleq/sdk';
// Verifies the signature, THEN parses — throws SignatureVerificationError on mismatch.
const job = verifyWebhook(rawBody, req.headers['x-simpleq-signature'], process.env.SQ_SIGNING_SECRET);On Express or Nest.js, the adapters do even the raw-body capture for you: simpleqWebhookHandler (from @simpleq/sdk/express) and SimpleQSignatureGuard (from @simpleq/sdk/nest) verify the signature before your handler runs. See the SDK page for the full worker shape.
Verifying by hand
If you're not on Node — or you want to see exactly what the SDK does under the hood — here is the raw contract, reproducible in any language: HMAC-SHA256 over the raw body bytes, compared in constant time against the sha256=-prefixed header. The examples below use Express and FastAPI, but the logic is identical everywhere.
Express (Node, without the SDK)
On Node you'd normally use the SDK above; this hand-rolled version shows the same contract in a familiar framework. Register express.raw({ type: 'application/json' }) on the webhook route specifically. The handler receives req.body as a Buffer (the raw bytes), which is what the signature was computed against. This is the same shape Stripe's Node webhook quickstart uses:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SIGNING_SECRET = process.env.SQ_SIGNING_SECRET;
function verifySignature(rawBody, header) {
if (!header) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', SIGNING_SECRET).update(rawBody).digest('hex');
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verifySignature(req.body, req.headers['x-simpleq-signature'])) {
return res.status(401).end();
}
const job = JSON.parse(req.body.toString('utf8'));
// ... handle the job ...
res.status(200).end();
},
);
// Other routes can use global JSON parsing as normal.
app.use(express.json());Two details worth knowing:
crypto.timingSafeEqual— comparing HMACs with===leaks information about which byte differed via timing.timingSafeEqualcompares in constant time. Always use it for signature checks.- Length check before
timingSafeEqual—timingSafeEqualthrows if the two buffers have different lengths. The length check short-circuits cleanly when the header is malformed or missing.
FastAPI (Python)
The trap to avoid in FastAPI is the same one Express's express.raw() sidesteps: you must verify against the raw request bytes. Declaring a Pydantic model (or calling await request.json()) hands you re-serialized data — whitespace, key order, and number formatting may all differ from what SimpleQ signed, so the HMAC won't match. Read the body with await request.body() first, verify against those exact bytes, and only then parse:
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Header, Request, Response
app = FastAPI()
SIGNING_SECRET = os.environ["SQ_SIGNING_SECRET"]
def verify_signature(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(
SIGNING_SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
# Constant-time compare; tolerates differing lengths without throwing.
return hmac.compare_digest(expected, header)
@app.post("/webhook")
async def webhook(
request: Request,
x_simpleq_signature: str | None = Header(default=None),
):
# Read the raw bytes BEFORE any parsing — this is what the signature
# was computed over. Do not declare a Pydantic body param here.
raw = await request.body()
if not verify_signature(raw, x_simpleq_signature):
return Response(status_code=401)
job = json.loads(raw)
# ... handle the job ...
return Response(status_code=200)Two details worth knowing:
hmac.compare_digest— comparing HMACs with==leaks information about which byte differed via timing.compare_digestcompares in constant time. Always use it for signature checks (it's Python's equivalent of Node'scrypto.timingSafeEqual).- Don't verify against a parsed body — if you let FastAPI parse the JSON (a Pydantic model param, or
await request.json()) and then re-serialize it to compute the HMAC, the bytes won't match what SimpleQ signed. Compute the HMAC over the originalrawbytes, thenjson.loads(raw)for your own use.
On a failed signature: return 401 and stop
If the signature doesn't match, return 401 and do not process the body. A failed signature means one of:
- The request didn't come from SimpleQ.
- The body was modified in transit.
- Your
signingSecretdoesn't match the queue's current secret.
In all three cases, processing the payload would be unsafe. SimpleQ won't treat a 401 as job failure — it indicates an authentication problem on the receiver, not a job-level outcome.
How this fits the trust model
The signing secret is the contract: SimpleQ holds it, you hold it, and no one else can forge a message that verifies. As long as your handler checks x-simpleq-signature on every request and signingSecret stays confidential, the only requests your worker ever processes are real SimpleQ deliveries — verified for both authenticity (it came from us) and integrity (the body wasn't tampered with in transit).