@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.
Files changed (47) hide show
  1. package/README.md +1 -0
  2. package/opencode-plugin/README.md +1 -1
  3. package/package.json +1 -1
  4. package/skills/better-auth-integration/SKILL.md +12 -3
  5. package/skills/billing-sdk/SKILL.md +15 -167
  6. package/skills/billing-sdk/references/pricing-page.md +197 -0
  7. package/skills/checkout-integration/SKILL.md +31 -460
  8. package/skills/checkout-integration/references/overlay-checkout.md +148 -0
  9. package/skills/checkout-integration/references/server-examples.md +317 -0
  10. package/skills/credit-based-billing/SKILL.md +11 -158
  11. package/skills/credit-based-billing/references/ai-token-example.md +108 -0
  12. package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
  13. package/skills/customer-management/SKILL.md +25 -17
  14. package/skills/discounts-and-promotions/SKILL.md +20 -17
  15. package/skills/dodo-best-practices/SKILL.md +18 -5
  16. package/skills/dodo-mcp-usage/SKILL.md +152 -0
  17. package/skills/framework-adapters/SKILL.md +64 -421
  18. package/skills/framework-adapters/references/astro.md +52 -0
  19. package/skills/framework-adapters/references/bun.md +45 -0
  20. package/skills/framework-adapters/references/convex.md +91 -0
  21. package/skills/framework-adapters/references/express.md +72 -0
  22. package/skills/framework-adapters/references/fastify.md +54 -0
  23. package/skills/framework-adapters/references/hono.md +63 -0
  24. package/skills/framework-adapters/references/nuxt.md +53 -0
  25. package/skills/framework-adapters/references/remix.md +65 -0
  26. package/skills/framework-adapters/references/sveltekit.md +49 -0
  27. package/skills/framework-adapters/references/tanstack-start.md +31 -0
  28. package/skills/license-keys/SKILL.md +117 -367
  29. package/skills/license-keys/references/cli-tool.md +143 -0
  30. package/skills/license-keys/references/desktop-app.md +170 -0
  31. package/skills/localized-pricing/SKILL.md +51 -29
  32. package/skills/mobile-checkout/SKILL.md +72 -310
  33. package/skills/mobile-checkout/references/android.md +69 -0
  34. package/skills/mobile-checkout/references/flutter.md +97 -0
  35. package/skills/mobile-checkout/references/ios.md +66 -0
  36. package/skills/mobile-checkout/references/react-native.md +100 -0
  37. package/skills/product-catalog-management/SKILL.md +39 -16
  38. package/skills/refunds-and-disputes/SKILL.md +91 -71
  39. package/skills/subscription-integration/SKILL.md +43 -64
  40. package/skills/subscription-integration/references/webhook-events.md +82 -0
  41. package/skills/testing-and-go-live/SKILL.md +22 -15
  42. package/skills/usage-based-billing/SKILL.md +16 -122
  43. package/skills/usage-based-billing/references/instrumentation.md +119 -0
  44. package/skills/webhook-integration/SKILL.md +31 -263
  45. package/skills/webhook-integration/references/common-mistakes.md +146 -0
  46. package/skills/webhook-integration/references/event-catalog.md +103 -0
  47. package/skills/webhook-integration/references/local-testing.md +42 -0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: checkout-integration
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.
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. Query parameters include `status=success` and `session_id`.
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
- ### API Route
212
+ Full guide: [references/server-examples.md](references/server-examples.md).
213
213
 
214
- ```typescript
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
- ```typescript
343
- // app/checkout/success/page.tsx
344
- import { Suspense } from 'react';
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
- ```typescript
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
- ```python
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
- Use the `dodopayments-checkout` package for overlay or inline checkout that stays on your site.
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
- export async function openOverlayCheckout(planId: PlanId): Promise<void> {
641
- const checkoutUrl = await createCheckoutUrl(planId);
232
+ Covers:
642
233
 
643
- DodoPayments.Initialize({
644
- mode: 'test', // or 'live'
645
- displayType: 'overlay',
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
- https://yoursite.com/success?status=success&session_id=cks_123
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
- - `status`: `success` or `failed`
797
- - `session_id`: Checkout session ID
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 above: it receives the raw signed body
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. Assuming only one discount-code form is valid**
822
- Both `discount_code` (a string) and `discount_codes` (an array) are valid Checkout Session parameters.
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
+ ---