@dodopayments/opencode-plugin 0.3.2 → 0.4.0

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,140 +1,287 @@
1
1
  ---
2
2
  name: checkout-integration
3
- description: Guide for creating checkout sessions and payment flows with Dodo Payments - one-time, subscriptions, and overlay checkout.
3
+ description: Guide for starting hosted Checkout Sessions, payment links, and overlay or inline checkout for one-time and recurring products; use subscription-integration for post-checkout lifecycle management.
4
4
  ---
5
5
 
6
6
  # Dodo Payments Checkout Integration
7
7
 
8
- **Reference: [docs.dodopayments.com/developer-resources/integration-guide](https://docs.dodopayments.com/developer-resources/integration-guide)**
8
+ Use `client.checkoutSessions.create(...)` to build hosted checkout pages or overlay checkout modals. This is the recommended path for all new payment integrations.
9
9
 
10
- Create seamless payment experiences with hosted checkout pages or overlay checkout modals.
10
+ ## When to use this skill
11
+
12
+ - Build a one-time payment checkout flow
13
+ - Create a subscription checkout with optional trial
14
+ - Embed checkout in an overlay or inline modal
15
+ - Collect customer billing info and custom fields at checkout
16
+ - Apply discount codes or handle currency selection
17
+ - Redirect customers after payment completes
11
18
 
12
19
  ---
13
20
 
14
21
  ## Checkout Methods
15
22
 
16
- | Method | Best For | Integration |
17
- |--------|----------|-------------|
18
- | **Hosted Checkout** | Simple integration, full-page redirect | Server-side SDK |
19
- | **Overlay Checkout** | Seamless UX, stays on your site | JavaScript SDK |
20
- | **Payment Links** | No-code, shareable links | Dashboard |
23
+ Dodo Payments offers three ways to collect payment:
24
+
25
+ | Method | Best For | Setup |
26
+ |--------|----------|-------|
27
+ | **Checkout Sessions** (recommended) | Most integrations; full control | Server-side SDK call |
28
+ | **Static Payment Links** | No-code sharing; reusable URLs | Dashboard or direct URL |
29
+ | **Overlay/Inline Checkout** | Checkout stays on your site | Client-side SDK |
30
+
31
+ **Legacy:** Dynamic Payment Links created via `POST /payments` or `POST /subscriptions` are deprecated. Use Checkout Sessions instead.
21
32
 
22
33
  ---
23
34
 
24
- ## Hosted Checkout
35
+ ## Core Concepts
36
+
37
+ **Amounts in smallest currency unit:** All prices are in cents (or equivalent). A $10 USD charge is `1000`.
38
+
39
+ **Checkout Session:** A single-use session that generates a hosted checkout URL. Expires after 24 hours (or 15 minutes if `confirm=true`).
40
+
41
+ **Return URL:** Where the customer lands after payment. Query parameters include `status=success` and `session_id`.
42
+
43
+ **Entitlement on webhook:** The browser redirect is not the source of truth. Always verify payment via webhook before granting access. See `webhook-integration` skill for verification.
44
+
45
+ ---
25
46
 
26
- ### Basic Implementation
47
+ ## Create a Checkout Session
48
+
49
+ ### Basic One-Time Payment
27
50
 
28
51
  ```typescript
29
52
  import DodoPayments from 'dodopayments';
30
53
 
31
54
  const client = new DodoPayments({
32
55
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
56
+ environment: 'test_mode', // or 'live_mode'
33
57
  });
34
58
 
35
- // Create checkout session
36
59
  const session = await client.checkoutSessions.create({
37
60
  product_cart: [
38
- { product_id: 'prod_xxxxx', quantity: 1 }
61
+ { product_id: 'pdt_example', quantity: 1 }
39
62
  ],
40
63
  customer: {
41
64
  email: 'customer@example.com',
42
- name: 'John Doe',
65
+ name: 'Jane Doe'
43
66
  },
44
- return_url: 'https://yoursite.com/checkout/success',
67
+ return_url: 'https://yoursite.com/checkout/success'
45
68
  });
46
69
 
47
- // Redirect customer to checkout
48
- // session.checkout_url
70
+ console.log('Redirect to:', session.checkout_url);
49
71
  ```
50
72
 
73
+ Response fields:
74
+ - `session_id`: Unique checkout session ID
75
+ - `checkout_url`: Hosted checkout URL (redirect customer here). Nullable when `confirm=true`
76
+ - `client_secret`: Present only if `confirm=true`
77
+ - `publishable_key`: Present only if `confirm=true`. Pair it with `client_secret` for the inline SDK flow
78
+ - `payment_id`: Present only if `confirm=true`
79
+
80
+ `publishable_key` is a per-session value returned for confirm-mode inline checkout. It is not a Stripe-style
81
+ publishable *API key* — Dodo issues no such credential, and every API key is secret and server-side only.
82
+
51
83
  ### With Multiple Products
52
84
 
53
85
  ```typescript
54
86
  const session = await client.checkoutSessions.create({
55
87
  product_cart: [
56
- { product_id: 'prod_item_1', quantity: 2 },
57
- { product_id: 'prod_item_2', quantity: 1 },
88
+ { product_id: 'pdt_item_1', quantity: 2 },
89
+ { product_id: 'pdt_item_2', quantity: 1 }
90
+ ],
91
+ customer: { email: 'customer@example.com' },
92
+ return_url: 'https://yoursite.com/success'
93
+ });
94
+ ```
95
+
96
+ ### With Existing Customer
97
+
98
+ ```typescript
99
+ const session = await client.checkoutSessions.create({
100
+ product_cart: [
101
+ { product_id: 'pdt_example', quantity: 1 }
102
+ ],
103
+ customer: { customer_id: 'cus_existing_id' },
104
+ return_url: 'https://yoursite.com/success'
105
+ });
106
+ ```
107
+
108
+ ### With Billing Address and Tax ID
109
+
110
+ ```typescript
111
+ const session = await client.checkoutSessions.create({
112
+ product_cart: [
113
+ { product_id: 'pdt_example', quantity: 1 }
58
114
  ],
59
115
  customer: {
60
116
  email: 'customer@example.com',
117
+ name: 'Jane Doe'
61
118
  },
62
- return_url: 'https://yoursite.com/success',
119
+ billing_address: {
120
+ country: 'US',
121
+ street: '123 Main St',
122
+ city: 'San Francisco',
123
+ state: 'CA',
124
+ zipcode: '94105'
125
+ },
126
+ tax_id: 'VAT123456789',
127
+ customer_business_name: 'Acme Corp',
128
+ return_url: 'https://yoursite.com/success'
63
129
  });
64
130
  ```
65
131
 
66
- ### With Customer ID (Existing Customer)
132
+ ### With Discount Codes
67
133
 
68
134
  ```typescript
69
135
  const session = await client.checkoutSessions.create({
70
136
  product_cart: [
71
- { product_id: 'prod_xxxxx', quantity: 1 }
137
+ { product_id: 'pdt_example', quantity: 1 }
72
138
  ],
73
- customer_id: 'cust_existing_customer',
74
- return_url: 'https://yoursite.com/success',
139
+ customer: { email: 'customer@example.com' },
140
+ discount_codes: ['PROMO10', 'WELCOME5'], // max 20, ordered
141
+ feature_flags: {
142
+ allow_discount_code: true
143
+ },
144
+ return_url: 'https://yoursite.com/success'
75
145
  });
76
146
  ```
77
147
 
78
- ### With Metadata
148
+ ### With Subscription and Trial
79
149
 
80
150
  ```typescript
81
151
  const session = await client.checkoutSessions.create({
82
152
  product_cart: [
83
- { product_id: 'prod_xxxxx', quantity: 1 }
153
+ { product_id: 'pdt_monthly_subscription', quantity: 1 }
84
154
  ],
85
- customer: {
86
- email: 'customer@example.com',
155
+ subscription_data: {
156
+ trial_period_days: 14
87
157
  },
158
+ customer: { email: 'subscriber@example.com' },
159
+ return_url: 'https://yoursite.com/success'
160
+ });
161
+ ```
162
+
163
+ ### With Custom Fields
164
+
165
+ ```typescript
166
+ const session = await client.checkoutSessions.create({
167
+ product_cart: [
168
+ { product_id: 'pdt_example', quantity: 1 }
169
+ ],
170
+ customer: { email: 'customer@example.com' },
171
+ custom_fields: [
172
+ {
173
+ key: 'company_size',
174
+ label: 'Company Size',
175
+ field_type: 'dropdown',
176
+ options: ['1-10', '11-50', '51-200', '200+'],
177
+ required: true
178
+ },
179
+ {
180
+ key: 'use_case',
181
+ label: 'Primary Use Case',
182
+ field_type: 'text',
183
+ placeholder: 'e.g., analytics, reporting',
184
+ required: false
185
+ }
186
+ ],
187
+ return_url: 'https://yoursite.com/success'
188
+ });
189
+ ```
190
+
191
+ ### With Metadata
192
+
193
+ ```typescript
194
+ const session = await client.checkoutSessions.create({
195
+ product_cart: [
196
+ { product_id: 'pdt_example', quantity: 1 }
197
+ ],
198
+ customer: { email: 'customer@example.com' },
88
199
  metadata: {
89
200
  order_id: 'order_12345',
90
201
  referral_code: 'FRIEND20',
91
- user_id: 'internal_user_id',
202
+ campaign: 'summer_sale'
92
203
  },
93
- return_url: 'https://yoursite.com/success',
204
+ return_url: 'https://yoursite.com/success'
94
205
  });
95
206
  ```
96
207
 
97
208
  ---
98
209
 
99
- ## Next.js Implementation
210
+ ## Next.js App Router
100
211
 
101
212
  ### API Route
102
213
 
103
214
  ```typescript
104
215
  // app/api/checkout/route.ts
105
- import { NextRequest, NextResponse } from 'next/server';
216
+ import { NextResponse } from 'next/server';
106
217
  import DodoPayments from 'dodopayments';
218
+ import { getCurrentUser } from '@/lib/auth';
107
219
 
108
220
  const client = new DodoPayments({
109
221
  bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
222
+ environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
110
223
  });
111
224
 
112
- export async function POST(req: NextRequest) {
225
+ type PlanId = 'starter' | 'pro';
226
+
227
+ const PRODUCT_IDS: Record<PlanId, string> = {
228
+ starter: 'pdt_starter_monthly',
229
+ pro: 'pdt_pro_monthly'
230
+ };
231
+
232
+ function productIdForPlan(value: unknown): string | null {
233
+ if (typeof value !== 'string') return null;
234
+ return PRODUCT_IDS[value as PlanId] ?? null;
235
+ }
236
+
237
+ export async function POST(request: Request) {
238
+ const user = await getCurrentUser();
239
+ if (!user) {
240
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
241
+ }
242
+
243
+ let body: unknown;
113
244
  try {
114
- const { productId, quantity = 1, email, name, metadata } = await req.json();
245
+ body = await request.json();
246
+ } catch {
247
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
248
+ }
115
249
 
116
- if (!productId || !email) {
117
- return NextResponse.json(
118
- { error: 'Missing required fields' },
119
- { status: 400 }
120
- );
121
- }
250
+ if (!body || typeof body !== 'object') {
251
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
252
+ }
253
+
254
+ const input = body as { planId?: unknown; quantity?: unknown };
255
+ const productId = productIdForPlan(input.planId);
256
+ const quantity = input.quantity ?? 1;
122
257
 
258
+ if (!productId) {
259
+ return NextResponse.json({ error: 'Unknown plan' }, { status: 400 });
260
+ }
261
+
262
+ if (!Number.isInteger(quantity) || (quantity as number) < 1) {
263
+ return NextResponse.json(
264
+ { error: 'Quantity must be a positive integer' },
265
+ { status: 400 }
266
+ );
267
+ }
268
+
269
+ try {
123
270
  const session = await client.checkoutSessions.create({
124
- product_cart: [{ product_id: productId, quantity }],
125
- customer: { email, name },
126
- metadata,
127
- return_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`,
271
+ product_cart: [{ product_id: productId, quantity: quantity as number }],
272
+ customer: { email: user.email, name: user.name },
273
+ metadata: { app_user_id: user.id },
274
+ return_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`
128
275
  });
129
276
 
130
- return NextResponse.json({
277
+ return NextResponse.json({
131
278
  checkoutUrl: session.checkout_url,
132
- sessionId: session.checkout_session_id,
279
+ sessionId: session.session_id
133
280
  });
134
- } catch (error: any) {
281
+ } catch (error) {
135
282
  console.error('Checkout error:', error);
136
283
  return NextResponse.json(
137
- { error: error.message || 'Failed to create checkout' },
284
+ { error: 'Failed to create checkout' },
138
285
  { status: 500 }
139
286
  );
140
287
  }
@@ -150,32 +297,30 @@ export async function POST(req: NextRequest) {
150
297
  import { useState } from 'react';
151
298
 
152
299
  interface CheckoutButtonProps {
153
- productId: string;
154
- email: string;
155
- name?: string;
300
+ planId: 'starter' | 'pro';
301
+ quantity?: number;
156
302
  children: React.ReactNode;
157
303
  }
158
304
 
159
- export function CheckoutButton({ productId, email, name, children }: CheckoutButtonProps) {
305
+ export function CheckoutButton({ planId, quantity = 1, children }: CheckoutButtonProps) {
160
306
  const [loading, setLoading] = useState(false);
161
307
 
162
308
  const handleCheckout = async () => {
163
309
  setLoading(true);
164
-
165
310
  try {
166
311
  const response = await fetch('/api/checkout', {
167
312
  method: 'POST',
168
313
  headers: { 'Content-Type': 'application/json' },
169
- body: JSON.stringify({ productId, email, name }),
314
+ body: JSON.stringify({ planId, quantity })
170
315
  });
171
316
 
172
- const data = await response.json();
173
-
174
- if (data.checkoutUrl) {
175
- window.location.href = data.checkoutUrl;
176
- } else {
177
- throw new Error(data.error || 'Failed to create checkout');
317
+ if (!response.ok) {
318
+ throw new Error('Failed to create checkout');
178
319
  }
320
+
321
+ const data = (await response.json()) as { checkoutUrl?: string };
322
+ if (!data.checkoutUrl) throw new Error('Checkout URL was not returned');
323
+ window.location.href = data.checkoutUrl;
179
324
  } catch (error) {
180
325
  console.error('Checkout error:', error);
181
326
  alert('Failed to start checkout. Please try again.');
@@ -201,9 +346,9 @@ import { Suspense } from 'react';
201
346
  function SuccessContent() {
202
347
  return (
203
348
  <div className="text-center py-20">
204
- <h1 className="text-3xl font-bold">Payment Successful!</h1>
349
+ <h1 className="text-3xl font-bold">Payment Successful</h1>
205
350
  <p className="mt-4 text-gray-600">
206
- Thank you for your purchase. You will receive a confirmation email shortly.
351
+ Thank you for your purchase. Check your email for a confirmation.
207
352
  </p>
208
353
  <a href="/" className="mt-8 inline-block text-blue-600 hover:underline">
209
354
  Return to Home
@@ -223,148 +368,99 @@ export default function SuccessPage() {
223
368
 
224
369
  ---
225
370
 
226
- ## Overlay Checkout
227
-
228
- Embed checkout directly on your page without redirects.
229
-
230
- ### Installation
231
-
232
- ```bash
233
- npm install @dodopayments/checkout
234
- ```
235
-
236
- ### Basic Usage
371
+ ## Express.js
237
372
 
238
373
  ```typescript
239
- import { DodoCheckout } from '@dodopayments/checkout';
374
+ import express from 'express';
375
+ import DodoPayments from 'dodopayments';
376
+ import { getCurrentUser } from './auth';
377
+ import { fulfillOneTimePurchase, grantSubscriptionAccess } from './entitlements';
240
378
 
241
- // Initialize
242
- const checkout = new DodoCheckout({
243
- apiKey: 'your_publishable_key',
244
- environment: 'live', // or 'test'
245
- });
379
+ const app = express();
246
380
 
247
- // Open overlay
248
- checkout.open({
249
- productId: 'prod_xxxxx',
250
- customer: {
251
- email: 'customer@example.com',
252
- },
253
- onSuccess: (result) => {
254
- console.log('Payment successful:', result);
255
- // Handle success
256
- },
257
- onClose: () => {
258
- console.log('Checkout closed');
259
- },
381
+ const client = new DodoPayments({
382
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
383
+ environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
260
384
  });
261
- ```
262
385
 
263
- ### React Component
386
+ // Mount the webhook before express.json() so verification receives signed bytes.
387
+ app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
388
+ try {
389
+ const event = client.webhooks.unwrap(req.body.toString('utf8'), {
390
+ headers: {
391
+ 'webhook-id': req.headers['webhook-id'] as string,
392
+ 'webhook-signature': req.headers['webhook-signature'] as string,
393
+ 'webhook-timestamp': req.headers['webhook-timestamp'] as string
394
+ }
395
+ });
264
396
 
265
- ```typescript
266
- // components/OverlayCheckout.tsx
267
- 'use client';
397
+ if (event.type === 'payment.succeeded') {
398
+ // This branch fulfills one-time purchases only.
399
+ await fulfillOneTimePurchase(event.data.customer.customer_id);
400
+ } else if (event.type === 'subscription.active') {
401
+ // Subscription access starts only after this verified event.
402
+ await grantSubscriptionAccess(event.data);
403
+ }
268
404
 
269
- import { useEffect, useRef } from 'react';
270
- import { DodoCheckout } from '@dodopayments/checkout';
405
+ res.json({ received: true });
406
+ } catch (error) {
407
+ console.error('Webhook verification failed:', error);
408
+ res.status(401).json({ error: 'Invalid signature' });
409
+ }
410
+ });
271
411
 
