@dodopayments/opencode-plugin 0.6.0 → 0.6.2
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/README.md +1 -0
- package/opencode-plugin/README.md +1 -1
- package/package.json +1 -1
- package/skills/better-auth-integration/SKILL.md +12 -3
- package/skills/billing-sdk/SKILL.md +15 -167
- package/skills/billing-sdk/references/pricing-page.md +197 -0
- package/skills/checkout-integration/SKILL.md +31 -460
- package/skills/checkout-integration/references/overlay-checkout.md +148 -0
- package/skills/checkout-integration/references/server-examples.md +317 -0
- package/skills/credit-based-billing/SKILL.md +11 -158
- package/skills/credit-based-billing/references/ai-token-example.md +108 -0
- package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
- package/skills/customer-management/SKILL.md +25 -17
- package/skills/discounts-and-promotions/SKILL.md +20 -17
- package/skills/dodo-best-practices/SKILL.md +18 -5
- package/skills/dodo-mcp-usage/SKILL.md +152 -0
- package/skills/framework-adapters/SKILL.md +64 -421
- package/skills/framework-adapters/references/astro.md +52 -0
- package/skills/framework-adapters/references/bun.md +45 -0
- package/skills/framework-adapters/references/convex.md +91 -0
- package/skills/framework-adapters/references/express.md +72 -0
- package/skills/framework-adapters/references/fastify.md +54 -0
- package/skills/framework-adapters/references/hono.md +63 -0
- package/skills/framework-adapters/references/nuxt.md +53 -0
- package/skills/framework-adapters/references/remix.md +65 -0
- package/skills/framework-adapters/references/sveltekit.md +49 -0
- package/skills/framework-adapters/references/tanstack-start.md +31 -0
- package/skills/license-keys/SKILL.md +117 -367
- package/skills/license-keys/references/cli-tool.md +143 -0
- package/skills/license-keys/references/desktop-app.md +170 -0
- package/skills/localized-pricing/SKILL.md +51 -29
- package/skills/mobile-checkout/SKILL.md +72 -310
- package/skills/mobile-checkout/references/android.md +69 -0
- package/skills/mobile-checkout/references/flutter.md +97 -0
- package/skills/mobile-checkout/references/ios.md +66 -0
- package/skills/mobile-checkout/references/react-native.md +100 -0
- package/skills/product-catalog-management/SKILL.md +39 -16
- package/skills/refunds-and-disputes/SKILL.md +91 -71
- package/skills/subscription-integration/SKILL.md +43 -64
- package/skills/subscription-integration/references/webhook-events.md +82 -0
- package/skills/testing-and-go-live/SKILL.md +22 -15
- package/skills/usage-based-billing/SKILL.md +16 -122
- package/skills/usage-based-billing/references/instrumentation.md +119 -0
- package/skills/webhook-integration/SKILL.md +31 -263
- package/skills/webhook-integration/references/common-mistakes.md +146 -0
- package/skills/webhook-integration/references/event-catalog.md +103 -0
- package/skills/webhook-integration/references/local-testing.md +42 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: checkout-integration
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments checkout via hosted Checkout Sessions (client.checkoutSessions.create), static payment links, and overlay or inline checkout for one-time and subscription products. Use when building a pay button, checkout page, trial signup, custom fields, discount codes at checkout, or return_url redirects; use subscription-integration for post-checkout lifecycle.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Dodo Payments Checkout Integration
|
|
@@ -38,7 +38,7 @@ Dodo Payments offers three ways to collect payment:
|
|
|
38
38
|
|
|
39
39
|
**Checkout Session:** A single-use session that generates a hosted checkout URL. Expires after 24 hours (or 15 minutes if `confirm=true`).
|
|
40
40
|
|
|
41
|
-
**Return URL:** Where the customer lands after payment.
|
|
41
|
+
**Return URL:** Where the customer lands after payment. Dodo appends `payment_id` (one-time) or `subscription_id` (subscription), `status`, and when available `license_key` and `email`. It is a UI hint only, never proof of payment.
|
|
42
42
|
|
|
43
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
44
|
|
|
@@ -209,471 +209,31 @@ const session = await client.checkoutSessions.create({
|
|
|
209
209
|
|
|
210
210
|
## Next.js App Router
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
Full guide: [references/server-examples.md](references/server-examples.md).
|
|
213
213
|
|
|
214
|
-
|
|
215
|
-
// app/api/checkout/route.ts
|
|
216
|
-
import { NextResponse } from 'next/server';
|
|
217
|
-
import DodoPayments from 'dodopayments';
|
|
218
|
-
import { getCurrentUser } from '@/lib/auth';
|
|
219
|
-
|
|
220
|
-
const client = new DodoPayments({
|
|
221
|
-
bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
|
|
222
|
-
environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
|
|
223
|
-
});
|
|
224
|
-
|
|
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;
|
|
244
|
-
try {
|
|
245
|
-
body = await request.json();
|
|
246
|
-
} catch {
|
|
247
|
-
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
248
|
-
}
|
|
249
|
-
|
|
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;
|
|
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 {
|
|
270
|
-
const session = await client.checkoutSessions.create({
|
|
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`
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
return NextResponse.json({
|
|
278
|
-
checkoutUrl: session.checkout_url,
|
|
279
|
-
sessionId: session.session_id
|
|
280
|
-
});
|
|
281
|
-
} catch (error) {
|
|
282
|
-
console.error('Checkout error:', error);
|
|
283
|
-
return NextResponse.json(
|
|
284
|
-
{ error: 'Failed to create checkout' },
|
|
285
|
-
{ status: 500 }
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
### Client Component
|
|
292
|
-
|
|
293
|
-
```typescript
|
|
294
|
-
// components/CheckoutButton.tsx
|
|
295
|
-
'use client';
|
|
296
|
-
|
|
297
|
-
import { useState } from 'react';
|
|
298
|
-
|
|
299
|
-
interface CheckoutButtonProps {
|
|
300
|
-
planId: 'starter' | 'pro';
|
|
301
|
-
quantity?: number;
|
|
302
|
-
children: React.ReactNode;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
export function CheckoutButton({ planId, quantity = 1, children }: CheckoutButtonProps) {
|
|
306
|
-
const [loading, setLoading] = useState(false);
|
|
307
|
-
|
|
308
|
-
const handleCheckout = async () => {
|
|
309
|
-
setLoading(true);
|
|
310
|
-
try {
|
|
311
|
-
const response = await fetch('/api/checkout', {
|
|
312
|
-
method: 'POST',
|
|
313
|
-
headers: { 'Content-Type': 'application/json' },
|
|
314
|
-
body: JSON.stringify({ planId, quantity })
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
if (!response.ok) {
|
|
318
|
-
throw new Error('Failed to create checkout');
|
|
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;
|
|
324
|
-
} catch (error) {
|
|
325
|
-
console.error('Checkout error:', error);
|
|
326
|
-
alert('Failed to start checkout. Please try again.');
|
|
327
|
-
} finally {
|
|
328
|
-
setLoading(false);
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
return (
|
|
333
|
-
<button onClick={handleCheckout} disabled={loading}>
|
|
334
|
-
{loading ? 'Loading...' : children}
|
|
335
|
-
</button>
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
### Success Page
|
|
214
|
+
Covers:
|
|
341
215
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
function SuccessContent() {
|
|
347
|
-
return (
|
|
348
|
-
<div className="text-center py-20">
|
|
349
|
-
<h1 className="text-3xl font-bold">Payment Successful</h1>
|
|
350
|
-
<p className="mt-4 text-gray-600">
|
|
351
|
-
Thank you for your purchase. Check your email for a confirmation.
|
|
352
|
-
</p>
|
|
353
|
-
<a href="/" className="mt-8 inline-block text-blue-600 hover:underline">
|
|
354
|
-
Return to Home
|
|
355
|
-
</a>
|
|
356
|
-
</div>
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
export default function SuccessPage() {
|
|
361
|
-
return (
|
|
362
|
-
<Suspense fallback={<div>Loading...</div>}>
|
|
363
|
-
<SuccessContent />
|
|
364
|
-
</Suspense>
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
---
|
|
216
|
+
- API Route
|
|
217
|
+
- Client Component
|
|
218
|
+
- Success Page
|
|
370
219
|
|
|
371
220
|
## Express.js
|
|
372
221
|
|
|
373
|
-
|
|
374
|
-
import express from 'express';
|
|
375
|
-
import DodoPayments from 'dodopayments';
|
|
376
|
-
import { getCurrentUser } from './auth';
|
|
377
|
-
import { fulfillOneTimePurchase, grantSubscriptionAccess } from './entitlements';
|
|
378
|
-
|
|
379
|
-
const app = express();
|
|
380
|
-
|
|
381
|
-
const client = new DodoPayments({
|
|
382
|
-
bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
|
|
383
|
-
environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
|
|
384
|
-
});
|
|
385
|
-
|
|
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
|
-
});
|
|
396
|
-
|
|
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
|
-
}
|
|
404
|
-
|
|
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
|
-
});
|
|
411
|
-
|
|
412
|
-
app.use(express.json());
|
|
413
|
-
|
|
414
|
-
type PlanId = 'starter' | 'pro';
|
|
415
|
-
|
|
416
|
-
const PRODUCT_IDS: Record<PlanId, string> = {
|
|
417
|
-
starter: 'pdt_starter_monthly',
|
|
418
|
-
pro: 'pdt_pro_monthly'
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
function productIdForPlan(value: unknown): string | null {
|
|
422
|
-
if (typeof value !== 'string') return null;
|
|
423
|
-
return PRODUCT_IDS[value as PlanId] ?? null;
|
|
424
|
-
}
|
|
425
|
-
|
|
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
|
-
}
|
|
432
|
-
|
|
433
|
-
const { planId, quantity = 1 } = req.body as {
|
|
434
|
-
planId?: unknown;
|
|
435
|
-
quantity?: unknown;
|
|
436
|
-
};
|
|
437
|
-
const productId = productIdForPlan(planId);
|
|
438
|
-
|
|
439
|
-
if (!productId) {
|
|
440
|
-
res.status(400).json({ error: 'Unknown plan' });
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (!Number.isInteger(quantity) || (quantity as number) < 1) {
|
|
445
|
-
res.status(400).json({ error: 'Quantity must be a positive integer' });
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
try {
|
|
450
|
-
const session = await client.checkoutSessions.create({
|
|
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`
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
res.json({ checkoutUrl: session.checkout_url });
|
|
458
|
-
} catch (error) {
|
|
459
|
-
console.error('Checkout error:', error);
|
|
460
|
-
res.status(500).json({ error: 'Failed to create checkout' });
|
|
461
|
-
}
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
app.get('/success', (req, res) => {
|
|
465
|
-
res.send('Payment successful!');
|
|
466
|
-
});
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
---
|
|
222
|
+
Full guide: [references/server-examples.md](references/server-examples.md).
|
|
470
223
|
|
|
471
224
|
## Python (FastAPI)
|
|
472
225
|
|
|
473
|
-
|
|
474
|
-
import logging
|
|
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
|
|
483
|
-
|
|
484
|
-
app = FastAPI()
|
|
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
|
-
}
|
|
495
|
-
|
|
496
|
-
class CheckoutRequest(BaseModel):
|
|
497
|
-
plan_id: Literal["starter", "pro"]
|
|
498
|
-
quantity: int = Field(default=1, gt=0)
|
|
499
|
-
|
|
500
|
-
@app.post("/api/checkout")
|
|
501
|
-
async def create_checkout(
|
|
502
|
-
request: CheckoutRequest,
|
|
503
|
-
user: Annotated[User, Depends(get_current_user)],
|
|
504
|
-
):
|
|
505
|
-
try:
|
|
506
|
-
session = client.checkout_sessions.create(
|
|
507
|
-
product_cart=[{
|
|
508
|
-
"product_id": PRODUCT_IDS[request.plan_id],
|
|
509
|
-
"quantity": request.quantity
|
|
510
|
-
}],
|
|
511
|
-
customer={
|
|
512
|
-
"email": user.email,
|
|
513
|
-
"name": user.name
|
|
514
|
-
},
|
|
515
|
-
metadata={"app_user_id": user.id},
|
|
516
|
-
return_url=f"{os.environ['APP_URL']}/success"
|
|
517
|
-
)
|
|
518
|
-
return {"checkout_url": session.checkout_url}
|
|
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
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
---
|
|
226
|
+
Full guide: [references/server-examples.md](references/server-examples.md).
|
|
525
227
|
|
|
526
228
|
## Overlay Checkout
|
|
527
229
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
### Installation
|
|
531
|
-
|
|
532
|
-
```bash
|
|
533
|
-
npm install dodopayments-checkout
|
|
534
|
-
```
|
|
535
|
-
|
|
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.
|
|
540
|
-
|
|
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
|
-
});
|
|
551
|
-
|
|
552
|
-
type PlanId = 'starter' | 'pro';
|
|
553
|
-
|
|
554
|
-
const PRODUCT_IDS: Record<PlanId, string> = {
|
|
555
|
-
starter: 'pdt_starter_monthly',
|
|
556
|
-
pro: 'pdt_pro_monthly'
|
|
557
|
-
};
|
|
558
|
-
|
|
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
|
-
}
|
|
564
|
-
|
|
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;
|
|
638
|
-
}
|
|
230
|
+
Full guide: [references/overlay-checkout.md](references/overlay-checkout.md).
|
|
639
231
|
|
|
640
|
-
|
|
641
|
-
const checkoutUrl = await createCheckoutUrl(planId);
|
|
232
|
+
Covers:
|
|
642
233
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
onEvent: (event) => {
|
|
647
|
-
console.log('Checkout event:', event);
|
|
648
|
-
}
|
|
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);
|
|
662
|
-
}
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
DodoPayments.Checkout.open({
|
|
666
|
-
checkoutUrl,
|
|
667
|
-
elementId: 'dodo-inline-checkout'
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
```
|
|
671
|
-
|
|
672
|
-
```html
|
|
673
|
-
<div id="dodo-inline-checkout"></div>
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
---
|
|
234
|
+
- Installation
|
|
235
|
+
- Server Route
|
|
236
|
+
- Browser Overlay and Inline Modes
|
|
677
237
|
|
|
678
238
|
## Static Payment Links
|
|
679
239
|
|
|
@@ -685,6 +245,8 @@ No-code shareable links. No server-side API call needed.
|
|
|
685
245
|
https://checkout.dodopayments.com/buy/{productid}
|
|
686
246
|
```
|
|
687
247
|
|
|
248
|
+
In test mode the host is `test.checkout.dodopayments.com` (for example `https://test.checkout.dodopayments.com/buy/{productid}`); a test-mode product does not exist on the live host.
|
|
249
|
+
|
|
688
250
|
Example:
|
|
689
251
|
```text
|
|
690
252
|
https://checkout.dodopayments.com/buy/pdt_example
|
|
@@ -789,16 +351,25 @@ const session = await client.checkoutSessions.create({
|
|
|
789
351
|
After payment, the customer is redirected to your `return_url` with query parameters:
|
|
790
352
|
|
|
791
353
|
```text
|
|
792
|
-
|
|
354
|
+
# One-time payment
|
|
355
|
+
https://yoursite.com/success?payment_id=pay_xxx&status=succeeded&email=customer%40example.com
|
|
356
|
+
|
|
357
|
+
# Subscription (with license keys)
|
|
358
|
+
https://yoursite.com/success?subscription_id=sub_xxx&status=active&license_key=LK-001,LK-002&email=customer%40example.com
|
|
793
359
|
```
|
|
794
360
|
|
|
795
361
|
Query parameters:
|
|
796
|
-
- `
|
|
797
|
-
- `
|
|
362
|
+
- `payment_id` (one-time payments) or `subscription_id` (subscriptions)
|
|
363
|
+
- `status`: `succeeded` (one-time) or `active` (subscription) on success; `failed` if declined; `processing` (or a `requires_*` value) if it settles later; `expired` if the session expired
|
|
364
|
+
- `license_key`: present when the product issues license keys (comma-separated if several)
|
|
365
|
+
- `email`: present when the customer has an email on record
|
|
366
|
+
|
|
367
|
+
There is no `session_id` parameter. Treat `processing`/missing status as "unknown" and wait for the webhook.
|
|
798
368
|
|
|
799
369
|
### Verify Payment Server-Side
|
|
800
370
|
|
|
801
|
-
Do not trust the browser redirect. Use the Express webhook route
|
|
371
|
+
Do not trust the browser redirect. Use the Express webhook route in
|
|
372
|
+
[references/server-examples.md](references/server-examples.md#expressjs): it receives the raw signed body
|
|
802
373
|
before `express.json()`, fulfills one-time purchases on verified `payment.succeeded` using
|
|
803
374
|
`event.data.customer.customer_id`, and grants subscription access only on verified `subscription.active`.
|
|
804
375
|
|
|
@@ -818,8 +389,8 @@ Do not use `client.payments.create()` or `client.subscriptions.create()` for new
|
|
|
818
389
|
**3. Forgetting the `environment` flag**
|
|
819
390
|
The default is `live_mode`. Always set `environment: 'test_mode'` during development to avoid charging real cards.
|
|
820
391
|
|
|
821
|
-
**4.
|
|
822
|
-
|
|
392
|
+
**4. Using the deprecated `discount_code`**
|
|
393
|
+
Use `discount_codes` (an array). The singular `discount_code` string is deprecated (still accepted for backward compatibility) and cannot be combined with `discount_codes` in the same request.
|
|
823
394
|
|
|
824
395
|
**5. Amounts in wrong unit**
|
|
825
396
|
All amounts are in the smallest currency unit (cents for USD). $10 is `1000`, not `10`.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Dodo Payments Checkout Integration: Overlay Checkout
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
Use the `dodopayments-checkout` package for overlay or inline checkout that stays on your site.
|
|
5
|
+
|
|
6
|
+
### Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install dodopayments-checkout
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### Server Route
|
|
13
|
+
|
|
14
|
+
Create the Checkout Session on the server. The browser sends a public plan slug, while the server derives
|
|
15
|
+
the customer from the authenticated user and maps the slug to an allowlisted product id.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// app/api/overlay-checkout/route.ts
|
|
19
|
+
import { NextResponse } from 'next/server';
|
|
20
|
+
import DodoPayments from 'dodopayments';
|
|
21
|
+
import { getCurrentUser } from '@/lib/auth';
|
|
22
|
+
|
|
23
|
+
const client = new DodoPayments({
|
|
24
|
+
bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
|
|
25
|
+
environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
type PlanId = 'starter' | 'pro';
|
|
29
|
+
|
|
30
|
+
const PRODUCT_IDS: Record<PlanId, string> = {
|
|
31
|
+
starter: 'pdt_starter_monthly',
|
|
32
|
+
pro: 'pdt_pro_monthly'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function POST(request: Request) {
|
|
36
|
+
const user = await getCurrentUser();
|
|
37
|
+
if (!user) {
|
|
38
|
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let body: unknown;
|
|
42
|
+
try {
|
|
43
|
+
body = await request.json();
|
|
44
|
+
} catch {
|
|
45
|
+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!body || typeof body !== 'object') {
|
|
49
|
+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { planId, quantity = 1 } = body as {
|
|
53
|
+
planId?: unknown;
|
|
54
|
+
quantity?: unknown;
|
|
55
|
+
};
|
|
56
|
+
const productId = typeof planId === 'string'
|
|
57
|
+
? PRODUCT_IDS[planId as PlanId]
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
if (!productId) {
|
|
61
|
+
return NextResponse.json({ error: 'Unknown plan' }, { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!Number.isInteger(quantity) || (quantity as number) < 1) {
|
|
65
|
+
return NextResponse.json(
|
|
66
|
+
{ error: 'Quantity must be a positive integer' },
|
|
67
|
+
{ status: 400 }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const session = await client.checkoutSessions.create({
|
|
73
|
+
product_cart: [{ product_id: productId, quantity: quantity as number }],
|
|
74
|
+
customer: { email: user.email, name: user.name },
|
|
75
|
+
metadata: { app_user_id: user.id },
|
|
76
|
+
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!session.checkout_url) {
|
|
80
|
+
return NextResponse.json({ error: 'Checkout URL was not returned' }, { status: 502 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return NextResponse.json({ checkoutUrl: session.checkout_url });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('Overlay checkout error:', error);
|
|
86
|
+
return NextResponse.json({ error: 'Failed to create checkout' }, { status: 500 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Browser Overlay and Inline Modes
|
|
92
|
+
|
|
93
|
+
Browser code calls the server route and receives only the checkout URL. The Dodo Payments API bearer token
|
|
94
|
+
stays in the server route and is never sent to the browser.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// lib/open-checkout.ts
|
|
98
|
+
import { DodoPayments } from 'dodopayments-checkout';
|
|
99
|
+
|
|
100
|
+
type PlanId = 'starter' | 'pro';
|
|
101
|
+
|
|
102
|
+
async function createCheckoutUrl(planId: PlanId, quantity = 1): Promise<string> {
|
|
103
|
+
const response = await fetch('/api/overlay-checkout', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ planId, quantity })
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!response.ok) throw new Error('Failed to create checkout');
|
|
110
|
+
|
|
111
|
+
const data = (await response.json()) as { checkoutUrl?: string };
|
|
112
|
+
if (!data.checkoutUrl) throw new Error('Checkout URL was not returned');
|
|
113
|
+
return data.checkoutUrl;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Call ONCE when the app loads (e.g. in your root component or entry point),
|
|
117
|
+
// not before every checkout. Pick the display type your page uses.
|
|
118
|
+
export function initCheckout(displayType: 'overlay' | 'inline'): void {
|
|
119
|
+
DodoPayments.Initialize({
|
|
120
|
+
mode: 'test', // or 'live'
|
|
121
|
+
displayType,
|
|
122
|
+
onEvent: (event) => {
|
|
123
|
+
console.log('Checkout event:', event);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Requires initCheckout('overlay') to have run at app load.
|
|
129
|
+
export async function openOverlayCheckout(planId: PlanId): Promise<void> {
|
|
130
|
+
const checkoutUrl = await createCheckoutUrl(planId);
|
|
131
|
+
DodoPayments.Checkout.open({ checkoutUrl });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Requires initCheckout('inline') to have run at app load.
|
|
135
|
+
export async function openInlineCheckout(planId: PlanId): Promise<void> {
|
|
136
|
+
const checkoutUrl = await createCheckoutUrl(planId);
|
|
137
|
+
DodoPayments.Checkout.open({
|
|
138
|
+
checkoutUrl,
|
|
139
|
+
elementId: 'dodo-inline-checkout'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```html
|
|
145
|
+
<div id="dodo-inline-checkout"></div>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|