webhook-integration — Claude Code skill for Dodo Payments webhooks
A skill that walks your agent through receiving Dodo Payments webhooks: verifying the signature on the raw body and skipping duplicate deliveries.
npx skills add dodopayments/skills --skill webhook-integration -a claude-codeWhat webhook-integration does
The skill covers the whole webhook path: creating an endpoint in the Dodo dashboard (Developer → Webhooks), picking event types, and storing the signing secret in DODO_PAYMENTS_WEBHOOK_KEY. It then breaks down the three headers that arrive with every request: webhook-id, webhook-signature, webhook-timestamp. Verification runs against the raw body, before JSON parsing, because the signature will not match otherwise.
Ready-made examples ship for TypeScript/Node, Python and Go. All of them lean on the SDK helper webhooks.unwrap, which handles the verification details. Idempotency gets its own section: the webhook-id header tells you when a delivery is a retry, and those should be skipped. Retries and local testing during development are covered too.
Watch the DODO_PAYMENTS_ENVIRONMENT variable. The examples narrow it to live_mode or test_mode and fall back to test mode. Pass the raw value through and client construction throws something like Unknown environment: None. Worth checking your config before the first deploy.
What it helps with
- stand up an endpoint for payment and subscription events
- verify a webhook signature against the raw request body
- drop duplicate deliveries using the
webhook-idheader - sync your database after
payment.succeeded - test webhooks locally before shipping
Example prompts for the agent
- set up Dodo Payments webhook handling in my Express app
- add signature verification and skip duplicates by
webhook-id - update the order status in my database when
payment.succeededarrives
How to install webhook-integration
1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill webhook-integration -a claude-code2Manually: put the files in place
~/.claude/skills/webhook-integration/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/webhook-integration/
1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill webhook-integration -a codex2Manually: put the files in place
.agents/skills/webhook-integration/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill webhook-integration -a cursor2Manually: put the files in place
.cursor/skills/webhook-integration/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install dodopayments/skills webhook-integration2Manually: put the files in place
.github/skills/webhook-integration/SKILL.md1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill webhook-integration -a gemini-cli2Platform CLI
gemini skills install https://github.com/dodopayments/skills --path dodo-payments/webhook-integrationManually: put the files in place
.devin/skills/webhook-integration/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add dodopayments/skills --skill webhook-integration -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about webhook-integration
How do I install webhook-integration in Claude?
Run npx skills add dodopayments/skills --skill webhook-integration -a claude-code. The skill lands in ~/.claude/skills/webhook-integration/SKILL.md.
What does webhook-integration do?
It covers receiving Dodo Payments webhooks: endpoint setup, signature verification, payment, subscription and refund events, idempotency, and local testing.
What do I need before using it?
A signing secret from the dashboard in DODO_PAYMENTS_WEBHOOK_KEY and an API key in DODO_PAYMENTS_API_KEY. The examples use the Dodo Payments SDK for TypeScript/Node, Python or Go.
Source text
SKILL.md · MIT
---
name: webhook-integration
description: Dodo Payments webhook handling, covering endpoint setup, Standard Webhooks signature verification on the raw body, payment, subscription, and refund event types, idempotency with webhook-id, retries, and local testing. Use when receiving Dodo events, verifying webhook-signature headers, or syncing your database from payment.succeeded or subscription events.
---
# Dodo Payments Webhook Integration
Webhooks deliver real-time notifications when payment events occur. Use them to automate workflows, update databases, send confirmations, and keep your systems in sync.
## When to use this skill
- Setting up a webhook endpoint to receive payment, subscription, or refund events
- Implementing signature verification to ensure webhook authenticity
- Handling specific event types (payment succeeded, subscription renewed, etc.)
- Testing webhooks locally during development
- Ensuring idempotent webhook processing to handle retries
---
## Core Concepts
**Webhook**: An HTTP POST request sent by Dodo to your endpoint when an event occurs.
**Signature verification**: Cryptographic proof that a webhook came from Dodo, not an attacker. Required for production.
**Idempotency**: Processing the same webhook multiple times produces the same result. Use the `webhook-id` header to detect and skip duplicates.
**Raw body**: The exact bytes received from Dodo, before parsing. Required for signature verification.
---
## Setup: Creating a Webhook Endpoint
### 1. Create the endpoint in the dashboard
1. Go to **Developer → Webhooks**
2. Click **Create Webhook**
3. Enter your endpoint URL (must be HTTPS in production)
4. Select the events you want to receive
5. Copy the signing secret
### 2. Store the signing secret
```bash
export DODO_PAYMENTS_WEBHOOK_KEY=whsec_...
```
The SDK reads this automatically. You can also pass it explicitly when initializing the client.
---
## Webhook Headers and Payload
Every webhook request includes three required headers (all lowercase, hyphenated):
| Header | Example | Purpose |
|--------|---------|---------|
| `webhook-id` | `evt_abc123` | Unique identifier for this webhook delivery |
| `webhook-signature` | `v1,base64_signature_here` | HMAC-SHA256 signature for verification |
| `webhook-timestamp` | `1704067200` | Unix timestamp (seconds) when the event was sent |
The request body is JSON:
```json
{
"business_id": "bus_xxxxx",
"type": "payment.succeeded",
"timestamp": "2024-01-01T12:00:00Z",
"data": {
"payload_type": "Payment",
"payment_id": "pay_xxxxx",
"status": "succeeded",
"total_amount": 2999,
"currency": "USD",
"customer": {
"customer_id": "cus_xxxxx",
"email": "[email protected]",
"name": "John Doe"
}
}
}
```
---
## Verification: The Centerpiece
**Always verify the signature before processing.** Unverified webhooks can be spoofed.
### Preferred: SDK helper
The simplest and safest approach. The SDK handles all verification details.
**TypeScript/Node:**
```typescript
import DodoPayments from 'dodopayments';
import express from 'express';
const app = express();
app.use(express.raw({ type: 'application/json' }));
// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});
app.post('/webhook', async (req, res) => {
try {
const unwrapped = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
// Signature verified. Process the event.
console.log(`Received ${unwrapped.type}`);
res.json({ received: true });
} catch (error) {
res.status(401).json({ error: 'Invalid signature' });
}
});
```
**Python:**
```python
from typing import Literal
from fastapi import FastAPI, Request, HTTPException
from dodopayments import DodoPayments
import os
# `environment` is Literal["live_mode", "test_mode"], not str. Passing the raw
# variable through raises at construction: unset gives
# `ValueError: Unknown environment: None`, and a typo like "test" gives
# `Unknown environment: test`. Narrow it, defaulting to test mode so a
# misconfigured variable can never select live.
ENVIRONMENT: Literal["live_mode", "test_mode"] = (
"live_mode" if os.getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" else "test_mode"
)
app = FastAPI()
client = DodoPayments(
bearer_token=os.getenv("DODO_PAYMENTS_API_KEY"),
environment=ENVIRONMENT,
webhook_key=os.getenv("DODO_PAYMENTS_WEBHOOK_KEY"),
)
@app.post("/webhook")
async def handle_webhook(request: Request):
try:
unwrapped = client.webhooks.unwrap(
await request.body(),
headers={
"webhook-id": request.headers.get("webhook-id", ""),
"webhook-signature": request.headers.get("webhook-signature", ""),
"webhook-timestamp": request.headers.get("webhook-timestamp", ""),
},
)
# Signature verified. Process the event.
return {"received": True}
except Exception:
raise HTTPException(status_code=401, detail="Invalid signature")
```
**Go:**
```go
import (
"io"
"net/http"
"os"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
// The Go SDK has no WithEnvironment(string). It exposes two explicit options,
// so narrow here and default to test mode: an unset or misspelled variable must
// never select live mode.
func dodoEnvironment() option.RequestOption {
if os.Getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" {
return option.WithEnvironmentLiveMode()
}
return option.WithEnvironmentTestMode()
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
dodoEnvironment(),
option.WithWebhookKey(os.Getenv("DODO_PAYMENTS_WEBHOOK_KEY")),
)
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusBadRequest)
return
}
// Unwrap takes the raw body and the request headers directly. It is not
// context-aware and does not take a map: the signature is
// Unwrap(payload []byte, headers http.Header, opts ...option.RequestOption).
if _, err := client.Webhooks.Unwrap(rawBody, r.Header); err != nil {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Signature verified. Process the event.
w.WriteHeader(http.StatusOK)
}
```
### Alternative: `standardwebhooks` package
If you prefer manual verification or don't use the Dodo SDK:
```typescript
import { Webhook } from "standardwebhooks";
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
const webhook = new Webhook(process.env.DODO_PAYMENTS_WEBHOOK_KEY);
app.post("/webhooks/dodo", async (req, res) => {
try {
const payload = req.body.toString();
await webhook.verify(payload, req.headers);
const event = JSON.parse(payload);
console.log(`Received ${event.type}`);
res.json({ received: true });
} catch (error) {
res.status(401).json({ error: "Invalid signature" });
}
});
```
### `unwrap()` vs `unsafeUnwrap()`
- `unwrap()` verifies the signature. Use this for all production webhooks.
- `unsafeUnwrap()` skips verification. Use only for unsigned test payloads from `dodo wh trigger`.
---
## Raw Body Requirement
Signature verification requires the exact bytes Dodo sent. If you parse the JSON first and then re-serialize it, the bytes change and verification fails.
**Framework-specific setup:**
| Framework | Raw body setup |
|-----------|---|
| **Express** | `app.use(express.raw({ type: 'application/json' }))` |
| **Next.js** | `req.text()` in route handlers; no middleware needed |
| **Fastify** | Custom content-type parser (see adaptor docs) |
| **Hono** | Built-in; no special setup |
| **FastAPI** | `await request.body()` returns bytes |
| **Go** | `io.ReadAll(r.Body)` |
---
## Webhook Event Catalog
Full guide: [references/event-catalog.md](references/event-catalog.md).
Covers:
- Payment events
- Subscription events
- Refund events
- Dispute events
- License key events
- Entitlement grant events
- Credit events
- Recovery and dunning events
- Payout events
## Production-Grade Handler Pattern
Respond quickly after durably recording the event, process asynchronously, and use idempotency keys. In the worker, insert the idempotency claim and apply all durable business changes in one database transaction. If processing throws, the transaction rolls back the claim so the job can retry safely:
```typescript
import DodoPayments from 'dodopayments';
import express from 'express';
import { Queue, Worker } from 'bullmq';
// BullMQ is illustrative; use your preferred durable async queue.
const app = express();
app.use(express.raw({ type: 'application/json' }));
// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});
type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
type WebhookJob = { event: WebhookEvent; webhookId: string };
const connection = {
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? '6379'),
};
const eventQueue = new Queue<WebhookJob>('webhook-events', { connection });
app.post('/webhook', async (req, res) => {
let unwrapped: WebhookEvent;
try {
unwrapped = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
} catch (error) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Durably enqueue before acknowledging, keyed by webhook-id for idempotency
const webhookId = req.headers['webhook-id'] as string;
try {
await eventQueue.add(
`process-${unwrapped.type}`,
{ event: unwrapped, webhookId },
{
jobId: webhookId, // Prevents duplicate queue entries
attempts: 8,
backoff: { type: 'exponential', delay: 1000 },
}
);
return res.json({ received: true });
} catch (error) {
console.error('Failed to persist webhook', error);
return res.status(503).json({ error: 'Webhook persistence failed' });
}
});
// Async worker. webhookLog.webhookId must have a unique constraint.
const worker = new Worker<WebhookJob>(
'webhook-events',
async (job) => {
const { event, webhookId } = job.data;
await db.$transaction(async (tx) => {
const claim = await tx.webhookLog.createMany({
data: [{ webhookId, eventType: event.type }],
skipDuplicates: true,
});
if (claim.count === 0) return;
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSucceeded(event.data, tx);
break;
case 'subscription.active':
await handleSubscriptionActive(event.data, tx);
break;
// ... handle other events
}
});
},
{ connection }
);
```
The handlers above must perform entitlement writes through `tx`. Queue emails or other external work through a transactional outbox; a database transaction cannot roll back an already-sent external request.
---
## Delivery Semantics
Understand how Dodo delivers webhooks:
| Property | Behavior |
|----------|----------|
| **Timeout** | 30 seconds for connection and read |
| **Success** | Any `2xx` response acknowledges delivery. Return `200` immediately after durably recording the event. |
| **Failure** | Any non-`2xx` response triggers a retry. |
| **Retries** | Eight attempts: immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h |
| **Idempotency** | Use `webhook-id` to detect and skip duplicates |
| **Ordering** | No guarantee. Events can arrive out of order. |
| **Payload freshness** | Delivery contains the latest resource state at delivery time |
| **Transport** | Use HTTPS in production |
---
## Framework Adaptor Shortcuts
If you use a supported framework, use the official adaptor package for built-in webhook handling:
| Framework | Package | Webhook handler |
|-----------|---------|---|
| Next.js | `@dodopayments/nextjs` | `Webhooks({ webhookKey, onPayload })` |
| Nuxt | `@dodopayments/nuxt` | `Webhooks({ webhookKey, onPayload })` |
| Express | `@dodopayments/express` | `Webhooks({ webhookKey, onPayload })` - register `express.json()` first, **not** `express.raw()` (it verifies against the parsed `req.body`) |
| Fastify | `@dodopayments/fastify` | `Webhooks({ webhookKey, onPayload })` |
| Hono | `@dodopayments/hono` | `Webhooks({ webhookKey, onPayload })` |
| Astro | `@dodopayments/astro` | `Webhooks({ webhookKey, onPayload })` |
| SvelteKit | `@dodopayments/sveltekit` | `Webhooks({ webhookKey, onPayload })` |
| Remix | `@dodopayments/remix` | `Webhooks({ webhookKey, onPayload })` |
| TanStack Start | `@dodopayments/tanstack` | `Webhooks({ webhookKey, onPayload })` |
| Bun | `@dodopayments/bun` | `Webhooks({ webhookKey, onPayload })` |
| Better Auth | `@dodopayments/better-auth` | Plugin with `webhooks({ webhookKey, onPayload })` |
| Convex | `@dodopayments/convex` | Component with verified HTTP handler |
**Next.js example:**
The adapter callback does not expose `webhook-id`, so this payment example uses `payment_id` as its stable key and commits the claim and durable fulfillment together. Use a handler that exposes `webhook-id` for event types without a verified stable identifier.
```typescript
// app/api/webhook/dodo-payments/route.ts
import { Webhooks } from "@dodopayments/nextjs";
export const POST = Webhooks({
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
onPayload: async (payload) => {
// payload is already verified
console.log(`Received ${payload.type}`);
if (payload.type !== 'payment.succeeded') return;
// webhookLog.webhookId must have a unique constraint. If fulfillment
// throws, the claim rolls back and Dodo's redelivery can retry it.
await db.$transaction(async (tx) => {
const claim = await tx.webhookLog.createMany({
data: [{
webhookId: payload.data.payment_id,
eventType: payload.type,
}],
skipDuplicates: true,
});
if (claim.count === 0) return;
await handlePaymentSucceeded(payload.data, tx);
});
},
});
```
Handle `subscription.active` only in a handler that exposes `webhook-id`, then commit that claim and the entitlement changes in the same transaction.
---
## Local Testing
Full guide: [references/local-testing.md](references/local-testing.md).
Covers:
- Dashboard test tool
- CLI: Live forwarding
- CLI: Unsigned mock events
- Tunnel
## Common Mistakes
Full guide: [references/common-mistakes.md](references/common-mistakes.md).
Covers:
- Signing anything other than `webhook-id.webhook-timestamp.raw_body`
- Re-serializing the parsed body
- Naive comma-splitting of the signature header
- `timingSafeEqual` throwing on length mismatch
- Granting access from `return_url` instead of verified webhooks
- Doing slow work before responding 2xx
- Acknowledging before durable persistence
- Committing an idempotency claim before fulfillment
## Resources
- [Webhook Documentation](https://docs.dodopayments.com/developer-resources/webhooks)
- [Event Catalog](https://docs.dodopayments.com/developer-resources/webhooks/intents/webhook-events-guide)
- [Standard Webhooks Spec](https://standardwebhooks.com/)
- [Framework Adaptors](https://docs.dodopayments.com/developer-resources/framework-adaptors)
- [CLI Reference](https://docs.dodopayments.com/developer-resources/sdks/cli)
© dodopayments · MIT · dodopayments/skills