Provider verifiers
Ready-made verifiers for Stripe, GitHub, Shopify, Twilio, and Slack — one factory each, composing like any other Verifier.
Secret, PublicKey, and Keyset speak Standard Webhooks. For the top non-Standard-Webhooks senders, @postel/core ships ready-made verifiers instead of asking you to hand-write one:
| Provider | Factory | Header(s) | Tolerance / replay | Event shape |
|---|---|---|---|---|
| Standard Webhooks | Secret / PublicKey / Keyset | webhook-id, webhook-timestamp, webhook-signature | ±5 min default, configurable | { type, data, timestamp } |
| Stripe | Stripe(secret, options?) | Stripe-Signature (t=, one or more v1=) | ±300s default (Stripe's own default), configurable via { toleranceSeconds, clock } | type/data from the event body |
| GitHub | GitHub(secret) | X-Hub-Signature-256 (sha256=), X-GitHub-Event | None — GitHub sends no timestamp, so there is no replay window at this layer | type from X-GitHub-Event; data is the whole payload |
| Shopify | Shopify(secret) | X-Shopify-Hmac-Sha256 (base64), X-Shopify-Topic | None — Shopify sends no timestamp, so there is no replay window at this layer | type from X-Shopify-Topic; data is the whole payload |
| Twilio | Twilio(authToken, url) | X-Twilio-Signature (base64 HMAC-SHA1 over url + sorted form params) | None — Twilio's scheme has no time component at all | type is the fixed literal "twilio.webhook" (Twilio's wire format carries no event-type field); data is the parsed form parameters |
| Slack | Slack(signingSecret, options?) | X-Slack-Signature (v0=), X-Slack-Request-Timestamp | ±300s default (Slack's own recommendation), configurable via { toleranceSeconds, clock } | type/data from the event body |
import { Postel, Stripe, GitHub, Shopify, Twilio, Slack } from "@postel/core";
import { config } from "./config.js";
export const postel = Postel({
inbound: {
stripe: { verify: Stripe(config.stripeWebhookSecret) },
github: { verify: GitHub(config.githubWebhookSecret) },
shopify: { verify: Shopify(config.shopifyWebhookSecret) },
twilioSms: {
verify: Twilio(config.twilioAuthToken, "https://api.example.com/webhooks/twilio/sms"),
},
slack: { verify: Slack(config.slackSigningSecret) },
},
});All five are ordinary Verifiers — they compose in arrays/maps, report matchedVerifierIndex, and throw the same SignatureInvalid/MalformedHeader/TimestampTooOld errors as the Standard Webhooks verifiers. One important difference: the secret you pass to any of them is used as literal UTF-8 key material, exactly as that provider issues it (a Stripe secret keeps its whsec_ prefix as part of the key) — it is not run through the whsec_-strip-then-base64-decode convention Secret/PublicKey use for Standard Webhooks secrets. Twilio's url must be the exact webhook endpoint URL registered with Twilio (scheme, host, path, and query string) — Twilio signs over that URL, so a reverse proxy that rewrites what your app sees will break verification, the same caveat Twilio's own SDKs carry.
Each provider has its own page with the full recipe — the config one-liner, a gated route, the scheme's details, its gotchas, and how to test locally:
Stripe
Stripe-Signature: t= + v1= HMAC-SHA256, ±300s window, secret rolls.
GitHub
X-Hub-Signature-256 over the raw payload; type from X-GitHub-Event.
Shopify
Base64 HMAC in X-Shopify-Hmac-Sha256; topic from X-Shopify-Topic.
Twilio
SHA-1 over URL + sorted form params — the exact registered URL matters.
Slack
v0 signing scheme with its own ±300s timestamp window.
What's next
- Custom verifiers — write one for any other sender.
- Verify a signed request — the base recipe these plug into.
- Key rotation — composing old + new keys during a window.