Skip to content

Adding support for an stripe web hook endpoint.#3174

Merged
bbartman merged 14 commits into
masterfrom
bmb/3167-stripe-webhooks
Jul 16, 2026
Merged

Adding support for an stripe web hook endpoint.#3174
bbartman merged 14 commits into
masterfrom
bmb/3167-stripe-webhooks

Conversation

@bbartman

Copy link
Copy Markdown
Contributor

Description:

We are adding support for a stripe webhook endpoint to the control plane api. In order to achieve the desired behavior we have to pass the tenant name to setup_intent which will return the tenant name to us inside of the event structure.

Workflow steps:

This only has to be done once:
We have to set the url inside of stripe to be /api/v1/stripe/webhook this will give us a stripe secret.
We have to configured the environment variable STRIPE_WEBHOOK_SECRET to have the value of that secret.

Once we've done that there is no need to change anything else, there is no harm in doing this more than once, so long as we always use the consistent secret (We cannot have more than one secret registered to receive webhook info at this time). So there should only ever be a single endpoint registered for now.

Documentation links affected:

(list any documentation links that you created, or existing ones that you've identified as needing updates, along with a brief description)

Notes for reviewers:

Stripe Webhook Endpoint — setup_intent.succeeded → wake TenantController

Context

Today, a tenant's payment/billing state is reconciled by the TenantController
agent, which is woken via the SQL function internal.wake_tenant_controller(...).
In the app, the setBillingPaymentMethod GraphQL mutation calls that wake after
updating the default payment method
(crates/control-plane-api/src/server/public/graphql/billing/mutations.rs:112).

We want Stripe to be able to notify us directly when a payment method is set up,
so the controller reconciles even for out-of-band / async completions of the
SetupIntent flow. We'll add a Stripe webhook receiver that verifies the event
signature, and on setup_intent.succeeded wakes the TenantController for the
tenant — exactly as the mutation does.

Decisions (confirmed with user):

  • Event handled: only setup_intent.succeeded.
  • Tenant resolution: read the tenant from the event's SetupIntent metadata.
    This requires stamping metadata["estuary.dev/tenant_name"] = <tenant> onto the
    SetupIntent at creation (it isn't set today).
  • Signing secret: a baked-in constant used as a dev/test fixture only.
    Production must set STRIPE_WEBHOOK_SECRET; if unset the endpoint fails closed
    (no silent fallback to the source-tree constant in prod).
  • Path: POST /api/v1/stripe/webhook, in a new server/public/stripe_webhooks/ module.

Key facts from exploration

  • TENANT_METADATA_KEY = "estuary.dev/tenant_name"crates/billing-types/src/lib.rs:7.
  • Wake primitive: sqlx::query!("SELECT internal.wake_tenant_controller($1::TEXT)", tenant)
    — precompiled query metadata already exists in .sqlx/, so reusing this exact
    string needs no cargo sqlx prepare. The Rust helper wake_tenant_controller
    (mutations.rs:218) is private; we inline the one-liner in the new handler.
  • App state: crate::App struct (server/mod.rs:35) + App::new (:46), built in
    the agent binary (crates/agent/src/main.rs:366) and in tests
    (crates/control-plane-api/src/test_server.rs:117). Secrets are passed as
    constructor args, not read here.
  • Existing secret clap pattern: crates/agent/src/main.rs:44#[derivative(Debug="ignore")]
    • #[clap(long, env=...)] on an Option<String>, so the secret never logs.
  • Handler pattern to mirror: token_exchange::handle_post_token
    (server/public/token_exchange.rs:28) — State<Arc<crate::App>>, returns
    Result<Json<T>, crate::ApiError>. No handler currently reads a raw body; use
    axum::body::Bytes (must be the last extractor) since signature verification
    needs the exact bytes.
  • Router: api_v1_router (server/public/mod.rs:55) supports both .api_route(...)
    (OpenAPI) and plain .route(...). Use plain .route(...) — a webhook shouldn't
    appear in the public OpenAPI spec and a raw-Bytes handler avoids aide trait bounds.
  • stripe::Webhook::construct_event(payload: &str, sig: &str, secret: &str) does
    timestamp check + HMAC-SHA256 verify + deserialize into stripe::Event. It (and
    EventType/EventObject) are gated behind the async-stripe webhook-events
    feature, not currently enabled.
  • create_setup_intent callers/impls: trait billing/provider.rs:33, real impl
    billing/stripe_impl.rs:161, mock billing/memory.rs:176, caller mutations.rs:78.

Implementation steps

1. Enable the async-stripe webhook feature

Cargo.toml:35 — add "webhook-events" to the async-stripe feature list
(alongside billing, checkout, runtime-tokio-hyper).

2. Stamp the tenant onto the SetupIntent at creation

So the setup_intent.succeeded payload carries the tenant in its metadata.

  • billing/provider.rs:33 — add a tenant: &str param to create_setup_intent.
  • billing/stripe_impl.rs:161 — set metadata: Some(HashMap::from([(billing_types::TENANT_METADATA_KEY.to_string(), tenant.to_string())]))
    on stripe::CreateSetupIntent.
  • billing/memory.rs:176 — update the mock signature to match (body unchanged).
  • mutations.rs:78 — pass tenant.as_str() (already validated at mutations.rs:41).

3. Thread the webhook secret through App state (fails closed in prod)

  • server/mod.rs:35 — add field pub stripe_webhook_secret: Option<String> to App.
  • server/mod.rs:46 — add stripe_webhook_secret: Option<String> param to App::new
    and set the field.
  • crates/agent/src/main.rs — add clap arg mirroring the Stripe API key one:
    #[derivative(Debug = "ignore")] #[clap(long = "stripe-webhook-secret", env = "STRIPE_WEBHOOK_SECRET")] stripe_webhook_secret: Option<String>
    (no default_value — unset in prod ⇒ None). Pass args.stripe_webhook_secret
    into App::new at main.rs:366.
  • crates/control-plane-api/src/test_server.rs:117 — pass
    Some(stripe_webhooks::DEV_WEBHOOK_SECRET.to_string()) so integration tests and
    the local stack (via test_server) can exercise verification.

4. New module server/public/stripe_webhooks/mod.rs

  • pub const DEV_WEBHOOK_SECRET: &str = "whsec_..."; — generate one realistic
    whsec_-prefixed value once, hardcode it. Doc-comment that it's a dev/test
    fixture, never a production secret.
  • pub async fn handle_post_stripe_webhook(State<Arc<crate::App>>, headers: HeaderMap, body: Bytes) -> Result<StatusCode, crate::ApiError>:
    1. If app.stripe_webhook_secret is None → log a warning and return an error
      status (fails closed; prod misconfiguration is visible, never trusts the const).
    2. Read the Stripe-Signature header; verify with
      stripe::Webhook::construct_event(std::str::from_utf8(&body)?, sig, secret).
      On error → 400 Bad Request (bad signature / stale timestamp).
    3. match event.type_: EventType::SetupIntentSucceeded → pull the
      stripe::SetupIntent out of event.data.object (EventObject::SetupIntent),
      read metadata[TENANT_METADATA_KEY]. Missing metadata → log + 200 OK (ack,
      nothing to do). Any other event type → 200 OK (ignore).
    4. Inline the wake:
      sqlx::query!("SELECT internal.wake_tenant_controller($1::TEXT)", tenant).execute(&app.pg_pool).await?;
      then return 200 OK.
  • Add a module doc comment explaining why this handler intentionally departs from
    the crate's Envelope/Json<T> convention (Stripe authenticates via signature
    over the raw body, not a JWT bearer).

5. Register the route

server/public/mod.rs — add pub mod stripe_webhooks; and register inside
api_v1_router:
.route("/api/v1/stripe/webhook", axum::routing::post(stripe_webhooks::handle_post_stripe_webhook)).

6. Tests

  • Add dev-deps for signing test payloads if not already present: hmac, sha2,
    hex (a small sign(secret, timestamp, payload) test helper reproducing Stripe's
    t=<ts>,v1=<hex hmac_sha256(secret, "<ts>.<payload>")> scheme).
  • Unit tests in the module:
    • valid signature + setup_intent.succeeded with tenant metadata → handler
      returns 200 (use test_server with a seeded tenant; assert the controller
      task was woken, e.g. its internal.tasks inbox / next-run advanced).
    • bad/again-used signature → 400.
    • unknown event type → 200, no wake.
    • missing tenant metadata → 200, no wake.

Things worth flagging (answers to "anything missing?")

  • Tenant wasn't in the event before — the SetupIntent had no tenant metadata;
    step 2 makes the chosen event-metadata resolution actually work.
  • Raw body is mandatory for signature verification; the handler must not use
    Json<T>. Confirmed no global JSON middleware on the public router interferes
    (ensure_accepts_json is per-route on /catalog/status only).
  • Idempotency — Stripe retries on non-2xx and can redeliver; waking the
    controller is idempotent, so ack fast with 200 and let dedupe be a non-issue.
  • Out-of-scope but required to actually receive events: the endpoint URL and
    the setup_intent.succeeded subscription must be registered in the Stripe
    Dashboard, and the resulting whsec_... set as STRIPE_WEBHOOK_SECRET in prod.

Frontend impact: none required

The tenant stamping is entirely server-side (inside create_setup_intent), so the
client_secret returned by createBillingSetupIntent is unchanged and the browser
never sees or touches the metadata. The SetupIntent confirmation the frontend
already performs (Stripe.js / Elements confirmSetup) is exactly what makes Stripe
fire setup_intent.succeeded — so the event we're newly listening to already fires
today; we're only adding a server-side listener.

Notes:

  • The frontend also calls setBillingPaymentMethod today, which already wakes the
    controller (mutations.rs:112). The webhook adds a second, idempotent wake — a
    safety net, not a capability the frontend must opt into. No change (and no removal)
    is needed there.
  • The real value is out-of-band completions the browser flow doesn't cover: user
    closes the tab after entering card details, or an ACH (us_bank_account)
    SetupIntent that only succeeds asynchronously after bank verification.
  • The frontend lives in a separate repo (not this checkout); the only frontend
    precondition is that it confirms the SetupIntent client-side — which it must
    already do, since that's the only way cards are added today.

Verification

  • cargo build -p control-plane-api -p agent — confirms the webhook-events
    feature, trait-signature change, and App wiring compile across all call sites.
  • cargo test -p control-plane-api stripe_webhook — runs the signature/tenant/wake
    unit + integration tests above.
  • Local smoke (optional): run the local stack with STRIPE_WEBHOOK_SECRET set to
    DEV_WEBHOOK_SECRET, then stripe listen --forward-to <base>/api/v1/stripe/webhook.
    Note a raw stripe trigger setup_intent.succeeded produces an event with no
    tenant metadata (ack-and-ignore); a true end-to-end check drives
    createBillingSetupIntent and completes the intent so our stamped metadata is
    present.

@bbartman
bbartman requested review from jshearer July 15, 2026 13:58
@bbartman
bbartman requested a review from GregorShear July 15, 2026 14:50

@jshearer jshearer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking mostly good, got some nits plus changes to HTTP error codes to prevent causing Stripe to retry erroring requests forever.

Comment thread crates/control-plane-api/src/server/public/stripe_webhooks.rs Outdated
Comment thread crates/control-plane-api/src/server/public/stripe_webhooks.rs Outdated
Comment thread crates/control-plane-api/src/server/public/stripe_webhooks.rs Outdated
Comment thread crates/control-plane-api/src/server/public/stripe_webhooks.rs Outdated
Comment thread crates/control-plane-api/src/billing/stripe_impl.rs Outdated
Comment thread crates/control-plane-api/src/server/public/stripe_webhooks.rs Outdated
Comment thread crates/control-plane-api/src/server/public/stripe_webhooks.rs Outdated

@jshearer jshearer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more -- we should only return 200 if the cause is something we structurally can't handle (i.e invalid/missing tenant), not due to a transient cause like a database connection error etc.

Comment thread crates/control-plane-api/src/server/public/stripe_webhooks.rs

@jshearer jshearer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks :)

@bbartman
bbartman merged commit 5df9df7 into master Jul 16, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants