Adding support for an stripe web hook endpoint.#3174
Merged
Conversation
jshearer
requested changes
Jul 15, 2026
jshearer
left a comment
Contributor
There was a problem hiding this comment.
Looking mostly good, got some nits plus changes to HTTP error codes to prevent causing Stripe to retry erroring requests forever.
jshearer
reviewed
Jul 15, 2026
jshearer
left a comment
Contributor
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/webhookthis will give us a stripe secret.We have to configured the environment variable
STRIPE_WEBHOOK_SECRETto 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 TenantControllerContext
Today, a tenant's payment/billing state is reconciled by the
TenantControlleragent, which is woken via the SQL function
internal.wake_tenant_controller(...).In the app, the
setBillingPaymentMethodGraphQL mutation calls that wake afterupdating 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.succeededwakes the TenantController for thetenant — exactly as the mutation does.
Decisions (confirmed with user):
setup_intent.succeeded.metadata.This requires stamping
metadata["estuary.dev/tenant_name"] = <tenant>onto theSetupIntent at creation (it isn't set today).
Production must set
STRIPE_WEBHOOK_SECRET; if unset the endpoint fails closed(no silent fallback to the source-tree constant in prod).
POST /api/v1/stripe/webhook, in a newserver/public/stripe_webhooks/module.Key facts from exploration
TENANT_METADATA_KEY = "estuary.dev/tenant_name"—crates/billing-types/src/lib.rs:7.sqlx::query!("SELECT internal.wake_tenant_controller($1::TEXT)", tenant)— precompiled query metadata already exists in
.sqlx/, so reusing this exactstring needs no
cargo sqlx prepare. The Rust helperwake_tenant_controller(mutations.rs:218) is private; we inline the one-liner in the new handler.
crate::Appstruct (server/mod.rs:35) +App::new(:46), built inthe
agentbinary (crates/agent/src/main.rs:366) and in tests(
crates/control-plane-api/src/test_server.rs:117). Secrets are passed asconstructor args, not read here.
crates/agent/src/main.rs:44—#[derivative(Debug="ignore")]#[clap(long, env=...)]on anOption<String>, so the secret never logs.token_exchange::handle_post_token(
server/public/token_exchange.rs:28) —State<Arc<crate::App>>, returnsResult<Json<T>, crate::ApiError>. No handler currently reads a raw body; useaxum::body::Bytes(must be the last extractor) since signature verificationneeds the exact bytes.
api_v1_router(server/public/mod.rs:55) supports both.api_route(...)(OpenAPI) and plain
.route(...). Use plain.route(...)— a webhook shouldn'tappear in the public OpenAPI spec and a raw-
Byteshandler avoids aide trait bounds.stripe::Webhook::construct_event(payload: &str, sig: &str, secret: &str)doestimestamp check + HMAC-SHA256 verify + deserialize into
stripe::Event. It (andEventType/EventObject) are gated behind the async-stripewebhook-eventsfeature, not currently enabled.
create_setup_intentcallers/impls: traitbilling/provider.rs:33, real implbilling/stripe_impl.rs:161, mockbilling/memory.rs:176, callermutations.rs:78.Implementation steps
1. Enable the async-stripe webhook feature
Cargo.toml:35— add"webhook-events"to theasync-stripefeature list(alongside
billing,checkout,runtime-tokio-hyper).2. Stamp the tenant onto the SetupIntent at creation
So the
setup_intent.succeededpayload carries the tenant in itsmetadata.billing/provider.rs:33— add atenant: &strparam tocreate_setup_intent.billing/stripe_impl.rs:161— setmetadata: 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— passtenant.as_str()(already validated atmutations.rs:41).3. Thread the webhook secret through App state (fails closed in prod)
server/mod.rs:35— add fieldpub stripe_webhook_secret: Option<String>toApp.server/mod.rs:46— addstripe_webhook_secret: Option<String>param toApp::newand 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). Passargs.stripe_webhook_secretinto
App::newatmain.rs:366.crates/control-plane-api/src/test_server.rs:117— passSome(stripe_webhooks::DEV_WEBHOOK_SECRET.to_string())so integration tests andthe local stack (via test_server) can exercise verification.
4. New module
server/public/stripe_webhooks/mod.rspub const DEV_WEBHOOK_SECRET: &str = "whsec_...";— generate one realisticwhsec_-prefixed value once, hardcode it. Doc-comment that it's a dev/testfixture, never a production secret.
pub async fn handle_post_stripe_webhook(State<Arc<crate::App>>, headers: HeaderMap, body: Bytes) -> Result<StatusCode, crate::ApiError>:app.stripe_webhook_secretisNone→ log a warning and return an errorstatus (fails closed; prod misconfiguration is visible, never trusts the const).
Stripe-Signatureheader; verify withstripe::Webhook::construct_event(std::str::from_utf8(&body)?, sig, secret).On error →
400 Bad Request(bad signature / stale timestamp).match event.type_:EventType::SetupIntentSucceeded→ pull thestripe::SetupIntentout ofevent.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).sqlx::query!("SELECT internal.wake_tenant_controller($1::TEXT)", tenant).execute(&app.pg_pool).await?;then return
200 OK.the crate's
Envelope/Json<T>convention (Stripe authenticates via signatureover the raw body, not a JWT bearer).
5. Register the route
server/public/mod.rs— addpub mod stripe_webhooks;and register insideapi_v1_router:.route("/api/v1/stripe/webhook", axum::routing::post(stripe_webhooks::handle_post_stripe_webhook)).6. Tests
hmac,sha2,hex(a smallsign(secret, timestamp, payload)test helper reproducing Stripe'st=<ts>,v1=<hex hmac_sha256(secret, "<ts>.<payload>")>scheme).setup_intent.succeededwith tenant metadata → handlerreturns
200(usetest_serverwith a seeded tenant; assert the controllertask was woken, e.g. its
internal.tasksinbox / next-run advanced).400.200, no wake.200, no wake.Things worth flagging (answers to "anything missing?")
step 2 makes the chosen event-metadata resolution actually work.
Json<T>. Confirmed no global JSON middleware on the public router interferes(
ensure_accepts_jsonis per-route on/catalog/statusonly).controller is idempotent, so ack fast with
200and let dedupe be a non-issue.the
setup_intent.succeededsubscription must be registered in the StripeDashboard, and the resulting
whsec_...set asSTRIPE_WEBHOOK_SECRETin prod.Frontend impact: none required
The tenant stamping is entirely server-side (inside
create_setup_intent), so theclient_secretreturned bycreateBillingSetupIntentis unchanged and the browsernever sees or touches the metadata. The SetupIntent confirmation the frontend
already performs (Stripe.js / Elements
confirmSetup) is exactly what makes Stripefire
setup_intent.succeeded— so the event we're newly listening to already firestoday; we're only adding a server-side listener.
Notes:
setBillingPaymentMethodtoday, which already wakes thecontroller (
mutations.rs:112). The webhook adds a second, idempotent wake — asafety net, not a capability the frontend must opt into. No change (and no removal)
is needed there.
closes the tab after entering card details, or an ACH (
us_bank_account)SetupIntent that only succeeds asynchronously after bank verification.
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 thewebhook-eventsfeature, trait-signature change, and App wiring compile across all call sites.
cargo test -p control-plane-api stripe_webhook— runs the signature/tenant/wakeunit + integration tests above.
STRIPE_WEBHOOK_SECRETset toDEV_WEBHOOK_SECRET, thenstripe listen --forward-to <base>/api/v1/stripe/webhook.Note a raw
stripe trigger setup_intent.succeededproduces an event with notenant metadata (ack-and-ignore); a true end-to-end check drives
createBillingSetupIntentand completes the intent so our stamped metadata ispresent.