Webhook
The Webhook capability answers one question: did this inbound HTTP request really come from the third party it claims to be from? A WebhookPlugin is a verifier — hand it the raw inbound request, get back Ok(()) or a classified error, and then let your route touch the payload. The trait is deliberately vendor-neutral: Stripe's timestamped HMAC, GitHub's body HMAC, Shopify's base64 HMAC, Slack's prefixed HMAC and Twilio's URL+form-params HMAC all satisfy the same surface, and a project can swap implementations (or add a sixth) by editing bext.config.toml without touching the route code.
This page covers the trait surface, why the trait is pure-verify with no handle(event), how errors are classified so middleware picks the right HTTP status, what each of the five reference verifiers does, and how to wire a verifier into a bext route.
The trait#
Every webhook verifier plugin implements one trait, WebhookPlugin, defined in bext-plugin-api::webhook:
pub trait WebhookPlugin: Send + Sync {
fn provider(&self) -> &str;
fn scheme(&self) -> WebhookSchemeKind;
fn verify(&self, req: &WebhookRequest) -> Result<(), WebhookError>;
fn is_healthy(&self) -> bool { true }
}
Every method is synchronous — matching every other trait in bext-plugin-api — so the shape travels cleanly across the WASM, QuickJS, and nsjail sandboxes. All five in-scope verifiers are pure in-memory HMAC: no network I/O, nothing to await. Verifiers that later need network access (an OAuth signature scheme with a JWKS fetch, for instance) bridge with block_on the same way SesMailerPlugin does today.
Verify-only: no handle(event)#
The original plan sketch in plan/ecosystem/02-capabilities.md included an async fn handle(&self, event: WebhookEvent) -> Result<HandlerOutcome, WebhookError> alongside verify. This trait deliberately drops it.
The reason is architecture principle 6 (no vendor-specific fields on a trait). A single WebhookEvent type that covers "a Stripe charge.succeeded" and "a GitHub pull_request payload" and "a Shopify orders/create" cannot exist without either being a stringly-typed blob (in which case the trait provides nothing) or dragging five different event decoders into every verifier crate (in which case the capability becomes an open-ended commitment to deserialize everyone's payload). Neither is acceptable.
In bext, that work already has a home: the route. A webhook endpoint is a normal route; the verifier runs as a pre-handler, validates the signature, and if it passes, the route body deserializes the JSON/form payload using whatever shape the application actually cares about. This keeps the trait surface tiny and puts business logic where it belongs.
WebhookRequest#
The verifier takes a minimal, ABI-flat snapshot of the inbound request:
pub struct WebhookRequest {
pub url: String,
pub method: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub received_at_ms: u64,
}
Several of these choices matter:
body: Vec<u8>, not a parsed JSON value. Every in-scope scheme signs the exact byte stream on the wire. Deserializing toserde_json::Valueand re-serializing would mutate whitespace and key ordering and break the HMAC. Middleware passes raw bytes.headers: Vec<(String, String)>, not a map. HTTP allows duplicate header names. Order-preserving storage keeps logs reproducible and avoids losing data. The type has aheader(name)helper that does a case-insensitive first-match lookup.urlis the exact string the client sent. Twilio's scheme signs the full URL — don't pass it through a URL parser and reformat; bit-identical bytes matter.received_at_msis the host's observation of "now". Verifiers that enforce a freshness window (Stripe, Slack) use this rather than reading the system clock directly, which makes tests deterministic and keeps a single middleware-owned clock for the whole request path.
WebhookSchemeKind#
Plugins self-identify which family they implement so admin tooling can render the right config shape:
pub enum WebhookSchemeKind {
HmacBody, // GitHub, Shopify
HmacTimestampedBody, // Stripe, Slack
HmacUrlFormFields, // Twilio
}
This is a hint, not a binding — new schemes land as new variants as vendors are added.
Error classification and HTTP mapping#
Every failure flows through one enum, and the variant is the thing middleware branches on to pick a status code:
pub enum WebhookError {
MissingHeader(String),
MalformedPayload(String),
InvalidSignature(String),
ReplayDetected(String),
Backend(String),
}
| Variant | HTTP status | Retry? | Meaning |
|---|---|---|---|
MissingHeader |
400 | No — fix the sender. | A required signature header is not on the request. |
MalformedPayload |
400 | No — fix the sender. | Header format is wrong (bad hex, missing t= segment, non-UTF-8 body). |
InvalidSignature |
401 | No — wrong secret or tamper. | HMAC comparison failed. Indistinguishable from a forgery. |
ReplayDetected |
401 | No — resend with new ts. | Signature is valid but the timestamp falls outside the freshness window. |
Backend |
500 | Once, cautiously. | Host-side fault (empty signing secret, clock probe failed). |
WebhookError::http_status() returns the recommended status code for each variant. Middleware is free to override — the trait only advises.
The reference plugins#
Lives in crates/bext-impls/bext-webhook-verifiers. One crate, five modules — stripe, github, shopify, slack, twilio — sharing a single hmac_helpers module so that constant-time comparison (Hmac::verify_slice, which is constant-time by construction) and MAC construction live in one place. Dependencies: hmac + sha2 + sha1 + base64. No async runtime, no network stack, no tokio.
Stripe — StripeWebhookPlugin#
Spec: https://stripe.com/docs/webhooks/signatures.
Header: Stripe-Signature: t=<unix-ts>,v1=<hex>[,v1=<hex>]. Multiple v1 entries can appear during a secret rotation; the verifier accepts the request if any matches. The signed payload is the byte string {t}.{body} under the webhook signing secret (whsec_... in the dashboard).
Replay window: default 300s, configurable via with_tolerance_secs. The plugin compares received_at_ms (or the system clock) against the t= segment and returns ReplayDetected when drift exceeds the tolerance.
use bext_webhook_verifiers::StripeWebhookPlugin;
let verifier = StripeWebhookPlugin::new(b"whsec_abc...".to_vec())
.with_tolerance_secs(300);
GitHub — GithubWebhookPlugin#
Spec: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries.
Header: X-Hub-Signature-256: sha256=<hex>. The signed payload is the raw body under the webhook's shared secret. No timestamp on the wire, so no replay-window check — routes that want idempotency deduplicate against the X-GitHub-Delivery header in the handler.
The published doc vector (secret = "It's a Secret to Everybody", body = "Hello, World!", signature sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17) is reproduced byte-for-byte in the crate's test suite.
Shopify — ShopifyWebhookPlugin#
Spec: https://shopify.dev/docs/apps/build/webhooks/subscribe/https#verify-a-webhook.
Header: X-Shopify-Hmac-Sha256: <base64>. The signature is the base64 of HMAC-SHA256(api_secret, raw_body). No prefix, no timestamp. Shopify recommends app-level deduplication via X-Shopify-Webhook-Id.
Slack — SlackWebhookPlugin#
Spec: https://api.slack.com/authentication/verifying-requests-from-slack.
Headers: X-Slack-Request-Timestamp: <unix-ts> plus X-Slack-Signature: v0=<hex>. The signed string is v0:{ts}:{raw_body} under the app's signing secret. Slack recommends rejecting requests with a drift larger than 5 minutes, which the plugin does by default.
The published Slack doc vector is reproduced in the test suite: a slash-command form body, ts=1531420618, signature v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503, secret 8f742231b10e8888abcd99yyyzzz85a5.
Twilio — TwilioWebhookPlugin#
Spec: https://www.twilio.com/docs/usage/webhooks/webhooks-security.
Header: X-Twilio-Signature: <base64>. Twilio uses HMAC-SHA1 (the only in-scope vendor on SHA-1) under the account's auth token. The signed string is:
- the full request URL (scheme + host + path + query — bit-identical to what the client sent), followed immediately by
- for form POSTs: every form field rendered as
key||valuewith the pairs sorted alphabetically by key, concatenated with no separator between pairs. - for non-form POSTs (e.g. JSON): nothing — the URL alone is signed.
The verifier detects the two modes by looking at Content-Type: application/x-www-form-urlencoded selects the form path, anything else (or absent) selects the URL-only path. Twilio does not carry a signed timestamp, so there is no replay-window check.
Wiring a verifier into a route#
A typical usage is a middleware that runs before the body handler. Pseudocode:
use bext_plugin_api::webhook::{WebhookPlugin, WebhookRequest};
use bext_webhook_verifiers::StripeWebhookPlugin;
// At startup:
let stripe = StripeWebhookPlugin::new(std::env::var("STRIPE_SECRET").unwrap().into_bytes());
// Per request:
async fn stripe_handler(req: HttpRequest) -> HttpResponse {
let wr = WebhookRequest {
url: req.full_url(),
method: req.method().to_string(),
headers: req.headers_clone(),
body: req.body_bytes().await,
received_at_ms: now_ms(),
};
if let Err(e) = stripe.verify(&wr) {
return HttpResponse::new(e.http_status()).body(e.to_string());
}
// Signature valid — now deserialize and act.
let event: StripeEvent = serde_json::from_slice(&wr.body).unwrap();
// ... business logic ...
HttpResponse::ok()
}
The value of the capability is not that it hides the route wiring — it is that it centralises the verification algorithm, which is subtle (constant-time compare, replay window, UTF-8 handling, base-string construction) and easy to get wrong.
Configuration#
Each verifier reads its signing secret from bext.config.toml:
[webhooks.stripe]
plugin = "@bext/webhook-verifiers"
provider = "stripe"
secret = "{{env.STRIPE_WEBHOOK_SECRET}}"
tolerance_secs = 300
[webhooks.github]
plugin = "@bext/webhook-verifiers"
provider = "github"
secret = "{{env.GITHUB_WEBHOOK_SECRET}}"
[webhooks.slack]
plugin = "@bext/webhook-verifiers"
provider = "slack"
secret = "{{env.SLACK_SIGNING_SECRET}}"
tolerance_secs = 300
[webhooks.shopify]
plugin = "@bext/webhook-verifiers"
provider = "shopify"
secret = "{{env.SHOPIFY_API_SECRET}}"
[webhooks.twilio]
plugin = "@bext/webhook-verifiers"
provider = "twilio"
auth_token = "{{env.TWILIO_AUTH_TOKEN}}"
Secrets never live in the file itself — the {{env.FOO}} syntax resolves to the corresponding environment variable at load time.
Out of scope#
- Event decoding. Routes deserialize payloads in whatever shape they need. The trait is a verifier only.
- Idempotency. Dedup against the vendor's delivery-id header in the route handler.
- Response shapes. Each vendor has its own retry semantics and "please send
200" requirements; those belong in the route, not the verifier. - Secret rotation. Stripe supports multiple
v1tags on the wire for rotation windows; other vendors don't. Plugins accept any matching tag; multi-secret rotation at config level is a future enhancement.
The trait is a verifier. Everything richer layers above it.
The verifier takes the raw byte body — do not parse the JSON before calling verify. Every HMAC scheme signs the exact bytes on the wire; deserializing and re-serializing even once will invalidate the signature.
For payment providers, the Payment capability's decode_webhook_event assumes the body has already been authenticated. Always run the webhook verifier first, then decode.
Related#
- Payment —
decode_webhook_eventruns after webhook verification - Auth — request-level authentication; webhook verification is a separate surface
- Capabilities overview — the full list of pluggable capabilities
- Plugin system overview — how plugins are packaged, sandboxed, and loaded
- Security guide — broader request-security patterns on bext