@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.
- package/.mcp.json +4 -2
- package/README.md +92 -12
- package/opencode-plugin/README.md +22 -3
- package/opencode-plugin/index.js +19 -12
- package/package.json +1 -1
- package/skills/best-practices/SKILL.md +335 -179
- package/skills/better-auth-integration/SKILL.md +214 -0
- package/skills/billing-sdk/SKILL.md +357 -370
- package/skills/checkout-integration/SKILL.md +580 -329
- package/skills/credit-based-billing/SKILL.md +448 -775
- package/skills/customer-management/SKILL.md +281 -0
- package/skills/discounts-and-promotions/SKILL.md +464 -0
- package/skills/framework-adapters/SKILL.md +644 -0
- package/skills/license-keys/SKILL.md +324 -199
- package/skills/localized-pricing/SKILL.md +230 -0
- package/skills/mobile-checkout/SKILL.md +521 -0
- package/skills/product-catalog-management/SKILL.md +311 -0
- package/skills/refunds-and-disputes/SKILL.md +333 -0
- package/skills/subscription-integration/SKILL.md +323 -404
- package/skills/testing-and-go-live/SKILL.md +335 -0
- package/skills/usage-based-billing/SKILL.md +339 -479
- package/skills/webhook-integration/SKILL.md +600 -402
|
@@ -1,140 +1,287 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: checkout-integration
|
|
3
|
-
description: Guide for
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
|
19
|
-
|
|
20
|
-
| **
|
|
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
|
-
##
|
|
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
|
-
|
|
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: '
|
|
61
|
+
{ product_id: 'pdt_example', quantity: 1 }
|
|
39
62
|
],
|
|
40
63
|
customer: {
|
|
41
64
|
email: 'customer@example.com',
|
|
42
|
-
name: '
|
|
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
|
-
|
|
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: '
|
|
57
|
-
{ product_id: '
|
|
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
|
-
|
|
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
|
|
132
|
+
### With Discount Codes
|
|
67
133
|
|
|
68
134
|
```typescript
|
|
69
135
|
const session = await client.checkoutSessions.create({
|
|
70
136
|
product_cart: [
|
|
71
|
-
{ product_id: '
|
|
137
|
+
{ product_id: 'pdt_example', quantity: 1 }
|
|
72
138
|
],
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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: '
|
|
153
|
+
{ product_id: 'pdt_monthly_subscription', quantity: 1 }
|
|
84
154
|
],
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
body = await request.json();
|
|
246
|
+
} catch {
|
|
247
|
+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
248
|
+
}
|
|
115
249
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
279
|
+
sessionId: session.session_id
|
|
133
280
|
});
|
|
134
|
-
} catch (error
|
|
281
|
+
} catch (error) {
|
|
135
282
|
console.error('Checkout error:', error);
|
|
136
283
|
return NextResponse.json(
|
|
137
|
-
{ error:
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
name?: string;
|
|
300
|
+
planId: 'starter' | 'pro';
|
|
301
|
+
quantity?: number;
|
|
156
302
|
children: React.ReactNode;
|
|
157
303
|
}
|
|
158
304
|
|
|
159
|
-
export function CheckoutButton({
|
|
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({
|
|
314
|
+
body: JSON.stringify({ planId, quantity })
|
|
170
315
|
});
|
|
171
316
|
|
|
172
|
-
|
|
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
|
|
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.
|
|
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
|
-
##
|
|
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
|
|
374
|
+
import express from 'express';
|
|
375
|
+
import DodoPayments from 'dodopayments';
|
|
376
|
+
import { getCurrentUser } from './auth';
|
|
377
|
+
import { fulfillOneTimePurchase, grantSubscriptionAccess } from './entitlements';
|
|
240
378
|
|
|
241
|
-
|
|
242
|
-
const checkout = new DodoCheckout({
|
|
243
|
-
apiKey: 'your_publishable_key',
|
|
244
|
-
environment: 'live', // or 'test'
|
|
245
|
-
});
|
|
379
|
+
const app = express();
|
|
246
380
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
productId: string;
|
|
274
|
-
email: string;
|
|
275
|
-
onSuccess?: (result: any) => void;
|
|
276
|
-
children: React.ReactNode;
|
|
277
|
-
}
|
|
412
|
+
app.use(express.json());
|
|
278
413
|
|
|
279
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
433
|
+
const { planId, quantity = 1 } = req.body as {
|
|
434
|
+
planId?: unknown;
|
|
435
|
+
quantity?: unknown;
|
|
436
|
+
};
|
|
437
|
+
const productId = productIdForPlan(planId);
|
|
343
438
|
|
|
344
|
-
|
|
345
|
-
|
|
439
|
+
if (!productId) {
|
|
440
|
+
res.status(400).json({ error: 'Unknown plan' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
346
443
|
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
|
|
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
|
|
376
|
-
|
|
377
|
-
### FastAPI
|
|
471
|
+
## Python (FastAPI)
|
|
378
472
|
|
|
379
473
|
```python
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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(
|
|
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.
|
|
508
|
+
"product_id": PRODUCT_IDS[request.plan_id],
|
|
400
509
|
"quantity": request.quantity
|
|
401
510
|
}],
|
|
402
511
|
customer={
|
|
403
|
-
"email":
|
|
404
|
-
"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
|
|
411
|
-
|
|
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
|
-
|
|
524
|
+
---
|
|
415
525
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
package main
|
|
552
|
+
type PlanId = 'starter' | 'pro';
|
|
449
553
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
##
|
|
678
|
+
## Static Payment Links
|
|
502
679
|
|
|
503
|
-
|
|
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
|
-
###
|
|
509
|
-
Don't rely solely on the redirect. Always verify via webhook:
|
|
682
|
+
### Basic Format
|
|
510
683
|
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
##
|
|
710
|
+
## Retrieve and Preview Sessions
|
|
711
|
+
|
|
712
|
+
### Retrieve Session Status
|
|
528
713
|
|
|
529
|
-
### Prefill Customer Info
|
|
530
714
|
```typescript
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
},
|
|
545
|
-
|
|
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
|
-
|
|
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: '
|
|
743
|
+
product_cart: [{ product_id: 'pdt_example', quantity: 1 }],
|
|
553
744
|
customer: { email: 'customer@example.com' },
|
|
554
|
-
|
|
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
|
-
###
|
|
765
|
+
### Feature Flags
|
|
766
|
+
|
|
559
767
|
```typescript
|
|
560
768
|
const session = await client.checkoutSessions.create({
|
|
561
|
-
product_cart: [{ product_id: '
|
|
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
|
-
|
|
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
|
-
##
|
|
785
|
+
## Post-Payment Flow
|
|
573
786
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
- [
|
|
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)
|