bext.dev
DocsCapabilities
Capabilities8 min read
On this page

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:

rust
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:

rust
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:

rust
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:

rust
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.

rust
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:

rust
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:

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.

Warning

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.

Tip

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.

  • Paymentdecode_webhook_event runs 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
Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.