1---
2title: Onboard merchants with Connect
3description: 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
6Stripe Connect is for platforms: a marketplace, a SaaS that invoices
7on behalf of its customers, anything where *other businesses* get paid
8through your app. Each of those businesses becomes a **connected
9account** with its own Stripe identity. Your platform creates it,
10sends the owner through Stripe's onboarding form, and — once Stripe
11has verified them — can route payments to it.
12
13That is a state machine, not a form submission. The parts every
14platform needs:
15
161. **Create the account** when a merchant signs up. Store its `acct_…`
17 id next to your own user record.
182. **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.
213. **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.
254. **Gate on it.** Your app checks your stored `charges_enabled` before
26 it lets the merchant sell.
27
28This guide walks through `examples/stripe-connect`, which implements
29all 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);
31the 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
35account. 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
42One row per connected account, keyed by Stripe's id. The three
43capability flags mirror what Stripe reports; `updated_at` moves when a
44webhook lands.
45
46```sql
47-- migrations/0001_init.sql
48CREATE 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
61import * as Cloudflare from "alchemy/Cloudflare";
62
63export 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
72export 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
82Two Stripe bindings: one to create accounts, one to mint onboarding
83links. Both add `accounts_write` to the Worker's token.
84
85## Listen for `account.updated`
86
87This is the piece most first attempts skip. Stripe fires
88`account.updated` on *every* change to *any* connected account — most
89importantly when the merchant finishes onboarding and
90`charges_enabled` flips to `true`. `consumeEvents` provisions the
91endpoint; 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
119Account Links need two URLs. `return_url` is where the merchant lands
120when they finish. `refresh_url` is where Stripe sends them if the link
121has expired — your job there is to mint a fresh one. Factor it out
122because 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
138Create the Express account requesting the capabilities the platform
139needs, 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
179puts Stripe in charge of the onboarding UI. `standard` and `custom`
180trade that off differently — see
181[Stripe's account types](https://docs.stripe.com/connect/accounts).
182
183### `POST /merchants/:id/onboarding` — resume
184
185A merchant who abandoned onboarding comes back. Check they exist, mint
186a 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
214Your `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
247the 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
266cd examples/stripe-connect
267bun alchemy deploy
268```
269
270```sh
271curl -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
276Open `onboardingUrl` and complete the test-mode form (Stripe pre-fills
277most of it). Then:
278
279```sh
280curl "$URL/merchants/acct_…"
281# → { "onboarded": true, "chargesEnabled": true, … }
282```
283
284Or skip the form: `POST /merchants/acct_…/onboarding` proves link
285minting, and any change to the account — even a metadata edit in the
286Dashboard — fires `account.updated` and bumps `updatedAt`. That's
287what `test/integ.test.ts` does.
288
289## What's next on a real platform
290
291This example stops at "the merchant can take payments". From here a
292platform 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
299Those are ordinary `Create*` bindings and `consumeEvents` classes in the
300same shape as above.
301
302## Full file
303
304<details>
305<summary><code>examples/stripe-connect/src/Api.ts</code></summary>
306
307```typescript
308import * as Cloudflare from "alchemy/Cloudflare";
309import * as Stripe from "alchemy/Stripe";
310import * as Effect from "effect/Effect";
311import { HttpServerRequest } from "effect/http/HttpServerRequest";
312import * as HttpServerResponse from "effect/http/HttpServerResponse";
313import { Database } from "./database.ts";
314
315interface 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
325export 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>