| 1 | --- |
| 2 | title: Onboard merchants with Connect |
| 3 | description: Build a Connect platform on a Cloudflare Worker — create Express accounts, send merchants through hosted onboarding, and learn from account.updated when they can take payments. |
| 4 | --- |
| 5 | |
| 6 | Stripe Connect is for platforms: a marketplace, a SaaS that invoices |
| 7 | on behalf of its customers, anything where *other businesses* get paid |
| 8 | through your app. Each of those businesses becomes a **connected |
| 9 | account** with its own Stripe identity. Your platform creates it, |
| 10 | sends the owner through Stripe's onboarding form, and — once Stripe |
| 11 | has verified them — can route payments to it. |
| 12 | |
| 13 | That is a state machine, not a form submission. The parts every |
| 14 | platform needs: |
| 15 | |
| 16 | 1. **Create the account** when a merchant signs up. Store its `acct_…` |
| 17 | id next to your own user record. |
| 18 | 2. **Send them to hosted onboarding** via an Account Link. Links are |
| 19 | single-use and expire in minutes; merchants abandon them and come |
| 20 | back tomorrow. You must be able to mint a new one on demand. |
| 21 | 3. **Learn when they're done.** Stripe sets `charges_enabled` on the |
| 22 | account after verification and fires `account.updated`. That |
| 23 | webhook — not the merchant returning to your `return_url` — is the |
| 24 | source of truth. |
| 25 | 4. **Gate on it.** Your app checks your stored `charges_enabled` before |
| 26 | it lets the merchant sell. |
| 27 | |
| 28 | This guide walks through `examples/stripe-connect`, which implements |
| 29 | all four on one Worker with a D1 table. Every snippet is a piece of |
| 30 | [`src/Api.ts`](https://github.com/alchemy-run/alchemy/blob/main/examples/stripe-connect/src/Api.ts); |
| 31 | the full file is at the end. |
| 32 | |
| 33 | :::caution[Enable Connect first] |
| 34 | `POST /v1/accounts` fails until Connect is switched on for your Stripe |
| 35 | account. Do that once at |
| 36 | [dashboard.stripe.com/connect](https://dashboard.stripe.com/test/connect) |
| 37 | — there is no API for it. |
| 38 | ::: |
| 39 | |
| 40 | ## The merchant table |
| 41 | |
| 42 | One row per connected account, keyed by Stripe's id. The three |
| 43 | capability flags mirror what Stripe reports; `updated_at` moves when a |
| 44 | webhook lands. |
| 45 | |
| 46 | ```sql |
| 47 | -- migrations/0001_init.sql |
| 48 | CREATE TABLE merchants ( |
| 49 | id text PRIMARY KEY, |
| 50 | email text NOT NULL, |
| 51 | details_submitted integer NOT NULL DEFAULT 0, |
| 52 | charges_enabled integer NOT NULL DEFAULT 0, |
| 53 | payouts_enabled integer NOT NULL DEFAULT 0, |
| 54 | created_at integer NOT NULL DEFAULT (unixepoch()), |
| 55 | updated_at integer NOT NULL DEFAULT (unixepoch()) |
| 56 | ); |
| 57 | ``` |
| 58 | |
| 59 | ```typescript |
| 60 | // src/database.ts |
| 61 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 62 | |
| 63 | export const Database = Cloudflare.D1.Database("Database", { |
| 64 | migrations: "./migrations", |
| 65 | }); |
| 66 | ``` |
| 67 | |
| 68 | ## Bind the database and the Stripe calls |
| 69 | |
| 70 | ```typescript |
| 71 | // src/Api.ts |
| 72 | export default class Api extends Cloudflare.Worker<Api>()( |
| 73 | "Api", |
| 74 | { main: import.meta.url }, |
| 75 | Effect.gen(function* () { |
| 76 | const db = yield* Cloudflare.D1.QueryDatabase(Database); |
| 77 | const createAccount = yield* Stripe.CreateAccount(); |
| 78 | const createAccountLink = yield* Stripe.CreateAccountLink(); |
| 79 | // ... |
| 80 | ``` |
| 81 | |
| 82 | Two Stripe bindings: one to create accounts, one to mint onboarding |
| 83 | links. Both add `accounts_write` to the Worker's token. |
| 84 | |
| 85 | ## Listen for `account.updated` |
| 86 | |
| 87 | This is the piece most first attempts skip. Stripe fires |
| 88 | `account.updated` on *every* change to *any* connected account — most |
| 89 | importantly when the merchant finishes onboarding and |
| 90 | `charges_enabled` flips to `true`. `consumeEvents` provisions the |
| 91 | endpoint; the handler copies the flags into D1. |
| 92 | |
| 93 | ```typescript |
| 94 | yield* Stripe.consumeEvents( |
| 95 | "Events", |
| 96 | { events: [Stripe.AccountUpdated] }, |
| 97 | Effect.fn(function* (event) { |
| 98 | const account = event.object; |
| 99 | yield* db |
| 100 | .prepare( |
| 101 | `UPDATE merchants |
| 102 | SET details_submitted = ?, charges_enabled = ?, payouts_enabled = ?, updated_at = unixepoch() |
| 103 | WHERE id = ?`, |
| 104 | ) |
| 105 | .bind( |
| 106 | account.details_submitted ? 1 : 0, |
| 107 | account.charges_enabled ? 1 : 0, |
| 108 | account.payouts_enabled ? 1 : 0, |
| 109 | account.id, |
| 110 | ) |
| 111 | .run() |
| 112 | .pipe(Effect.orDie); |
| 113 | }), |
| 114 | ); |
| 115 | ``` |
| 116 | |
| 117 | ## Minting an onboarding link |
| 118 | |
| 119 | Account Links need two URLs. `return_url` is where the merchant lands |
| 120 | when they finish. `refresh_url` is where Stripe sends them if the link |
| 121 | has expired — your job there is to mint a fresh one. Factor it out |
| 122 | because two routes need it: |
| 123 | |
| 124 | ```typescript |
| 125 | const onboardingLink = (merchantId: string, origin: string) => |
| 126 | createAccountLink({ |
| 127 | account: merchantId, |
| 128 | type: "account_onboarding", |
| 129 | return_url: `${origin}/merchants/${merchantId}/onboarded`, |
| 130 | refresh_url: `${origin}/merchants/${merchantId}/onboarding/refresh`, |
| 131 | }); |
| 132 | ``` |
| 133 | |
| 134 | ## The routes |
| 135 | |
| 136 | ### `POST /merchants` — sign a merchant up |
| 137 | |
| 138 | Create the Express account requesting the capabilities the platform |
| 139 | needs, store the id, and return the first onboarding link: |
| 140 | |
| 141 | ```typescript |
| 142 | if (request.method === "POST" && url.pathname === "/merchants") { |
| 143 | const body = (yield* request.json) as { email?: string }; |
| 144 | if (!body.email) { |
| 145 | return yield* HttpServerResponse.json( |
| 146 | { error: "email is required" }, |
| 147 | { status: 400 }, |
| 148 | ); |
| 149 | } |
| 150 | |
| 151 | const account = yield* createAccount({ |
| 152 | type: "express", |
| 153 | country: "US", |
| 154 | email: body.email, |
| 155 | capabilities: { |
| 156 | card_payments: { requested: true }, |
| 157 | transfers: { requested: true }, |
| 158 | }, |
| 159 | }).pipe(Effect.orDie); |
| 160 | |
| 161 | yield* db |
| 162 | .prepare("INSERT INTO merchants (id, email) VALUES (?, ?)") |
| 163 | .bind(account.id, body.email) |
| 164 | .run() |
| 165 | .pipe(Effect.orDie); |
| 166 | |
| 167 | const link = yield* onboardingLink(account.id, url.origin).pipe( |
| 168 | Effect.orDie, |
| 169 | ); |
| 170 | |
| 171 | return yield* HttpServerResponse.json( |
| 172 | { merchantId: account.id, onboardingUrl: link.url }, |
| 173 | { status: 201 }, |
| 174 | ); |
| 175 | } |
| 176 | ``` |
| 177 | |
| 178 | `type: "express"` gives merchants a lightweight Stripe dashboard and |
| 179 | puts Stripe in charge of the onboarding UI. `standard` and `custom` |
| 180 | trade that off differently — see |
| 181 | [Stripe's account types](https://docs.stripe.com/connect/accounts). |
| 182 | |
| 183 | ### `POST /merchants/:id/onboarding` — resume |
| 184 | |
| 185 | A merchant who abandoned onboarding comes back. Check they exist, mint |
| 186 | a new link: |
| 187 | |
| 188 | ```typescript |
| 189 | if ( |
| 190 | request.method === "POST" && |
| 191 | segments.length === 3 && |
| 192 | segments[0] === "merchants" && |
| 193 | segments[2] === "onboarding" |
| 194 | ) { |
| 195 | const merchantId = segments[1]; |
| 196 | const row = yield* db |
| 197 | .prepare("SELECT id FROM merchants WHERE id = ?") |
| 198 | .bind(merchantId) |
| 199 | .first<{ id: string }>() |
| 200 | .pipe(Effect.orDie); |
| 201 | if (row === null) { |
| 202 | return yield* HttpServerResponse.json( |
| 203 | { error: "Unknown merchant" }, |
| 204 | { status: 404 }, |
| 205 | ); |
| 206 | } |
| 207 | const link = yield* onboardingLink(merchantId, url.origin).pipe( |
| 208 | Effect.orDie, |
| 209 | ); |
| 210 | return yield* HttpServerResponse.json({ onboardingUrl: link.url }); |
| 211 | } |
| 212 | ``` |
| 213 | |
| 214 | Your `refresh_url` page should call this and redirect. |
| 215 | |
| 216 | ### `GET /merchants/:id` — are they live? |
| 217 | |
| 218 | ```typescript |
| 219 | if ( |
| 220 | request.method === "GET" && |
| 221 | segments.length === 2 && |
| 222 | segments[0] === "merchants" |
| 223 | ) { |
| 224 | const merchant = yield* db |
| 225 | .prepare("SELECT * FROM merchants WHERE id = ?") |
| 226 | .bind(segments[1]) |
| 227 | .first<Merchant>() |
| 228 | .pipe(Effect.orDie); |
| 229 | if (merchant === null) { |
| 230 | return yield* HttpServerResponse.json( |
| 231 | { error: "Unknown merchant" }, |
| 232 | { status: 404 }, |
| 233 | ); |
| 234 | } |
| 235 | return yield* HttpServerResponse.json({ |
| 236 | id: merchant.id, |
| 237 | email: merchant.email, |
| 238 | onboarded: merchant.details_submitted === 1, |
| 239 | chargesEnabled: merchant.charges_enabled === 1, |
| 240 | payoutsEnabled: merchant.payouts_enabled === 1, |
| 241 | updatedAt: merchant.updated_at, |
| 242 | }); |
| 243 | } |
| 244 | ``` |
| 245 | |
| 246 | `chargesEnabled` is the gate. Until it's `true`, keep the merchant on |
| 247 | the onboarding path. |
| 248 | |
| 249 | ## Provide the layers |
| 250 | |
| 251 | ```typescript |
| 252 | }).pipe( |
| 253 | Effect.provide([ |
| 254 | Cloudflare.D1.QueryDatabaseBinding, |
| 255 | Stripe.CreateAccountHttp, |
| 256 | Stripe.CreateAccountLinkHttp, |
| 257 | Stripe.ConsumeEventsLive, |
| 258 | ]), |
| 259 | ), |
| 260 | ) {} |
| 261 | ``` |
| 262 | |
| 263 | ## Try it |
| 264 | |
| 265 | ```sh |
| 266 | cd examples/stripe-connect |
| 267 | bun alchemy deploy |
| 268 | ``` |
| 269 | |
| 270 | ```sh |
| 271 | curl -X POST "$URL/merchants" -H 'content-type: application/json' \ |
| 272 | -d '{"email":"merchant@example.com"}' |
| 273 | # → { "merchantId": "acct_…", "onboardingUrl": "https://connect.stripe.com/…" } |
| 274 | ``` |
| 275 | |
| 276 | Open `onboardingUrl` and complete the test-mode form (Stripe pre-fills |
| 277 | most of it). Then: |
| 278 | |
| 279 | ```sh |
| 280 | curl "$URL/merchants/acct_…" |
| 281 | # → { "onboarded": true, "chargesEnabled": true, … } |
| 282 | ``` |
| 283 | |
| 284 | Or skip the form: `POST /merchants/acct_…/onboarding` proves link |
| 285 | minting, and any change to the account — even a metadata edit in the |
| 286 | Dashboard — fires `account.updated` and bumps `updatedAt`. That's |
| 287 | what `test/integ.test.ts` does. |
| 288 | |
| 289 | ## What's next on a real platform |
| 290 | |
| 291 | This example stops at "the merchant can take payments". From here a |
| 292 | platform typically: |
| 293 | |
| 294 | - creates Checkout Sessions with `payment_intent_data.transfer_data.destination` |
| 295 | set to the merchant's `acct_…` so funds route to them, or |
| 296 | - creates Transfers from its own balance after the fact, and |
| 297 | - listens for `payout.*` events on connected accounts. |
| 298 | |
| 299 | Those are ordinary `Create*` bindings and `consumeEvents` classes in the |
| 300 | same shape as above. |
| 301 | |
| 302 | ## Full file |
| 303 | |
| 304 | <details> |
| 305 | <summary><code>examples/stripe-connect/src/Api.ts</code></summary> |
| 306 | |
| 307 | ```typescript |
| 308 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 309 | import * as Stripe from "alchemy/Stripe"; |
| 310 | import * as Effect from "effect/Effect"; |
| 311 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 312 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 313 | import { Database } from "./database.ts"; |
| 314 | |
| 315 | interface Merchant { |
| 316 | id: string; |
| 317 | email: string; |
| 318 | details_submitted: number; |
| 319 | charges_enabled: number; |
| 320 | payouts_enabled: number; |
| 321 | created_at: number; |
| 322 | updated_at: number; |
| 323 | } |
| 324 | |
| 325 | export default class Api extends Cloudflare.Worker<Api>()( |
| 326 | "Api", |
| 327 | { |
| 328 | main: import.meta.url, |
| 329 | }, |
| 330 | Effect.gen(function* () { |
| 331 | const db = yield* Cloudflare.D1.QueryDatabase(Database); |
| 332 | const createAccount = yield* Stripe.CreateAccount(); |
| 333 | const createAccountLink = yield* Stripe.CreateAccountLink(); |
| 334 | |
| 335 | yield* Stripe.consumeEvents( |
| 336 | "Events", |
| 337 | { events: [Stripe.AccountUpdated] }, |
| 338 | Effect.fn(function* (event) { |
| 339 | const account = event.object; |
| 340 | yield* db |
| 341 | .prepare( |
| 342 | `UPDATE merchants |
| 343 | SET details_submitted = ?, charges_enabled = ?, payouts_enabled = ?, updated_at = unixepoch() |
| 344 | WHERE id = ?`, |
| 345 | ) |
| 346 | .bind( |
| 347 | account.details_submitted ? 1 : 0, |
| 348 | account.charges_enabled ? 1 : 0, |
| 349 | account.payouts_enabled ? 1 : 0, |
| 350 | account.id, |
| 351 | ) |
| 352 | .run() |
| 353 | .pipe(Effect.orDie); |
| 354 | }), |
| 355 | ); |
| 356 | |
| 357 | const onboardingLink = (merchantId: string, origin: string) => |
| 358 | createAccountLink({ |
| 359 | account: merchantId, |
| 360 | type: "account_onboarding", |
| 361 | return_url: `${origin}/merchants/${merchantId}/onboarded`, |
| 362 | refresh_url: `${origin}/merchants/${merchantId}/onboarding/refresh`, |
| 363 | }); |
| 364 | |
| 365 | return { |
| 366 | fetch: Effect.gen(function* () { |
| 367 | const request = yield* HttpServerRequest; |
| 368 | const url = new URL(request.originalUrl); |
| 369 | const segments = url.pathname.split("/").filter(Boolean); |
| 370 | |
| 371 | if (request.method === "GET" && url.pathname === "/") { |
| 372 | return HttpServerResponse.text("ok"); |
| 373 | } |
| 374 | |
| 375 | if (request.method === "POST" && url.pathname === "/merchants") { |
| 376 | const body = (yield* request.json) as { email?: string }; |
| 377 | if (!body.email) { |
| 378 | return yield* HttpServerResponse.json( |
| 379 | { error: "email is required" }, |
| 380 | { status: 400 }, |
| 381 | ); |
| 382 | } |
| 383 | |
| 384 | const account = yield* createAccount({ |
| 385 | type: "express", |
| 386 | country: "US", |
| 387 | email: body.email, |
| 388 | capabilities: { |
| 389 | card_payments: { requested: true }, |
| 390 | transfers: { requested: true }, |
| 391 | }, |
| 392 | }).pipe(Effect.orDie); |
| 393 | |
| 394 | yield* db |
| 395 | .prepare("INSERT INTO merchants (id, email) VALUES (?, ?)") |
| 396 | .bind(account.id, body.email) |
| 397 | .run() |
| 398 | .pipe(Effect.orDie); |
| 399 | |
| 400 | const link = yield* onboardingLink(account.id, url.origin).pipe( |
| 401 | Effect.orDie, |
| 402 | ); |
| 403 | |
| 404 | return yield* HttpServerResponse.json( |
| 405 | { merchantId: account.id, onboardingUrl: link.url }, |
| 406 | { status: 201 }, |
| 407 | ); |
| 408 | } |
| 409 | |
| 410 | if ( |
| 411 | request.method === "POST" && |
| 412 | segments.length === 3 && |
| 413 | segments[0] === "merchants" && |
| 414 | segments[2] === "onboarding" |
| 415 | ) { |
| 416 | const merchantId = segments[1]; |
| 417 | const row = yield* db |
| 418 | .prepare("SELECT id FROM merchants WHERE id = ?") |
| 419 | .bind(merchantId) |
| 420 | .first<{ id: string }>() |
| 421 | .pipe(Effect.orDie); |
| 422 | if (row === null) { |
| 423 | return yield* HttpServerResponse.json( |
| 424 | { error: "Unknown merchant" }, |
| 425 | { status: 404 }, |
| 426 | ); |
| 427 | } |
| 428 | const link = yield* onboardingLink(merchantId, url.origin).pipe( |
| 429 | Effect.orDie, |
| 430 | ); |
| 431 | return yield* HttpServerResponse.json({ onboardingUrl: link.url }); |
| 432 | } |
| 433 | |
| 434 | if ( |
| 435 | request.method === "GET" && |
| 436 | segments.length === 3 && |
| 437 | segments[0] === "merchants" && |
| 438 | (segments[2] === "onboarded" || segments[2] === "refresh") |
| 439 | ) { |
| 440 | return HttpServerResponse.text( |
| 441 | segments[2] === "onboarded" |
| 442 | ? "Onboarding complete. You can close this tab." |
| 443 | : "That onboarding link expired. Request a new one from the platform.", |
| 444 | ); |
| 445 | } |
| 446 | |
| 447 | if ( |
| 448 | request.method === "GET" && |
| 449 | segments.length === 2 && |
| 450 | segments[0] === "merchants" |
| 451 | ) { |
| 452 | const merchant = yield* db |
| 453 | .prepare("SELECT * FROM merchants WHERE id = ?") |
| 454 | .bind(segments[1]) |
| 455 | .first<Merchant>() |
| 456 | .pipe(Effect.orDie); |
| 457 | if (merchant === null) { |
| 458 | return yield* HttpServerResponse.json( |
| 459 | { error: "Unknown merchant" }, |
| 460 | { status: 404 }, |
| 461 | ); |
| 462 | } |
| 463 | return yield* HttpServerResponse.json({ |
| 464 | id: merchant.id, |
| 465 | email: merchant.email, |
| 466 | onboarded: merchant.details_submitted === 1, |
| 467 | chargesEnabled: merchant.charges_enabled === 1, |
| 468 | payoutsEnabled: merchant.payouts_enabled === 1, |
| 469 | updatedAt: merchant.updated_at, |
| 470 | }); |
| 471 | } |
| 472 | |
| 473 | return yield* HttpServerResponse.json( |
| 474 | { error: "Not found" }, |
| 475 | { status: 404 }, |
| 476 | ); |
| 477 | }), |
| 478 | }; |
| 479 | }).pipe( |
| 480 | Effect.provide([ |
| 481 | Cloudflare.D1.QueryDatabaseBinding, |
| 482 | Stripe.CreateAccountHttp, |
| 483 | Stripe.CreateAccountLinkHttp, |
| 484 | Stripe.ConsumeEventsLive, |
| 485 | ]), |
| 486 | ), |
| 487 | ) {} |
| 488 | ``` |
| 489 | |
| 490 | </details> |