272
- interface OverlayCheckoutProps {
273
- productId: string;
274
- email: string;
275
- onSuccess?: (result: any) => void;
276
- children: React.ReactNode;
277
- }
412
+ app.use(express.json());
278
413
 
279
- export function OverlayCheckout({
280
- productId,
281
- email,
282
- onSuccess,
283
- children
284
- }: OverlayCheckoutProps) {
285
- const checkoutRef = useRef<DodoCheckout | null>(null);
286
-
287
- useEffect(() => {
288
- checkoutRef.current = new DodoCheckout({
289
- apiKey: process.env.NEXT_PUBLIC_DODO_PUBLISHABLE_KEY!,
290
- environment: process.env.NODE_ENV === 'production' ? 'live' : 'test',
291
- });
414
+ type PlanId = 'starter' | 'pro';
292
415
 
293
- return () => {
294
- checkoutRef.current?.close();
295
- };
296
- }, []);
297
-
298
- const handleClick = () => {
299
- checkoutRef.current?.open({
300
- productId,
301
- customer: { email },
302
- onSuccess: (result) => {
303
- onSuccess?.(result);
304
- // Optionally redirect
305
- window.location.href = '/checkout/success';
306
- },
307
- onClose: () => {
308
- console.log('Checkout closed');
309
- },
310
- });
311
- };
416
+ const PRODUCT_IDS: Record<PlanId, string> = {
417
+ starter: 'pdt_starter_monthly',
418
+ pro: 'pdt_pro_monthly'
419
+ };
312
420
 
313
- return (
314
- <button onClick={handleClick}>
315
- {children}
316
- </button>
317
- );
421
+ function productIdForPlan(value: unknown): string | null {
422
+ if (typeof value !== 'string') return null;
423
+ return PRODUCT_IDS[value as PlanId] ?? null;
318
424
  }
319
- ```
320
-
321
- ### Customization
322
425
 
323
- ```typescript
324
- checkout.open({
325
- productId: 'prod_xxxxx',
326
- customer: { email: 'customer@example.com' },
327
- theme: {
328
- primaryColor: '#0066FF',
329
- backgroundColor: '#FFFFFF',
330
- fontFamily: 'Inter, sans-serif',
331
- },
332
- locale: 'en',
333
- });
334
- ```
335
-
336
- ---
337
-
338
- ## Express.js Implementation
426
+ app.post('/api/checkout', async (req, res) => {
427
+ const user = await getCurrentUser(req);
428
+ if (!user) {
429
+ res.status(401).json({ error: 'Not authenticated' });
430
+ return;
431
+ }
339
432
 
340
- ```typescript
341
- import express from 'express';
342
- import DodoPayments from 'dodopayments';
433
+ const { planId, quantity = 1 } = req.body as {
434
+ planId?: unknown;
435
+ quantity?: unknown;
436
+ };
437
+ const productId = productIdForPlan(planId);
343
438
 
344
- const app = express();
345
- app.use(express.json());
439
+ if (!productId) {
440
+ res.status(400).json({ error: 'Unknown plan' });
441
+ return;
442
+ }
346
443
 
347
- const client = new DodoPayments({
348
- bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
349
- });
444
+ if (!Number.isInteger(quantity) || (quantity as number) < 1) {
445
+ res.status(400).json({ error: 'Quantity must be a positive integer' });
446
+ return;
447
+ }
350
448
 
351
- app.post('/api/create-checkout', async (req, res) => {
352
449
  try {
353
- const { productId, email, name, quantity = 1 } = req.body;
354
-
355
450
  const session = await client.checkoutSessions.create({
356
- product_cart: [{ product_id: productId, quantity }],
357
- customer: { email, name },
358
- return_url: `${process.env.APP_URL}/success`,
451
+ product_cart: [{ product_id: productId, quantity: quantity as number }],
452
+ customer: { email: user.email, name: user.name },
453
+ metadata: { app_user_id: user.id },
454
+ return_url: `${process.env.APP_URL}/success`
359
455
  });
360
456
 
361
457
  res.json({ checkoutUrl: session.checkout_url });
362
- } catch (error: any) {
363
- res.status(500).json({ error: error.message });
458
+ } catch (error) {
459
+ console.error('Checkout error:', error);
460
+ res.status(500).json({ error: 'Failed to create checkout' });
364
461
  }
365
462
  });
366
463
 
367
- // Success page route
368
464
  app.get('/success', (req, res) => {
369
465
  res.send('Payment successful!');
370
466
  });
@@ -372,228 +468,383 @@ app.get('/success', (req, res) => {
372
468
 
373
469
  ---
374
470
 
375
- ## Python Implementation
376
-
377
- ### FastAPI
471
+ ## Python (FastAPI)
378
472
 
379
473
  ```python
380
- from fastapi import FastAPI, HTTPException
381
- from pydantic import BaseModel
382
- from dodopayments import DodoPayments
474
+ import logging
383
475
  import os
476
+ from typing import Annotated, Literal
477
+
478
+ from dodopayments import DodoPayments
479
+ from fastapi import Depends, FastAPI, HTTPException
480
+ from pydantic import BaseModel, Field
481
+
482
+ from app.auth import User, get_current_user
384
483
 
385
484
  app = FastAPI()
386
- client = DodoPayments(bearer_token=os.environ["DODO_PAYMENTS_API_KEY"])
485
+ logger = logging.getLogger(__name__)
486
+ client = DodoPayments(
487
+ bearer_token=os.environ["DODO_PAYMENTS_API_KEY"],
488
+ environment="test_mode"
489
+ )
490
+
491
+ PRODUCT_IDS = {
492
+ "starter": "pdt_starter_monthly",
493
+ "pro": "pdt_pro_monthly",
494
+ }
387
495
 
388
496
  class CheckoutRequest(BaseModel):
389
- product_id: str
390
- email: str
391
- name: str = None
392
- quantity: int = 1
497
+ plan_id: Literal["starter", "pro"]
498
+ quantity: int = Field(default=1, gt=0)
393
499
 
394
500
  @app.post("/api/checkout")
395
- async def create_checkout(request: CheckoutRequest):
501
+ async def create_checkout(
502
+ request: CheckoutRequest,
503
+ user: Annotated[User, Depends(get_current_user)],
504
+ ):
396
505
  try:
397
506
  session = client.checkout_sessions.create(
398
507
  product_cart=[{
399
- "product_id": request.product_id,
508
+ "product_id": PRODUCT_IDS[request.plan_id],
400
509
  "quantity": request.quantity
401
510
  }],
402
511
  customer={
403
- "email": request.email,
404
- "name": request.name
512
+ "email": user.email,
513
+ "name": user.name
405
514
  },
515
+ metadata={"app_user_id": user.id},
406
516
  return_url=f"{os.environ['APP_URL']}/success"
407
517
  )
408
-
409
518
  return {"checkout_url": session.checkout_url}
410
- except Exception as e:
411
- raise HTTPException(status_code=500, detail=str(e))
519
+ except Exception as error:
520
+ logger.exception("Checkout session creation failed")
521
+ raise HTTPException(status_code=500, detail="Failed to create checkout") from error
412
522
  ```
413
523
 
414
- ### Flask
524
+ ---
415
525
 
416
- ```python
417
- from flask import Flask, request, jsonify
418
- from dodopayments import DodoPayments
419
- import os
526
+ ## Overlay Checkout
527
+
528
+ Use the `dodopayments-checkout` package for overlay or inline checkout that stays on your site.
529
+
530
+ ### Installation
420
531
 
421
- app = Flask(__name__)
422
- client = DodoPayments(bearer_token=os.environ["DODO_PAYMENTS_API_KEY"])
423
-
424
- @app.route('/api/checkout', methods=['POST'])
425
- def create_checkout():
426
- data = request.json
427
-
428
- session = client.checkout_sessions.create(
429
- product_cart=[{
430
- "product_id": data['product_id'],
431
- "quantity": data.get('quantity', 1)
432
- }],
433
- customer={
434
- "email": data['email'],
435
- "name": data.get('name')
436
- },
437
- return_url=f"{os.environ['APP_URL']}/success"
438
- )
439
-
440
- return jsonify({"checkout_url": session.checkout_url})
532
+ ```bash
533
+ npm install dodopayments-checkout
441
534
  ```
442
535
 
443
- ---
536
+ ### Server Route
537
+
538
+ Create the Checkout Session on the server. The browser sends a public plan slug, while the server derives
539
+ the customer from the authenticated user and maps the slug to an allowlisted product id.
444
540
 
445
- ## Go Implementation
541
+ ```typescript
542
+ // app/api/overlay-checkout/route.ts
543
+ import { NextResponse } from 'next/server';
544
+ import DodoPayments from 'dodopayments';
545
+ import { getCurrentUser } from '@/lib/auth';
546
+
547
+ const client = new DodoPayments({
548
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
549
+ environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
550
+ });
446
551
 
447
- ```go
448
- package main
552
+ type PlanId = 'starter' | 'pro';
449
553
 
450
- import (
451
- "encoding/json"
452
- "net/http"
453
- "os"
454
-
455
- "github.com/dodopayments/dodopayments-go"
456
- )
554
+ const PRODUCT_IDS: Record<PlanId, string> = {
555
+ starter: 'pdt_starter_monthly',
556
+ pro: 'pdt_pro_monthly'
557
+ };
457
558
 
458
- var client = dodopayments.NewClient(
459
- option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
460
- )
559
+ export async function POST(request: Request) {
560
+ const user = await getCurrentUser();
561
+ if (!user) {
562
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
563
+ }
461
564
 
462
- type CheckoutRequest struct {
463
- ProductID string `json:"product_id"`
464
- Email string `json:"email"`
465
- Name string `json:"name"`
466
- Quantity int `json:"quantity"`
565
+ let body: unknown;
566
+ try {
567
+ body = await request.json();
568
+ } catch {
569
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
570
+ }
571
+
572
+ if (!body || typeof body !== 'object') {
573
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
574
+ }
575
+
576
+ const { planId, quantity = 1 } = body as {
577
+ planId?: unknown;
578
+ quantity?: unknown;
579
+ };
580
+ const productId = typeof planId === 'string'
581
+ ? PRODUCT_IDS[planId as PlanId]
582
+ : undefined;
583
+
584
+ if (!productId) {
585
+ return NextResponse.json({ error: 'Unknown plan' }, { status: 400 });
586
+ }
587
+
588
+ if (!Number.isInteger(quantity) || (quantity as number) < 1) {
589
+ return NextResponse.json(
590
+ { error: 'Quantity must be a positive integer' },
591
+ { status: 400 }
592
+ );
593
+ }
594
+
595
+ try {
596
+ const session = await client.checkoutSessions.create({
597
+ product_cart: [{ product_id: productId, quantity: quantity as number }],
598
+ customer: { email: user.email, name: user.name },
599
+ metadata: { app_user_id: user.id },
600
+ return_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`
601
+ });
602
+
603
+ if (!session.checkout_url) {
604
+ return NextResponse.json({ error: 'Checkout URL was not returned' }, { status: 502 });
605
+ }
606
+
607
+ return NextResponse.json({ checkoutUrl: session.checkout_url });
608
+ } catch (error) {
609
+ console.error('Overlay checkout error:', error);
610
+ return NextResponse.json({ error: 'Failed to create checkout' }, { status: 500 });
611
+ }
612
+ }
613
+ ```
614
+
615
+ ### Browser Overlay and Inline Modes
616
+
617
+ Browser code calls the server route and receives only the checkout URL. The Dodo Payments API bearer token
618
+ stays in the server route and is never sent to the browser.
619
+
620
+ ```typescript
621
+ // lib/open-checkout.ts
622
+ import { DodoPayments } from 'dodopayments-checkout';
623
+
624
+ type PlanId = 'starter' | 'pro';
625
+
626
+ async function createCheckoutUrl(planId: PlanId, quantity = 1): Promise<string> {
627
+ const response = await fetch('/api/overlay-checkout', {
628
+ method: 'POST',
629
+ headers: { 'Content-Type': 'application/json' },
630
+ body: JSON.stringify({ planId, quantity })
631
+ });
632
+
633
+ if (!response.ok) throw new Error('Failed to create checkout');
634
+
635
+ const data = (await response.json()) as { checkoutUrl?: string };
636
+ if (!data.checkoutUrl) throw new Error('Checkout URL was not returned');
637
+ return data.checkoutUrl;
467
638
  }
