Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Adding support for an stripe web hook endpoint. - #3174

#3174
Merged
bbartman merged 14 commits into
masterestuary/flow:masterfrom
bmb/3167-stripe-webhooksestuary/flow:bmb/3167-stripe-webhooksCopy head branch name to clipboard
Jul 16, 2026
Merged

Adding support for an stripe web hook endpoint.#3174
bbartman merged 14 commits into
masterestuary/flow:masterfrom
bmb/3167-stripe-webhooksestuary/flow:bmb/3167-stripe-webhooksCopy head branch name to clipboard

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
mdibaiee added a commit to estuary/homebrew-flowctl that referenced this pull request Jul 23, 2026
## What's Changed
* docs: surface MCP and agent skills for coding agents by @jwhartley in estuary/flow#3093
* dekaf: serve the document containing a mid-document fetch offset by @jshearer in estuary/flow#3150
* runtime: let docker atomically assign published connector host ports by @dgreer-dev in estuary/flow#3162
* runtime-next: discard hinted frontier when committed frontier is rebuilt by @jgraettinger in estuary/flow#3165
* docs/source-postgres: Sync statement_timeout change by @willdonnelly in estuary/flow#3168
* Provide capture/materializations created_at date by @dgreer-dev in estuary/flow#3160
* docs: add Processing order section for materialization binding priority by @jwhartley in estuary/flow#3171
* docs: reorganize agent skills page by plugin, add derivations and schema by @jwhartley in estuary/flow#3170
* go.mod: bump gazette to latest by @williamhbaker in estuary/flow#3180
* Service Accounts by @GregorShear in estuary/flow#3058
* Bound too-large error messages over RPC by @dgreer-dev in estuary/flow#3182
* control-plane: guarded, auto-expiring temporary support access to restricted tenants by @skord in estuary/flow#3115
* private links: model links as rows with controller-observed status by @jshearer in estuary/flow#3063
* Adding support for an stripe web hook endpoint. by @bbartman in estuary/flow#3174
* docs: Dekaf consumer behaviors + Spark Structured Streaming guidance by @jwhartley in estuary/flow#3094
* shuffle: log canonical journal name in slice read events by @jgraettinger in estuary/flow#3190
* runtime: persist the max-key fail-safe sentinel at adoption by @jgraettinger in estuary/flow#3191
* local: make flow-plane-link robust to control-plane agent start-up by @jgraettinger in estuary/flow#3158
* gazette: surface fragment store diagnostic on FRAGMENT_STORE_UNHEALTHY by @jgraettinger in estuary/flow#3159
* docs: add warning about source-stripe-native's events API version by @nicolaslazo in estuary/flow#3166
* agent-api: Add `STRIPE_WEBHOOK_SECRET` to cloud run config by @jshearer in estuary/flow#3196
* shuffle, gazette: guard against wedged reads by @jgraettinger in estuary/flow#3193
* runtime-next: gate task-log stream by configured log level by @williamhbaker in estuary/flow#3198
* flowctl: preview-next appends a final drain session by @mdibaiee in estuary/flow#3195
* Allow localhost:3000 origin for the local control-plane agent by @GregorShear in estuary/flow#3206
* control-plane-api: don't lock injected ops collections during publications by @skord in estuary/flow#3164
* runtime-next: force txn close at any usage ceiling, bypassing min duration by @jgraettinger in estuary/flow#3203
* shuffle: replay gapped producers on restart by @jgraettinger in estuary/flow#3202
* docs: source-zuora by @Alex-Bair in estuary/flow#3204
* dekaf: only read back when a fetch offset lands inside a document by @mdibaiee in estuary/flow#3205
* mise: dev-VM zone configurability (create fallback, zone-agnostic SSH, vm:move-gcp) by @skord in estuary/flow#3208
* docs/postgres: Discovery Filters by @willdonnelly in estuary/flow#3215
* docs: document materialize-s3-iceberg nanosecond_timestamps option by @jacobmarble in estuary/flow#3199
* runtime v2 flag for new captures and derivations by @williamhbaker in estuary/flow#3219
* runtime-next: converge startup reconciliation via rescan Persists by @jgraettinger in estuary/flow#3218
* build(deps): bump fast-uri from 3.1.0 to 3.1.4 in /site by @dependabot[bot] in estuary/flow#3223
* build(deps): bump svgo from 3.3.3 to 3.3.4 in /site by @dependabot[bot] in estuary/flow#3224
* build(deps): bump dompurify from 3.3.3 to 3.4.12 in /site by @dependabot[bot] in estuary/flow#3222
* build(deps): bump shell-quote from 1.8.3 to 1.10.0 in /site by @dependabot[bot] in estuary/flow#3213
* build(deps): bump body-parser from 1.20.4 to 1.20.6 in /site by @dependabot[bot] in estuary/flow#3212
* build(deps): bump webpack-dev-server from 5.2.2 to 5.2.6 in /site by @dependabot[bot] in estuary/flow#3211
* build(deps): bump websocket-driver from 0.7.4 to 0.7.5 in /site by @dependabot[bot] in estuary/flow#3183
* Docs: MySQL discovery filters by @aeluce in estuary/flow#3217
* docs: source-smartsheet by @nicolaslazo in estuary/flow#3228
* control-plane-api: surface per-store health diagnostics in storage-mapping mutations by @GregorShear in estuary/flow#3181
* async-process: handle reaping-task JoinError in Child::wait() without panicking by @jgraettinger in estuary/flow#3225
* feat: end-to-end truncation by @williamhbaker in estuary/flow#3132
* docs: source-zuora uses the AQuA API now by @Alex-Bair in estuary/flow#3237
* Improve storage mapping GraphQL results by @GregorShear in estuary/flow#3200
* control-plane: add `closed` flag to data planes by @GregorShear in estuary/flow#3175

