SKILL.md
Receiving Webhooks
A webhook is just an HTTP POST from a source you don't control. Treat every request as untrusted until its signature is verified. These guidelines apply to any handler consuming webhooks, regardless of which provider or platform sends them.
Header names, secret formats, and tolerances vary by provider. The names below follow the Standard Webhooks specification; always confirm the exact header names, signing scheme, and replay window against your provider's documentation before writing code.
Standard Webhooks
Standard Webhooks is an open specification plus reference libraries in most major languages for sending and verifying webhooks securely and consistently. Instead of every provider inventing its own header names and signing scheme, it defines a single, interoperable format. When your provider supports it, prefer the official standardwebhooks library over hand-rolled verification, it validates the signature, enforces the timestamp tolerance, and guards against replay attacks for you. The examples below use it.
If your provider sends webhooks through Svix (the svix-id, svix-timestamp, and svix-signature headers are the tell), use the official Svix SDK instead.
The non-negotiables
- Verify the signature on every request. An unverified webhook is an
anonymous internet POST. Anyone who learns your URL can forge events. Only act on payloads that pass verification.
- **Verify against the raw request body.** The signature is computed over the
exact bytes sent. Any framework that parses JSON and re-stringifies it breaks verification. Read the unprocessed body.
- Return a
2xxwithin seconds. Anything else, including3xx
redirects, is typically treated as a failure and retried. Push heavy work async.
Handler shape
- Read the raw body. Do not parse JSON before verification.
- Verify the signature using the provider's official library (or its
documented scheme), passing the signature headers and your signing secret. On failure, return 400.
- Acknowledge fast. Return
2xx(e.g.204) immediately; do real work in
a background job/queue if it can exceed a second or two.
- Branch on the payload (event type) and process.
import { Webhook } from "standardwebhooks";
// rawBody must be the RAW request body (string/bytes), not parsed JSON.
// secret is the base64 signing secret for this endpoint.
const wh = new Webhook(secret);
let payload;
try {
// Verifies the signature AND the timestamp tolerance; throws on failure.
payload = wh.verify(rawBody, req.headers);
} catch (err) {
return res.status(400).send(); // verification failed, reject
}
// payload is now trusted; enqueue and ack
return res.status(204).send();
Prefer the provider's official verification library when one exists: it validates the signature and the timestamp tolerance and handles replay protection for you, so you don't reimplement crypto by hand.
Signature headers and the secret
Most providers send some combination of:
| Concept | Typical header | Purpose |
|---|---|---|
| Message ID | webhook-id |
Unique identifier for the message |
| Timestamp | webhook-timestamp |
Send time, used for replay protection |
| Signature | webhook-signature |
HMAC signature(s), often versioned |
- Use the signing secret, not an API key. The secret used to verify inbound
webhooks is usually distinct from the API token you use to call the provider's management API.
- Keep the secret server-side. Never ship it in client bundles or commit it.
Responding, retries, and auto-disable
- Only
2xxmeans success. Every other code (often including3xx) is
treated as a failure and retried, usually on an exponential-backoff schedule.
- Respond within the provider's timeout (commonly a few to ~15 seconds). If
processing can take longer, ack first and work async.
- Use
4xxto reject bad/forged requests (failed verification returns
400); use 5xx/timeouts only for genuine transient failures you want retried.
- Endpoints often auto-disable after sustained failure. Many providers notify
you of this (e.g. via an operational/alert webhook or email). Keep your handler healthy to avoid silent disabling, and wire up failure notifications.
Manual verification (only when no official library exists)
Prefer the provider's official library. If your language genuinely has none, follow the provider's documented signing scheme exactly and don't invent your own. The steps are:
- Extract the secret bytes. The signing secret is prefixed with
whsec_;
strip the prefix and base64-decode the remainder to get the HMAC key.
- Read the signature headers. Take
webhook-id,webhook-timestamp, and
webhook-signature from the request.
- Check the timestamp tolerance. Reject if
webhook-timestampis too far
from now (Standard Webhooks uses a 5-minute window) to guard against replay.
- Build the signed content as
{id}.{timestamp}.{body}, using the raw
body bytes.
- Compute the HMAC-SHA256 of the signed content with the decoded secret and
base64-encode it.
- Compare in constant time.
webhook-signatureis a space-separated list of
v1,<sig> entries; pass if your computed signature matches any of them. Use a constant-time comparison, never ==.
Verification traps (debugging a failing handler)
- Body parsed before verification. Re-stringification changes the bytes. Use
raw-body access.
- Wrong secret. Ensure you're using the correct signing secret
- Secret in the wrong format. Some providers expect the secret decoded from
base64/hex, or with a documented prefix stripped.
- Replaying old captured payloads with curl. Verification rejects stale
timestamps; trigger a fresh delivery (e.g. the provider's "resend") instead.
- Reverse proxy stripping signature headers. Check proxy config.
- Clock skew. Unsynced server time fails timestamp validation.
Checklist
- Signature verified on every request (official library preferred)
- Verification runs against the raw body (no pre-parse)
- Failed verification returns
400 - Handler returns
2xxwithin the provider's timeout; heavy work is async - Signing secret used, kept server-side