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 to serde_json::Value and 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 a header(name) helper that does a case-insensitive first-match lookup.

- url is 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_ms is 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||value with 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 v1 tags 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.