**Full Changelog**: estuary/flow@v0.6.11...v0.6.12

Co-authored-by: mdibaiee <mdibaiee@users.noreply.github.com>
mdibaiee added a commit to estuary/homebrew-flowctl that referenced this pull request Jul 23, 2026
## What's Changed
* docs: surface MCP and agent skills for coding agents by @jwhartley in estuary/flow#3093
* dekaf: serve the document containing a mid-document fetch offset by @jshearer in estuary/flow#3150
* runtime: let docker atomically assign published connector host ports by @dgreer-dev in estuary/flow#3162
* runtime-next: discard hinted frontier when committed frontier is rebuilt by @jgraettinger in estuary/flow#3165
* docs/source-postgres: Sync statement_timeout change by @willdonnelly in estuary/flow#3168
* Provide capture/materializations created_at date by @dgreer-dev in estuary/flow#3160
* docs: add Processing order section for materialization binding priority by @jwhartley in estuary/flow#3171
* docs: reorganize agent skills page by plugin, add derivations and schema by @jwhartley in estuary/flow#3170
* go.mod: bump gazette to latest by @williamhbaker in estuary/flow#3180
* Service Accounts by @GregorShear in estuary/flow#3058
* Bound too-large error messages over RPC by @dgreer-dev in estuary/flow#3182
* control-plane: guarded, auto-expiring temporary support access to restricted tenants by @skord in estuary/flow#3115
* private links: model links as rows with controller-observed status by @jshearer in estuary/flow#3063
* Adding support for an stripe web hook endpoint. by @bbartman in estuary/flow#3174
* docs: Dekaf consumer behaviors + Spark Structured Streaming guidance by @jwhartley in estuary/flow#3094
* shuffle: log canonical journal name in slice read events by @jgraettinger in estuary/flow#3190
* runtime: persist the max-key fail-safe sentinel at adoption by @jgraettinger in estuary/flow#3191
* local: make flow-plane-link robust to control-plane agent start-up by @jgraettinger in estuary/flow#3158
* gazette: surface fragment store diagnostic on FRAGMENT_STORE_UNHEALTHY by @jgraettinger in estuary/flow#3159
* docs: add warning about source-stripe-native's events API version by @nicolaslazo in estuary/flow#3166
* agent-api: Add `STRIPE_WEBHOOK_SECRET` to cloud run config by @jshearer in estuary/flow#3196
* shuffle, gazette: guard against wedged reads by @jgraettinger in estuary/flow#3193
* runtime-next: gate task-log stream by configured log level by @williamhbaker in estuary/flow#3198
* flowctl: preview-next appends a final drain session by @mdibaiee in estuary/flow#3195
* Allow localhost:3000 origin for the local control-plane agent by @GregorShear in estuary/flow#3206
* control-plane-api: don't lock injected ops collections during publications by @skord in estuary/flow#3164
* runtime-next: force txn close at any usage ceiling, bypassing min duration by @jgraettinger in estuary/flow#3203
* shuffle: replay gapped producers on restart by @jgraettinger in estuary/flow#3202
* docs: source-zuora by @Alex-Bair in estuary/flow#3204
* dekaf: only read back when a fetch offset lands inside a document by @mdibaiee in estuary/flow#3205
* mise: dev-VM zone configurability (create fallback, zone-agnostic SSH, vm:move-gcp) by @skord in estuary/flow#3208
* docs/postgres: Discovery Filters by @willdonnelly in estuary/flow#3215
* docs: document materialize-s3-iceberg nanosecond_timestamps option by @jacobmarble in estuary/flow#3199
* runtime v2 flag for new captures and derivations by @williamhbaker in estuary/flow#3219
* runtime-next: converge startup reconciliation via rescan Persists by @jgraettinger in estuary/flow#3218
* build(deps): bump fast-uri from 3.1.0 to 3.1.4 in /site by @dependabot[bot] in estuary/flow#3223
* build(deps): bump svgo from 3.3.3 to 3.3.4 in /site by @dependabot[bot] in estuary/flow#3224
* build(deps): bump dompurify from 3.3.3 to 3.4.12 in /site by @dependabot[bot] in estuary/flow#3222
* build(deps): bump shell-quote from 1.8.3 to 1.10.0 in /site by @dependabot[bot] in estuary/flow#3213
* build(deps): bump body-parser from 1.20.4 to 1.20.6 in /site by @dependabot[bot] in estuary/flow#3212
* build(deps): bump webpack-dev-server from 5.2.2 to 5.2.6 in /site by @dependabot[bot] in estuary/flow#3211
* build(deps): bump websocket-driver from 0.7.4 to 0.7.5 in /site by @dependabot[bot] in estuary/flow#3183
* Docs: MySQL discovery filters by @aeluce in estuary/flow#3217
* docs: source-smartsheet by @nicolaslazo in estuary/flow#3228
* control-plane-api: surface per-store health diagnostics in storage-mapping mutations by @GregorShear in estuary/flow#3181
* async-process: handle reaping-task JoinError in Child::wait() without panicking by @jgraettinger in estuary/flow#3225
* feat: end-to-end truncation by @williamhbaker in estuary/flow#3132
* docs: source-zuora uses the AQuA API now by @Alex-Bair in estuary/flow#3237
* Improve storage mapping GraphQL results by @GregorShear in estuary/flow#3200
* control-plane: add `closed` flag to data planes by @GregorShear in estuary/flow#3175

**Full Changelog**: estuary/flow@v0.6.11...v0.6.12

Co-authored-by: mdibaiee <mdibaiee@users.noreply.github.com>
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

Morty Proxy This is a proxified and sanitized view of the page, visit original site.