dodo-webhook
Wire Dodo Payments webhooks end-to-end — signature verification, event routing, idempotency, and database sync. Three specific mistakes will silently break this. All three are covered.
Why This Exists
Dodo uses the Standard Webhooks spec. This is different from Stripe. The mistakes that will burn you:
- Using the raw
DODO_WEBHOOK_SECRETstring for verification — it won't work. The secret comes inwhsec_xxxxxformat. You must strip thewhsec_prefix and base64-decode the rest before using it. This fails silently in log-only mode, so you'll think verification works until you test strictly. - Letting any middleware parse the body as JSON before you verify. Signature verification happens over the raw body bytes. Once it's parsed and re-serialized, the bytes change and verification fails.
- Returning non-200 on processing errors. Dodo retries any non-200. If your handler throws and returns 500, you'll process the same payment event over and over.
Phase 1: Detect the Stack
Check the codebase:
- Framework: Next.js App Router / Pages Router / FastAPI / Express?
- Database ORM: Prisma / Drizzle / Supabase / Mongoose / raw SQL?
- User model: What field stores plan/credits? How is
userIdstored? - Existing webhook routes: Any
/api/webhooks/directory already?
Phase 2: Install Dependencies
# Node / Next.js
npm install standardwebhooks
# Python / FastAPI
pip install standardwebhooks
Add to .env.example:
DODO_API_KEY=
DODO_WEBHOOK_SECRET= # whsec_... format from Dodo dashboard
DODO_PRODUCT_ID= # Product ID for the thing users are buying
APP_URL= # Frontend URL for checkout redirect
Phase 3: Webhook Endpoint
Next.js App Router
// app/api/webhooks/dodo/route.ts
import { Webhook } from 'standardwebhooks';
export async function POST(req: Request) {
// CRITICAL: raw body — never req.json() here
const body = await req.text();
const webhookId = req.headers.get('webhook-id');
const webhookTimestamp = req.headers.get('webhook-timestamp');
const webhookSignature = req.headers.get('webhook-signature');
if (!webhookId || !webhookTimestamp || !webhookSignature) {
return new Response('Missing webhook headers', { status: 400 });
}
// Verify signature
let event: Record<string, any>;
try {
const secret = process.env.DODO_WEBHOOK_SECRET!;
// Strip whsec_ prefix — this is the step everyone misses
const base64Secret = secret.startsWith('whsec_') ? secret.slice(6) : secret;
const wh = new Webhook(base64Secret);
event = wh.verify(body, {
'webhook-id': webhookId,
'webhook-timestamp': webhookTimestamp,
'webhook-signature': webhookSignature,
}) as Record<string, any>;
} catch (err) {
console.error('Dodo webhook signature verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
// Idempotency — skip already-processed events
const alreadyProcessed = await checkEventProcessed(webhookId);
if (alreadyProcessed) {
return new Response('Already processed', { status: 200 });
}
try {
await handleEvent(event);
await markEventProcessed(webhookId);
} catch (err) {
// Log but still return 200 — otherwise Dodo retries endlessly
console.error(`Error processing Dodo event ${event.type}:`, err);
}
return new Response('OK', { status: 200 });
}
FastAPI / Python
from fastapi import Request, HTTPException
from standardwebhooks import Webhook
import os
@app.post("/api/webhooks/dodo")
async def dodo_webhook(request: Request):
body = await request.body() # raw bytes
webhook_id = request.headers.get("webhook-id")
webhook_timestamp = request.headers.get("webhook-timestamp")
webhook_signature = request.headers.get("webhook-signature")
if not all([webhook_id, webhook_timestamp, webhook_signature]):
raise HTTPException(status_code=400, detail="Missing headers")
raw_secret = os.environ["DODO_WEBHOOK_SECRET"]
# Strip whsec_ prefix
base64_secret = raw_secret[6:] if raw_secret.startswith("whsec_") else raw_secret
try:
wh = Webhook(base64_secret)
event = wh.verify(body, {
"webhook-id": webhook_id,
"webhook-timestamp": webhook_timestamp,
"webhook-signature": webhook_signature,
})
except Exception as e:
raise HTTPException(status_code=400, detail="Invalid signature")
if await event_already_processed(webhook_id):
return {"received": True}
try:
await handle_event(event)
await mark_event_processed(webhook_id)
except Exception as e:
print(f"Error processing {event.get('type')}: {e}")
# Still return 200
return {"received": True}
Next.js Proxy Pattern
If the frontend proxies to a separate backend, the Next.js route must forward the raw body and all three Standard Webhooks headers. Do not let Next.js parse the body:
// app/api/webhooks/dodo/route.ts (proxy version)
export async function POST(request: Request) {
const body = await request.text();
const headersToForward: Record<string, string> = {
'Content-Type': 'application/json',
};
for (const h of ['webhook-id', 'webhook-timestamp', 'webhook-signature']) {
const v = request.headers.get(h);
if (v) headersToForward[h] = v;
}
try {
const response = await fetch(`${process.env.BACKEND_URL}/api/webhooks/dodo`, {
method: 'POST',
headers: headersToForward,
body,
});
return new Response(await response.text(), { status: response.status });
} catch {
// Always return 200 to Dodo even if backend is unreachable
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
}
Phase 4: Event Router and Handlers
async function handleEvent(event: Record<string, any>) {
switch (event.type) {
case 'payment.succeeded':
return handlePaymentSucceeded(event.data);
case 'payment.failed':
return handlePaymentFailed(event.data);
case 'subscription.active':
return handleSubscriptionActivated(event.data);
case 'subscription.cancelled':
return handleSubscriptionCancelled(event.data);
case 'subscription.on_hold':
return handleSubscriptionOnHold(event.data);
default:
console.log(`Unhandled Dodo event type: ${event.type}`);
}
}
Generate handlers that match the actual user model in the codebase:
async function handlePaymentSucceeded(data: Record<string, any>) {
const userId = data.metadata?.userId;
if (!userId) {
console.error('No userId in Dodo payment metadata — cannot update user. Check checkout creation.');
return;
}
const creditsToAdd = parseInt(data.metadata?.credits || '100');
// Adapt this to match the detected ORM and user schema
await db.user.update({
where: { id: userId },
data: {
credits: { increment: creditsToAdd },
dodoCustomerId: data.customer?.id,
},
});
}
async function handleSubscriptionCancelled(data: Record<string, any>) {
const userId = data.metadata?.userId;
if (!userId) return;
await db.user.update({
where: { id: userId },
data: { plan: 'free' },
});
}
The userId comes from metadata you attach at checkout creation. If checkout is created without metadata: { userId }, the webhook has no way to find the user. Always verify this is set.
Phase 5: Idempotency Layer
Dodo retries events on non-200 responses and occasionally delivers duplicates. Process each event exactly once.
-- Add this table to your database
CREATE TABLE processed_webhooks (
webhook_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
async function checkEventProcessed(webhookId: string): Promise<boolean> {
const existing = await db.processedWebhook.findUnique({ where: { webhookId } });
return !!existing;
}
async function markEventProcessed(webhookId: string): Promise<void> {
await db.processedWebhook.create({ data: { webhookId } });
}
For Supabase without an ORM:
async function checkEventProcessed(webhookId: string) {
const { data } = await supabase
.from('processed_webhooks')
.select('webhook_id')
.eq('webhook_id', webhookId)
.maybeSingle();
return !!data;
}
Phase 6: Checkout Creation (the other half)
The webhook only works if checkout was created correctly. Check the checkout creation endpoint and ensure userId is in metadata:
// api/payments/create-checkout/route.ts
const checkout = await dodo.payments.create({
payment_link: true,
customer: { email: user.email },
product_cart: [{ product_id: process.env.DODO_PRODUCT_ID!, quantity: 1 }],
metadata: {
userId: user.id, // REQUIRED — webhook uses this to find the user
credits: '100', // how many credits to add on success
},
return_url: `${process.env.APP_URL}/checkout/success`,
});
return Response.json({ checkout_url: checkout.payment_link });
Phase 7: Local Testing
Use ngrok to expose your local server, then add the ngrok URL as a webhook endpoint in the Dodo dashboard (test mode):
ngrok http 3000
# Add https://your-ngrok-id.ngrok.io/api/webhooks/dodo in Dodo test dashboard
Trigger test events from the Dodo dashboard to verify the full flow locally before deploying.
Phase 8: Verify
Flow 1: Signature Verification
[ ] whsec_ prefix stripped before use
[ ] Raw body used — not parsed JSON
[ ] Invalid signatures return 400
[ ] Valid signatures proceed to processing
Flow 2: Payment Succeeded
[ ] userId present in event metadata
[ ] User credits updated in database
[ ] dodoCustomerId stored on user
[ ] Event marked as processed in processed_webhooks
Flow 3: Idempotency
[ ] Same event sent twice → processed only once
[ ] No double-credit on duplicate delivery
Flow 4: Error Handling
[ ] Processing error → logged, 200 returned anyway
[ ] Missing userId in metadata → logged, no crash
Flow 5: Subscription Events
[ ] subscription.cancelled → user plan set to free
[ ] subscription.on_hold → user access restricted
Important Notes
- The
whsec_prefix strip is not optional. Using the raw string fails silently — you won't get an error, verification just won't work. - Always return 200. Even on database errors, return 200. Log the error and investigate, but don't let Dodo retry the same payment event.
- Idempotency is not optional. Payment providers retry. Process each event exactly once.
- Keep checkout metadata minimal but complete.
userIdis non-negotiable. AddcreditsorplanIdif relevant to your model.
More from tushaarmehtaa/tushar-skills
ship-credits
Scaffold a complete credits/token metering system for any app — database schema, backend middleware, payment webhooks, frontend state, and UI components. Goes from zero to "users can buy and spend credits" in one session.
17ship-email
Scaffold transactional and campaign email infrastructure end-to-end — provider setup, templates, user segmentation, and admin send UI. Use when the user wants to add email to their app — welcome emails, notifications, re-engagement, or bulk campaigns. Triggers on requests like "add email", "set up Resend", "email campaigns", "transactional email", "send emails to users", "welcome email", "notification emails", or any mention of email sending in an app context.
6make-skill
Turn any workflow into a properly structured Claude Code skill — YAML frontmatter, phase-based instructions, real code blocks, and a verify checklist. Use when the user wants to package a repeated workflow, create a new skill, turn a process into a slash command, or publish to the skills directory. Triggers on requests like "make a skill", "create a skill", "turn this into a skill", "new skill for...", "package this as a skill", "build a skill", "I want to publish a skill", "help me write a skill", or any request to create a reusable Claude Code skill.
6mvp-spec
Turn a rough product idea into a structured MVP spec — problem statement, personas, core loop, feature split, data model, API routes, page list, and tech stack recommendation. Write this before touching any code. Triggers on requests like "spec this out", "MVP spec", "plan this product", "what should I build first", "scope this idea", "PRD", "product spec", "write a spec for...", "help me plan this", "what do I build in v1", "product requirements", or any request to structure a product idea before writing code.
6og-image
Set up dynamic Open Graph image generation and all required meta tags so links look professional when shared on Twitter/X, LinkedIn, Slack, or anywhere that renders link previews. Triggers on requests like "OG image", "open graph", "social preview", "link preview", "Twitter card", "meta tags for sharing", "my links look broken when I share them", or any mention of how links appear when shared on social media.
6segment-users
Read your database schema, generate behavioral user segments with exact queries, and recommend targeted actions per segment. Use when the user wants to understand their user base, find power users, identify churn risk, build email cohorts, or understand usage patterns. Triggers on requests like "segment users", "who are my power users", "find churned users", "user cohorts", "churn analysis", "inactive users", "behavioral segmentation", "who's about to leave", or any mention of grouping users by activity, usage, or lifecycle.
6