468
639
 
469
- func createCheckout(w http.ResponseWriter, r *http.Request) {
470
- var req CheckoutRequest
471
- json.NewDecoder(r.Body).Decode(&req)
472
-
473
- if req.Quantity == 0 {
474
- req.Quantity = 1
640
+ export async function openOverlayCheckout(planId: PlanId): Promise<void> {
641
+ const checkoutUrl = await createCheckoutUrl(planId);
642
+
643
+ DodoPayments.Initialize({
644
+ mode: 'test', // or 'live'
645
+ displayType: 'overlay',
646
+ onEvent: (event) => {
647
+ console.log('Checkout event:', event);
475
648
  }
476
-
477
- session, err := client.CheckoutSessions.Create(r.Context(), &dodopayments.CheckoutSessionCreateParams{
478
- ProductCart: []dodopayments.CartItem{
479
- {ProductID: req.ProductID, Quantity: req.Quantity},
480
- },
481
- Customer: &dodopayments.Customer{
482
- Email: req.Email,
483
- Name: req.Name,
484
- },
485
- ReturnURL: os.Getenv("APP_URL") + "/success",
486
- })
487
-
488
- if err != nil {
489
- http.Error(w, err.Error(), http.StatusInternalServerError)
490
- return
649
+ });
650
+
651
+ DodoPayments.Checkout.open({ checkoutUrl });
652
+ }
653
+
654
+ export async function openInlineCheckout(planId: PlanId): Promise<void> {
655
+ const checkoutUrl = await createCheckoutUrl(planId);
656
+
657
+ DodoPayments.Initialize({
658
+ mode: 'test', // or 'live'
659
+ displayType: 'inline',
660
+ onEvent: (event) => {
661
+ console.log('Checkout event:', event);
491
662
  }
492
-
493
- json.NewEncoder(w).Encode(map[string]string{
494
- "checkout_url": session.CheckoutURL,
495
- })
663
+ });
664
+
665
+ DodoPayments.Checkout.open({
666
+ checkoutUrl,
667
+ elementId: 'dodo-inline-checkout'
668
+ });
496
669
  }
497
670
  ```
498
671
 
672
+ ```html
673
+ <div id="dodo-inline-checkout"></div>
674
+ ```
675
+
499
676
  ---
500
677
 
501
- ## Handling Success
678
+ ## Static Payment Links
502
679
 
503
- ### Query Parameters
504
- The return URL receives these query parameters:
505
- - `status=success` - Payment completed
506
- - `session_id` - Checkout session ID
680
+ No-code shareable links. No server-side API call needed.
507
681
 
508
- ### Verify Payment Server-Side
509
- Don't rely solely on the redirect. Always verify via webhook:
682
+ ### Basic Format
510
683
 
511
- ```typescript
512
- // Webhook handler confirms payment
513
- app.post('/webhook', async (req, res) => {
514
- const event = req.body;
515
-
516
- if (event.type === 'payment.succeeded') {
517
- // This is the source of truth
518
- await fulfillOrder(event.data);
519
- }
520
-
521
- res.json({ received: true });
522
- });
684
+ ```text
685
+ https://checkout.dodopayments.com/buy/{productid}
686
+ ```
687
+
688
+ Example:
689
+ ```text
690
+ https://checkout.dodopayments.com/buy/pdt_example
691
+ ```
692
+
693
+ ### With Query Parameters
694
+
695
+ ```text
696
+ https://checkout.dodopayments.com/buy/pdt_example?quantity=2&email=customer@example.com&redirect_url=https%3A%2F%2Fyoursite.com%2Fsuccess
523
697
  ```
524
698
 
699
+ Supported parameters:
700
+ - `quantity`: Item quantity
701
+ - `redirect_url`: Success redirect URL
702
+ - `email`: Prefill customer email
703
+ - `fullName`, `firstName`, `lastName`: Prefill name
704
+ - `country`, `city`, `state`, `zipCode`, `addressLine`: Prefill address
705
+ - `paymentCurrency`: Force currency
706
+ - `metadata_*`: Custom metadata (e.g., `metadata_orderId=123`)
707
+
525
708
  ---
526
709
 
527
- ## Advanced Options
710
+ ## Retrieve and Preview Sessions
711
+
712
+ ### Retrieve Session Status
528
713
 
529
- ### Prefill Customer Info
530
714
  ```typescript
531
- const session = await client.checkoutSessions.create({
532
- product_cart: [{ product_id: 'prod_xxxxx', quantity: 1 }],
533
- customer: {
534
- email: 'customer@example.com',
535
- name: 'John Doe',
536
- phone: '+1234567890',
537
- address: {
538
- line1: '123 Main St',
539
- city: 'San Francisco',
540
- state: 'CA',
541
- postal_code: '94105',
542
- country: 'US',
543
- },
544
- },
545
- return_url: 'https://yoursite.com/success',
715
+ const status = await client.checkoutSessions.retrieve('cks_session_id');
716
+
717
+ console.log(status.id);
718
+ console.log(status.payment_status);
719
+ ```
720
+
721
+ ### Preview Session (without creating)
722
+
723
+ ```typescript
724
+ const preview = await client.checkoutSessions.preview({
725
+ product_cart: [
726
+ { product_id: 'pdt_example', quantity: 1 }
727
+ ],
728
+ customer: { email: 'customer@example.com' },
729
+ billing_currency: 'EUR'
546
730
  });
731
+
732
+ console.log('Preview total:', preview.current_breakup.total_amount);
547
733
  ```
548
734
 
549
- ### Custom Success/Cancel URLs
735
+ ---
736
+
737
+ ## Customization
738
+
739
+ ### Theme and Appearance
740
+
550
741
  ```typescript
551
742
  const session = await client.checkoutSessions.create({
552
- product_cart: [{ product_id: 'prod_xxxxx', quantity: 1 }],
743
+ product_cart: [{ product_id: 'pdt_example', quantity: 1 }],
553
744
  customer: { email: 'customer@example.com' },
554
- return_url: 'https://yoursite.com/checkout/success?session_id={CHECKOUT_SESSION_ID}',
745
+ customization: {
746
+ theme: 'dark', // 'light', 'dark', or 'system'
747
+ force_language: 'en',
748
+ show_order_details: true,
749
+ theme_config: {
750
+ font_size: 'md',
751
+ font_weight: 'normal',
752
+ radius: '8px',
753
+ pay_button_text: 'Complete Purchase',
754
+ light: {
755
+ bg_primary: '#ffffff',
756
+ text_primary: '#000000',
757
+ button_primary: '#0066ff'
758
+ }
759
+ }
760
+ },
761
+ return_url: 'https://yoursite.com/success'
555
762
  });
556
763
  ```
557
764
 
558
- ### Subscription with Trial
765
+ ### Feature Flags
766
+
559
767
  ```typescript
560
768
  const session = await client.checkoutSessions.create({
561
- product_cart: [{ product_id: 'prod_subscription', quantity: 1 }],
562
- subscription_data: {
563
- trial_period_days: 14,
564
- },
769
+ product_cart: [{ product_id: 'pdt_example', quantity: 1 }],
565
770
  customer: { email: 'customer@example.com' },
566
- return_url: 'https://yoursite.com/success',
771
+ feature_flags: {
772
+ allow_discount_code: true,
773
+ allow_currency_selection: true,
774
+ allow_customer_editing_email: true,
775
+ allow_phone_number_collection: true,
776
+ require_phone_number: false,
777
+ allow_tax_id: true
778
+ },
779
+ return_url: 'https://yoursite.com/success'
567
780
  });
568
781
  ```
569
782
 
570
783
  ---
571
784
 
572
- ## Error Handling
785
+ ## Post-Payment Flow
573
786
 
574
- ```typescript
575
- try {
576
- const session = await client.checkoutSessions.create({...});
577
- } catch (error: any) {
578
- if (error.status === 400) {
579
- // Invalid parameters
580
- console.error('Invalid request:', error.message);
581
- } else if (error.status === 401) {
582
- // Invalid API key
583
- console.error('Authentication failed');
584
- } else if (error.status === 404) {
585
- // Product not found
586
- console.error('Product not found');
587
- } else {
588
- console.error('Checkout error:', error);
589
- }
590
- }
787
+ ### Return URL Handling
788
+
789
+ After payment, the customer is redirected to your `return_url` with query parameters:
790
+
791
+ ```text
792
+ https://yoursite.com/success?status=success&session_id=cks_123
591
793
  ```
592
794
 
795
+ Query parameters:
796
+ - `status`: `success` or `failed`
797
+ - `session_id`: Checkout session ID
798
+
799
+ ### Verify Payment Server-Side
800
+
801
+ Do not trust the browser redirect. Use the Express webhook route above: it receives the raw signed body
802
+ before `express.json()`, fulfills one-time purchases on verified `payment.succeeded` using
803
+ `event.data.customer.customer_id`, and grants subscription access only on verified `subscription.active`.
804
+
805
+ Webhook signature verification is covered in the `webhook-integration` skill.
806
+
807
+ ---
808
+
809
+ ## Common Mistakes
810
+
811
+ **1. Granting access from the return URL**
812
+ The return URL redirect is not proof of payment. Never grant access or fulfill an order from its query
813
+ parameters; wait for a verified webhook event.
814
+
815
+ **2. Using deprecated APIs**
816
+ Do not use `client.payments.create()` or `client.subscriptions.create()` for new integrations. Both are deprecated. Use `client.checkoutSessions.create()`.
817
+
818
+ **3. Forgetting the `environment` flag**
819
+ The default is `live_mode`. Always set `environment: 'test_mode'` during development to avoid charging real cards.
820
+
821
+ **4. Assuming only one discount-code form is valid**
822
+ Both `discount_code` (a string) and `discount_codes` (an array) are valid Checkout Session parameters.
823
+
824
+ **5. Amounts in wrong unit**
825
+ All amounts are in the smallest currency unit (cents for USD). $10 is `1000`, not `10`.
826
+
827
+ **6. Reusing checkout URLs**
828
+ Checkout URLs are single-use. Create a new session for each checkout attempt.
829
+
830
+ **7. Ignoring `confirm=true` behavior**
831
+ When `confirm=true`, the session is finalized immediately and the checkout URL expires in 15 minutes instead of 24 hours. Use only when you have all required customer data.
832
+
833
+ **8. Forgetting raw body for webhooks**
834
+ Webhook signature verification requires the raw request body, not a re-serialized JSON object. Mount the
835
+ webhook route with `express.raw({ type: 'application/json' })` before `express.json()`.
836
+
837
+ **9. Trusting a client-supplied product id**
838
+ Do not accept an arbitrary `pdt_` id from the browser. Authenticate the user, accept a public plan slug,
839
+ map it to an allowlisted product id on the server, and reject quantities that are not positive integers.
840
+
593
841
  ---
594
842
 
595
843
  ## Resources
596
844
 
597
- - [Integration Guide](https://docs.dodopayments.com/developer-resources/integration-guide)
845
+ - [Checkout Sessions Integration Guide](https://docs.dodopayments.com/developer-resources/integration-guide)
846
+ - [Checkout Sessions API Reference](https://docs.dodopayments.com/api-reference/checkout-sessions/create)
598
847
  - [Overlay Checkout](https://docs.dodopayments.com/developer-resources/overlay-checkout)
599
- - [API Reference](https://docs.dodopayments.com/api-reference/checkout-sessions)
848
+ - [Inline Checkout](https://docs.dodopayments.com/developer-resources/inline-checkout)
849
+ - [Static Payment Links](https://docs.dodopayments.com/developer-resources/integration-guide)
850
+ - [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks)