| 1 | --- |
| 2 | title: React to Stripe events |
| 3 | description: consumeEvents provisions the webhook endpoint and runs one Effect per delivery. When to use it, which events to listen for, and how to manage the endpoint yourself. |
| 4 | --- |
| 5 | |
| 6 | Stripe tells you what happened asynchronously. A payment succeeded, a |
| 7 | renewal was declined, a merchant finished onboarding — none of these |
| 8 | come back on the request that started them. Anything your app needs to |
| 9 | *know* about billing state, it learns from webhooks. |
| 10 | |
| 11 | There are two levels: |
| 12 | |
| 13 | 1. **`WebhookEndpoint`** — the Stack resource. Owns the Stripe |
| 14 | endpoint: URL, enabled event types, signing secret. |
| 15 | 2. **`consumeEvents`** — the event source. One handler, one typed |
| 16 | `Effect` per delivery. |
| 17 | |
| 18 | For the common case you only write level 2. `consumeEvents` creates |
| 19 | the `WebhookEndpoint` pointed at the Worker it runs on, enables exactly |
| 20 | the events you listed, and binds the minted signing secret so every |
| 21 | delivery is verified before your handler sees it. |
| 22 | |
| 23 | ## On a Worker |
| 24 | |
| 25 | ```typescript |
| 26 | // src/Api.ts |
| 27 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 28 | import * as Stripe from "alchemy/Stripe"; |
| 29 | import * as Effect from "effect/Effect"; |
| 30 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 31 | |
| 32 | export default class Api extends Cloudflare.Worker<Api>()( |
| 33 | "Api", |
| 34 | { main: import.meta.url }, |
| 35 | Effect.gen(function* () { |
| 36 | yield* Stripe.consumeEvents( |
| 37 | "Events", |
| 38 | { |
| 39 | events: [ |
| 40 | Stripe.CustomerSubscriptionCreated, |
| 41 | Stripe.CustomerSubscriptionDeleted, |
| 42 | ], |
| 43 | }, |
| 44 | Effect.fn(function* (event) { |
| 45 | // event: CustomerSubscriptionCreated | CustomerSubscriptionDeleted |
| 46 | switch (event.type) { |
| 47 | case "customer.subscription.created": |
| 48 | yield* Effect.log(`new subscription ${event.object.id}`); |
| 49 | return; |
| 50 | case "customer.subscription.deleted": |
| 51 | yield* Effect.log(`canceled ${event.object.id}`); |
| 52 | return; |
| 53 | } |
| 54 | }), |
| 55 | ); |
| 56 | |
| 57 | return { |
| 58 | fetch: Effect.succeed(HttpServerResponse.text("ok")), |
| 59 | }; |
| 60 | }).pipe(Effect.provide(Stripe.ConsumeEventsLive)), |
| 61 | ) {} |
| 62 | ``` |
| 63 | |
| 64 | Pass event **classes**, not `"customer.subscription.created"` strings. |
| 65 | The class is both the subscription key and the type of `event` inside |
| 66 | the handler, so `event.object` is a `Subscription` in the first case |
| 67 | and the switch is exhaustive. |
| 68 | |
| 69 | Deliveries arrive at `/webhooks/stripe` on the Worker (override with |
| 70 | `path`). The listener verifies `Stripe-Signature`; a bad signature is a |
| 71 | `401` and never reaches your handler. A defect in your handler is a |
| 72 | `500`, which makes Stripe retry. |
| 73 | |
| 74 | :::note[Handlers must be idempotent] |
| 75 | Stripe retries on any non-2xx and may deliver an event more than once. |
| 76 | Write your handler so running it twice for the same event is harmless — |
| 77 | upserting a row keyed by customer or account id, as both examples do, |
| 78 | is the usual shape. |
| 79 | ::: |
| 80 | |
| 81 | ## Which events |
| 82 | |
| 83 | The classes exported from `alchemy/Stripe`: |
| 84 | |
| 85 | | Class | Event | You'll want it when | |
| 86 | |---|---|---| |
| 87 | | `CheckoutSessionCompleted` | `checkout.session.completed` | A buyer paid on hosted Checkout | |
| 88 | | `CustomerSubscriptionCreated` | `customer.subscription.created` | A subscription started (any path) | |
| 89 | | `CustomerSubscriptionUpdated` | `customer.subscription.updated` | Renewal, plan change, `past_due` recovery | |
| 90 | | `CustomerSubscriptionDeleted` | `customer.subscription.deleted` | Cancellation completed | |
| 91 | | `InvoicePaid` | `invoice.paid` | A renewal charge succeeded | |
| 92 | | `InvoicePaymentFailed` | `invoice.payment_failed` | A renewal charge was declined | |
| 93 | | `PaymentIntentSucceeded` / `PaymentIntentFailed` | `payment_intent.*` | One-off payments | |
| 94 | | `CustomerCreated` / `Updated` / `Deleted` | `customer.*` | Mirror customer records | |
| 95 | | `AccountUpdated` | `account.updated` | A Connect merchant finished onboarding | |
| 96 | |
| 97 | [Sell a subscription](/stripe/guides/subscriptions) shows the first six |
| 98 | driving an entitlement record. [Connect](/stripe/guides/connect) shows |
| 99 | `AccountUpdated` driving a merchant table. |
| 100 | |
| 101 | ## Managing the endpoint yourself |
| 102 | |
| 103 | Declare a `WebhookEndpoint` directly when the delivery URL is not a |
| 104 | Worker alchemy deploys — an existing service, a different cloud: |
| 105 | |
| 106 | ```typescript |
| 107 | // alchemy.run.ts |
| 108 | const webhook = yield* Stripe.WebhookEndpoint("Events", { |
| 109 | url: "https://api.example.com/stripe", |
| 110 | enabledEvents: ["customer.subscription.created"], |
| 111 | }); |
| 112 | ``` |
| 113 | |
| 114 | `webhook.secret` is the signing secret; get it to that service however |
| 115 | you get secrets there. |
| 116 | |
| 117 | If the target *is* an alchemy Worker but you want the endpoint declared |
| 118 | in the Stack rather than inside the Worker, bind the secret explicitly: |
| 119 | |
| 120 | ```typescript |
| 121 | const api = yield* Api; |
| 122 | const webhook = yield* Stripe.WebhookEndpoint("Events", { |
| 123 | url: Output.interpolate`${api.url}/webhooks/stripe`, |
| 124 | enabledEvents: [Stripe.CustomerSubscriptionCreated], |
| 125 | }); |
| 126 | yield* Stripe.bindWebhookSecret(api, webhook.secret); |
| 127 | ``` |
| 128 | |
| 129 | ## Reference |
| 130 | |
| 131 | [WebhookEndpoint](/providers/stripe/webhookendpoint) · |
| 132 | [consumeEvents](/providers/stripe/consumeeventslive) |