Accept One-Time Payments in Next.js with Dodo Payments (TypeScript Example)

I'm a passionate developer who loves turning unique ideas into real-life solutions through code. Competitive programming and globe-trotting keep my creativity alive.
Want to sell digital goods or accept one-time payments in your Next.js app? Dodo Payments makes it easy for Indian startups. Here’s a simple, step-by-step guide using TypeScript and a “Premium PDF Download” example.
1. Setup
First, install the required packages:
npx create-next-app@latest premium-pdf
cd premium-pdf
npm install dodopayments @prisma/client prisma
npx prisma init
Add these to your .env.local:
DODO_PAYMENT_API_KEY=your_dodo_api_key
DODO_WEBHOOK_SECRET=your_dodo_webhook_key
DODO_PDF_PRODUCT_ID=prod_premium_pdf
NEXTAUTH_URL=http://localhost:3000
2. Prisma Schema
For this example, let’s keep it simple:
model User {
id String @id @default(cuid())
email String @unique
name String?
purchases Purchase[]
}
model Purchase {
id String @id @default(cuid())
userId String
productId String
paid Boolean @default(false)
user User @relation(fields: [userId], references: [id])
}
3. Checkout API Route
When a user clicks “Buy PDF”, create a Dodo customer and payment link:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import DodoPayments from "dodopayments";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const { userId, userEmail, userName } = await req.json();
// 1. Create Dodo customer
const dodo = new DodoPayments({
bearerToken: process.env.DODO_PAYMENT_API_KEY,
baseURL: "https://test.dodopayments.com",
});
const customer = await dodo.customers.create({ email: userEmail, name: userName });
// 2. Save purchase record (unpaid for now)
const purchase = await prisma.purchase.create({
data: {
userId,
productId: process.env.DODO_PDF_PRODUCT_ID!,
paid: false,
},
});
// 3. Create Dodo payment link (one-time payment)
const payment = await dodo.payments.create({
amount: 499, // Rs. 499
currency: "INR",
customer: { customer_id: customer.customer_id },
product_id: process.env.DODO_PDF_PRODUCT_ID!,
payment_link: true,
return_url: `${process.env.NEXTAUTH_URL}/pdf/success?purchase_id=${purchase.id}`,
});
return NextResponse.json({ url: payment.payment_link });
}
4. Webhook Route: Mark Purchase as Paid
Dodo will call your webhook on payment events. Let’s mark the purchase as paid.
// app/api/webhooks/dodo/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Webhook } from "standardwebhooks";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
export async function POST(request: NextRequest) {
const webhook = new Webhook(process.env.DODO_WEBHOOK_SECRET!);
const body = await request.text();
const headersList = await headers();
const webhookHeaders = {
"webhook-id": headersList.get("webhook-id") || "",
"webhook-signature": headersList.get("webhook-signature") || "",
"webhook-timestamp": headersList.get("webhook-timestamp") || "",
};
const payload = await webhook.verify(body, webhookHeaders);
// If payment succeeded, mark purchase as paid
if (payload.event_type === "payment.succeeded") {
// You may want to match by customer_id and product_id
const purchase = await prisma.purchase.findFirst({
where: {
productId: payload.product_id,
user: { email: payload.customer_email }
}
});
if (purchase) {
await prisma.purchase.update({
where: { id: purchase.id },
data: { paid: true }
});
}
}
return NextResponse.json({ received: true });
}
5. Frontend: Buy PDF Button
// components/BuyPDFButton.tsx
'use client';
import { useState } from 'react';
export default function BuyPDFButton({ userId, userEmail, userName }: { userId: string, userEmail: string, userName: string }) {
const [loading, setLoading] = useState(false);
const handleBuy = async () => {
setLoading(true);
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ userId, userEmail, userName }),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
window.location.href = data.url; // Redirect to payment page
};
return (
<button onClick={handleBuy} disabled={loading}>
{loading ? 'Redirecting...' : 'Buy Premium PDF (₹499)'}
</button>
);
}
6. Download Access: Only for Paid Users
// app/api/pdf/download/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const purchaseId = req.nextUrl.searchParams.get("purchase_id");
if (!purchaseId) return NextResponse.json({ error: "No purchase ID." }, { status: 400 });
const purchase = await prisma.purchase.findUnique({ where: { id: purchaseId } });
if (!purchase?.paid) {
return NextResponse.json({ error: "Payment not completed." }, { status: 403 });
}
// Here, send the PDF file or a secure download link
return NextResponse.json({ success: true, downloadUrl: "/pdfs/premium.pdf" });
}
7. Testing Webhooks Locally
Use ngrok to expose your local webhook endpoint:
ngrok http 3000
Set your webhook URL in Dodo dashboard to https://YOUR_NGROK_URL/api/webhooks/dodo.
8. Summary
Checkout: Create Dodo customer, initiate one-time payment, redirect to payment page.
Webhook: On payment success, mark purchase as paid.
Download: Only paid users can download the PDF.
Frontend: “Buy PDF” button triggers the flow.
This pattern works for any digital product: PDFs, eBooks, templates, etc.
Happy selling! If you found this helpful, share or leave a comment.





