@dodopayments/opencode-plugin 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: framework-adapters
3
- description: Guide for mounting official @dodopayments/* checkout, portal, and verified-webhook route handlers in supported web frameworks; use domain skills for payment and lifecycle logic.
3
+ description: Official @dodopayments/* framework adapters that mount Checkout, CustomerPortal, and Webhooks route handlers. Use when integrating Dodo Payments into Next.js, Express, Fastify, Hono, Astro, Remix, SvelteKit, Nuxt, TanStack, Bun, or Convex, such as @dodopayments/nextjs; use domain skills for payment and subscription logic.
4
4
  ---
5
5
 
6
6
  # Framework Adapters
@@ -21,10 +21,12 @@ Dodo publishes `@dodopayments/*` packages that wrap the core SDK with framework-
21
21
 
22
22
  Each adapter exposes three handler families:
23
23
 
24
- - **Checkout:** static (GET only), dynamic (POST with cart), or session (POST with pre-built session).
24
+ - **Checkout:** static (GET only), dynamic (POST, creates a payment or subscription via the deprecated `POST /payments` / `POST /subscriptions` endpoints - existing integrations only), or session (POST with a checkout session payload - use this for new integrations).
25
25
  - **CustomerPortal:** generates a time-bound portal session link.
26
26
  - **Webhooks:** verifies webhook signatures and dispatches typed events.
27
27
 
28
+ > **Security - CustomerPortal does not authenticate.** Every `CustomerPortal` handler opens the portal for whatever `?customer_id=` it receives. Mounted as-is, any visitor can open any customer's portal by guessing or enumerating IDs. Put the route behind your own authentication and resolve the customer ID **server-side from the signed-in session**; never accept it from the client. The portal examples below do this.
29
+
28
30
  **Export names are not uniform across adapters.** Most export `Checkout` / `CustomerPortal` / `Webhooks`, but two differ, and the return shapes differ as well. Check this table before writing imports:
29
31
 
30
32
  | Adapter | Checkout export | Portal export | Handler shape |
@@ -34,7 +36,7 @@ Each adapter exposes three handler families:
34
36
  | `fastify` | `Checkout` | `CustomerPortal` | returns `{ getHandler, postHandler }` |
35
37
  | `sveltekit` | `Checkout` | `CustomerPortal` | returns `{ GET, POST }` / `{ GET }` |
36
38
  | `nuxt` | `checkoutHandler` (auto-imported) | `customerPortalHandler` | no import statement |
37
- | `convex` | `DodoPayments` component | — | `createDodoWebhookHandler` |
39
+ | `convex` | `checkout` (from `dodo.api()`) | `customerPortal` (from `dodo.api()`, uses `identify`) | `createDodoWebhookHandler` |
38
40
 
39
41
  The adapters handle raw body preservation for webhook verification, environment variable mapping, and framework-specific request/response shapes. Checkout payload design belongs to the `checkout-integration` skill; webhook business logic belongs to `webhook-integration`; portal behavior belongs to `customer-management`.
40
42
 
@@ -71,7 +73,7 @@ DODO_PAYMENTS_RETURN_URL=https://yourdomain.com/checkout/success
71
73
  NUXT_PRIVATE_BEARER_TOKEN=dodo_test_...
72
74
  NUXT_PRIVATE_WEBHOOK_KEY=your-webhook-secret
73
75
  NUXT_PRIVATE_ENVIRONMENT=test_mode
74
- NUXT_PRIVATE_RETURNURL=https://yourdomain.com/checkout/success
76
+ NUXT_PRIVATE_RETURN_URL=https://yourdomain.com/checkout/success
75
77
  ```
76
78
 
77
79
  **Convex** uses dashboard environment variables (not local `.env`):
@@ -108,6 +110,7 @@ Defaulting to `test_mode` is deliberate: a missing or misspelled variable must n
108
110
  ```typescript
109
111
  // app/api/checkout/route.ts
110
112
  import { Checkout } from "@dodopayments/nextjs";
113
+ import { dodoEnvironment } from "@/lib/dodo-env";
111
114
 
112
115
  export const GET = Checkout({
113
116
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
@@ -128,12 +131,26 @@ export const POST = Checkout({
128
131
 
129
132
  ```typescript
130
133
  // app/api/customer-portal/route.ts
134
+ import { NextRequest } from "next/server";
131
135
  import { CustomerPortal } from "@dodopayments/nextjs";
136
+ import { dodoEnvironment } from "@/lib/dodo-env";
137
+ // your auth helper
138
+ import { getSessionUser } from "@/lib/auth";
132
139
 
133
- export const GET = CustomerPortal({
140
+ const portal = CustomerPortal({
134
141
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
135
142
  environment: dodoEnvironment,
136
143
  });
144
+
145
+ export async function GET(req: NextRequest) {
146
+ const user = await getSessionUser(req);
147
+ if (!user?.dodoCustomerId) return new Response("Unauthorized", { status: 401 });
148
+
149
+ // Overwrite any client-supplied ?customer_id= with the signed-in user's ID.
150
+ const url = new URL(req.url);
151
+ url.searchParams.set("customer_id", user.dodoCustomerId);
152
+ return portal(new NextRequest(url, req));
153
+ }
137
154
  ```
138
155
 
139
156
  ### Webhooks
@@ -164,6 +181,8 @@ import { checkoutHandler } from "@dodopayments/express";
164
181
  import { dodoEnvironment } from "./lib/dodo-env";
165
182
 
166
183
  const app = express();
184
+ // Required before the POST route: the session/dynamic handlers read req.body.
185
+ app.use(express.json());
167
186
 
168
187
  app.get("/api/checkout", checkoutHandler({
169
188
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
@@ -182,13 +201,24 @@ app.post("/api/checkout", checkoutHandler({
182
201
 
183
202
  ### Customer Portal
184
203
 
204
+ The adapter's `CustomerPortal` reads `customer_id` from `req.query`, which you cannot safely rewrite in Express 5. Behind your auth middleware, call the SDK directly with the session's customer ID instead:
205
+
185
206
  ```typescript
186
- import { CustomerPortal } from "@dodopayments/express";
207
+ import DodoPayments from "dodopayments";
208
+ import { dodoEnvironment } from "./lib/dodo-env";
187
209
 
188
- app.get("/api/customer-portal", CustomerPortal({
210
+ const client = new DodoPayments({
189
211
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
190
212
  environment: dodoEnvironment,
191
- }));
213
+ });
214
+
215
+ // requireAuth = your session middleware; it must set req.user.
216
+ app.get("/api/customer-portal", requireAuth, async (req, res) => {
217
+ const customerId = req.user?.dodoCustomerId; // from the session, never from req.query
218
+ if (!customerId) return res.status(401).send("Unauthorized");
219
+ const session = await client.customers.customerPortal.create(customerId);
220
+ res.redirect(session.link);
221
+ });
192
222
  ```
193
223
 
194
224
  ### Webhooks
@@ -196,7 +226,10 @@ app.get("/api/customer-portal", CustomerPortal({
196
226
  ```typescript
197
227
  import { Webhooks } from "@dodopayments/express";
198
228
 
199
- app.use(express.raw({ type: "application/json" }));
229
+ // The Express handler verifies the signature against the PARSED req.body, so it
230
+ // needs express.json() (skip this line if already registered). Do NOT use express.raw() here - the
231
+ // handler would see a Buffer and reject every request.
232
+ app.use(express.json());
200
233
 
201
234
  app.post("/api/webhook", Webhooks({
202
235
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
@@ -269,6 +302,7 @@ fastify.post("/api/webhook", Webhooks({
269
302
  ```typescript
270
303
  import { Hono } from "hono";
271
304
  import { Checkout } from "@dodopayments/hono";
305
+ import { dodoEnvironment } from "./lib/dodo-env";
272
306
 
273
307
  const app = new Hono();
274
308
 
@@ -289,13 +323,24 @@ app.post("/api/checkout", Checkout({
289
323
 
290
324
  ### Customer Portal
291
325
 
326
+ The adapter's `CustomerPortal` reads `c.req.query("customer_id")`. Behind your auth middleware, call the SDK directly with the session's customer ID instead:
327
+
292
328
  ```typescript
293
- import { CustomerPortal } from "@dodopayments/hono";
329
+ import DodoPayments from "dodopayments";
330
+ import { dodoEnvironment } from "./lib/dodo-env";
294
331
 
295
- app.get("/api/customer-portal", CustomerPortal({
332
+ const client = new DodoPayments({
296
333
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
297
334
  environment: dodoEnvironment,
298
- }));
335
+ });
336
+
337
+ // Assumes your auth middleware ran first and stored the user on the context.
338
+ app.get("/api/customer-portal", async (c) => {
339
+ const customerId = c.get("user")?.dodoCustomerId; // from the session, never from the query
340
+ if (!customerId) return c.text("Unauthorized", 401);
341
+ const session = await client.customers.customerPortal.create(customerId);
342
+ return c.redirect(session.link);
343
+ });
299
344
  ```
300
345
 
301
346
  ### Webhooks
@@ -373,6 +418,7 @@ export const POST = Webhooks({
373
418
  // app/routes/api.checkout.tsx
374
419
  import { Checkout } from "@dodopayments/remix";
375
420
  import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
421
+ import { dodoEnvironment } from "~/lib/dodo-env";
376
422
 
377
423
  const checkoutHandler = Checkout({
378
424
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
@@ -391,13 +437,24 @@ export const action = ({ request }: ActionFunctionArgs) => checkoutHandler(reque
391
437
  // app/routes/api.customer-portal.tsx
392
438
  import { CustomerPortal } from "@dodopayments/remix";
393
439
  import type { LoaderFunctionArgs } from "@remix-run/node";
440
+ import { dodoEnvironment } from "~/lib/dodo-env";
441
+ // your auth helper
442
+ import { getSessionUser } from "~/lib/auth.server";
394
443
 
395
444
  const portalHandler = CustomerPortal({
396
445
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
397
446
  environment: dodoEnvironment,
398
447
  });
399
448
 
400
- export const loader = ({ request }: LoaderFunctionArgs) => portalHandler(request);
449
+ export const loader = async ({ request }: LoaderFunctionArgs) => {
450
+ const user = await getSessionUser(request);
451
+ if (!user?.dodoCustomerId) return new Response("Unauthorized", { status: 401 });
452
+
453
+ // Overwrite any client-supplied ?customer_id= with the signed-in user's ID.
454
+ const url = new URL(request.url);
455
+ url.searchParams.set("customer_id", user.dodoCustomerId);
456
+ return portalHandler(new Request(url, request));
457
+ };
401
458
  ```
402
459
 
403
460
  ### Webhooks
@@ -428,15 +485,26 @@ export const action = ({ request }: ActionFunctionArgs) =>
428
485
  import { Checkout } from "@dodopayments/sveltekit";
429
486
  import { DODO_PAYMENTS_API_KEY, DODO_PAYMENTS_RETURN_URL, DODO_PAYMENTS_ENVIRONMENT } from "$env/static/private";
430
487
 
431
- const checkoutHandler = Checkout({
488
+ const dodoEnvironment = DODO_PAYMENTS_ENVIRONMENT === "live_mode" ? "live_mode" : "test_mode";
489
+
490
+ // Checkout() returns { GET, POST }, not a handler. Export GET from a "static"
491
+ // handler - the GET of a "session"/"dynamic" handler returns 400.
492
+ const staticCheckout = Checkout({
493
+ bearerToken: DODO_PAYMENTS_API_KEY,
494
+ returnUrl: DODO_PAYMENTS_RETURN_URL,
495
+ environment: dodoEnvironment,
496
+ type: "static",
497
+ });
498
+
499
+ const sessionCheckout = Checkout({
432
500
  bearerToken: DODO_PAYMENTS_API_KEY,
433
501
  returnUrl: DODO_PAYMENTS_RETURN_URL,
434
- environment: DODO_PAYMENTS_ENVIRONMENT,
502
+ environment: dodoEnvironment,
435
503
  type: "session",
436
504
  });
437
505
 
438
- export const GET = checkoutHandler;
439
- export const POST = checkoutHandler;
506
+ export const GET = staticCheckout.GET;
507
+ export const POST = sessionCheckout.POST;
440
508
  ```
441
509
 
442
510
  ### Webhooks
@@ -469,7 +537,7 @@ export default defineNuxtConfig({
469
537
  bearerToken: process.env.NUXT_PRIVATE_BEARER_TOKEN,
470
538
  webhookKey: process.env.NUXT_PRIVATE_WEBHOOK_KEY,
471
539
  environment: process.env.NUXT_PRIVATE_ENVIRONMENT,
472
- returnUrl: process.env.NUXT_PRIVATE_RETURNURL,
540
+ returnUrl: process.env.NUXT_PRIVATE_RETURN_URL,
473
541
  },
474
542
  },
475
543
  });
@@ -518,7 +586,8 @@ export default Webhooks({
518
586
  ```typescript
519
587
  // src/routes/api/checkout.ts
520
588
  import { Checkout } from "@dodopayments/tanstack";
521
- import { dodoEnvironment } from "./lib/dodo-env";
589
+ // src/lib/dodo-env.ts
590
+ import { dodoEnvironment } from "../../lib/dodo-env";
522
591
 
523
592
  export const GET = Checkout({
524
593
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
@@ -545,6 +614,9 @@ TanStack Start's server-route definition API has changed across releases (`creat
545
614
 
546
615
  ```typescript
547
616
  import { Checkout, CustomerPortal } from "@dodopayments/bun";
617
+ import { dodoEnvironment } from "./lib/dodo-env";
618
+ // your auth helper
619
+ import { getSessionUser } from "./lib/auth";
548
620
 
549
621
  const checkoutHandler = Checkout({
550
622
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
@@ -560,14 +632,18 @@ const portalHandler = CustomerPortal({
560
632
 
561
633
  Bun.serve({
562
634
  port: 3000,
563
- fetch(request) {
635
+ async fetch(request) {
564
636
  const url = new URL(request.url);
565
637
 
566
638
  if (url.pathname === "/api/checkout") {
567
639
  return checkoutHandler(request);
568
640
  }
569
641
  if (url.pathname === "/api/customer-portal" && request.method === "GET") {
570
- return portalHandler(request);
642
+ const user = await getSessionUser(request);
643
+ if (!user?.dodoCustomerId) return new Response("Unauthorized", { status: 401 });
644
+ // Overwrite any client-supplied ?customer_id= with the signed-in user's ID.
645
+ url.searchParams.set("customer_id", user.dodoCustomerId);
646
+ return portalHandler(new Request(url, request));
571
647
  }
572
648
 
573
649
  return new Response("Not Found", { status: 404 });
@@ -591,20 +667,74 @@ app.use(dodopayments);
591
667
  export default app;
592
668
  ```
593
669
 
594
- Then use the component in your actions and HTTP routes:
670
+ Run `npx convex dev` once to generate `components.dodopayments`. `identify` needs an app-owned lookup from the signed-in user to their Dodo customer ID. This assumes a `customers` table with `authId` and `dodoCustomerId` fields and a `by_auth_id` index; adapt it to your schema:
671
+
672
+ ```typescript
673
+ // convex/customers.ts
674
+ import { internalQuery } from "./_generated/server";
675
+ import { v } from "convex/values";
676
+
677
+ export const getByAuthId = internalQuery({
678
+ args: { authId: v.string() },
679
+ handler: async (ctx, { authId }) =>
680
+ ctx.db
681
+ .query("customers")
682
+ .withIndex("by_auth_id", (q) => q.eq("authId", authId))
683
+ .unique(),
684
+ });
685
+ ```
686
+
687
+ Then create a `DodoPayments` client in a local module and export its API methods:
595
688
 
596
689
  ```typescript
597
- // convex/checkout.ts
598
- import { mutation } from "./_generated/server";
599
- import { components } from "./_generated/server";
600
-
601
- export const createCheckoutSession = mutation({
602
- args: { customerId: v.string() },
603
- handler: async (ctx, args) => {
604
- const dodo = components.dodopayments;
605
- return dodo.checkout.createSession(ctx, {
606
- customerId: args.customerId,
607
- // ... checkout params
690
+ // convex/dodo.ts
691
+ import { DodoPayments } from "@dodopayments/convex";
692
+ import { components, internal } from "./_generated/api";
693
+
694
+ const apiKey = process.env.DODO_PAYMENTS_API_KEY;
695
+ if (!apiKey) throw new Error("DODO_PAYMENTS_API_KEY is not set");
696
+
697
+ export const dodo = new DodoPayments(components.dodopayments, {
698
+ // Maps the signed-in Convex user to a Dodo customer; used by customerPortal.
699
+ identify: async (ctx) => {
700
+ const identity = await ctx.auth.getUserIdentity();
701
+ if (!identity) return null;
702
+ const customer = await ctx.runQuery(internal.customers.getByAuthId, {
703
+ authId: identity.subject,
704
+ });
705
+ return customer ? { dodoCustomerId: customer.dodoCustomerId } : null;
706
+ },
707
+ apiKey,
708
+ environment: process.env.DODO_PAYMENTS_ENVIRONMENT === "live_mode" ? "live_mode" : "test_mode",
709
+ });
710
+
711
+ export const { checkout, customerPortal } = dodo.api();
712
+ ```
713
+
714
+ Call `checkout` from an **action** (mutations cannot make network calls), passing the checkout session fields in `payload`. The client sends a plan slug; the server maps it to a product ID and owns the return URL, so a caller cannot pick an arbitrary product or redirect target:
715
+
716
+ ```typescript
717
+ // convex/payments.ts
718
+ import { action } from "./_generated/server";
719
+ import { v } from "convex/values";
720
+ import { checkout } from "./dodo";
721
+
722
+ // Public plan slug -> Dodo product ID. Never accept a product_id from the client.
723
+ const PRODUCT_IDS: Readonly<Record<string, string>> = {
724
+ starter: "pdt_starter_monthly",
725
+ pro: "pdt_pro_monthly",
726
+ };
727
+
728
+ export const createCheckout = action({
729
+ args: { plan: v.string() },
730
+ handler: async (ctx, { plan }) => {
731
+ if (!Object.hasOwn(PRODUCT_IDS, plan)) throw new Error(`Unknown plan: ${plan}`);
732
+ // Returns { checkout_url }
733
+ return await checkout(ctx, {
734
+ payload: {
735
+ product_cart: [{ product_id: PRODUCT_IDS[plan], quantity: 1 }],
736
+ return_url: process.env.DODO_PAYMENTS_RETURN_URL,
737
+ },
608
738
  });
609
739
  },
610
740
  });
@@ -618,7 +748,7 @@ Convex only supports session checkout, not static or dynamic modes.
618
748
 
619
749
  2. **Wrong webhook variable name:** Check whether your adapter uses `DODO_PAYMENTS_WEBHOOK_KEY` or `DODO_PAYMENTS_WEBHOOK_SECRET`. The docs are inconsistent; use the name your adapter actually references.
620
750
 
621
- 3. **Forgetting raw body preservation:** Webhook handlers must receive the raw request body, not a re-parsed JSON object. Fastify requires an explicit string body parser; Express needs `express.raw()`; other frameworks handle this automatically. Webhook signature verification is covered in the `webhook-integration` skill.
751
+ 3. **Forgetting raw body preservation:** Webhook handlers must receive the raw request body, not a re-parsed JSON object. Fastify requires an explicit string body parser; other frameworks handle this automatically. **Express is the exception:** its `Webhooks` handler verifies against the parsed `req.body`, so register `express.json()` before the route and do not use `express.raw()`, which makes every verification fail. Webhook signature verification is covered in the `webhook-integration` skill.
622
752
 
623
753
  4. **Mixing framework conventions:** Each framework has its own request/response shape. Don't try to use a Next.js handler in Express or vice versa. Use the adapter for your framework.
624
754
 
@@ -626,7 +756,9 @@ Convex only supports session checkout, not static or dynamic modes.
626
756
 
627
757
  6. **Skipping environment setup:** The adapters won't work without `DODO_PAYMENTS_API_KEY` and `DODO_PAYMENTS_ENVIRONMENT`. Set these before testing.
628
758
 
629
- 7. **Using `@dodopayments/core` directly:** The core package is an internal dependency, not a documented public entry point. Use the framework adapter for your stack.
759
+ 7. **Exposing CustomerPortal unauthenticated:** The portal handlers trust `?customer_id=` from the request. Resolve the ID from the signed-in session on the server, as in the examples above, or anyone can open any customer's portal.
760
+
761
+ 8. **Using `@dodopayments/core` directly:** The core package is an internal dependency, not a documented public entry point. Use the framework adapter for your stack.
630
762
 
631
763
  ## Resources
632
764