| name | paddle-webhooks |
| description | Receive and verify Paddle webhooks in a Next.js Route Handler — signature verification, idempotency, retry semantics, and local testing. |
Receive Paddle webhooks securely in Next.js
When to use this skill
Use this skill when building the server-side endpoint that receives Paddle webhook events (subscription changes, transactions, customer updates, payouts, etc.). It covers creating the notification destination in Paddle, writing a Next.js Route Handler that verifies signatures and processes events, handling retries and idempotency, and local testing.
This skill is the foundation for almost every server-side Paddle integration. Pair with:
subscription-sync — turn webhook events into rows in your database.
sandbox-testing — drive your endpoint with the webhook simulator.
checkout-web — the client-side counterpart that triggers the events you'll receive here.
The delivery contract
Every design choice in this skill follows from these facts about how Paddle delivers webhooks. Read this section first; everything else is mechanics.
- Only
2xx within 5 seconds is "delivered." Any other response — 400, 401, 500, 503, a redirect, a connection timeout — is treated as a failed delivery and gets retried. There is no status code that means "stop retrying" on the integrator side. Paddle retries any non-2xx response.
- Retry schedule. Sandbox: 3 attempts over ~15 minutes. Live: 60 attempts over ~3 days, exponential backoff (~60s ×
attempt^1.1). Connection timeouts count toward the same budget as non-2xx responses.
- Same
event.eventId on every retry. Paddle re-sends the identical payload (modulo a fresh signature timestamp) until you 2xx or the retry budget is exhausted. That id is your dedup key.
- No redirect following. A
301 or 302 is treated as a failed delivery, not followed.
- After the retry budget is exhausted, the event is gone. You can replay manually from the dashboard's notification log, or via the Paddle MCP server with
client.notifications.replay(notificationId) inside an execute call (note: notificationId is a positional path param, not a body field). Plan for this when bringing an endpoint back from extended downtime — query the API for current state rather than waiting for events.
The Paddle MCP exposes three tools per server (search, execute, report_missing_tool). Workflow: call search to confirm the exact method name and parameter shapes, then call execute with an async function that calls client.<resource>.<operation>(...). Method paths are camelCase (client.clientTokens.create, client.pricingPreview.preview). Body params and response fields are snake_case (tax_category, product_id, unit_price, currency_code). Pagination is { pagination: { hasMore }, data: [...] } with { after: "<last_id>" } — not .next() / .hasMore. Chain multi-step workflows inside one execute; variables don't persist between calls. Hard caps: 50 API calls per execute, 30s timeout, 32KB code.
Prerequisites
You need:
- A Paddle account with a configured product and price (sandbox is fine for development) — see
catalog-setup if you don't have one yet.
- A publicly reachable URL for your dev environment if you want real Paddle events to hit local code (see "Local testing" below).
- Three environment variables:
NEXT_PUBLIC_PADDLE_ENV=sandbox
PADDLE_API_KEY=pdl_sdbx_apikey_...
PADDLE_NOTIFICATION_WEBHOOK_SECRET=pdl_ntfset_...
Install the server SDK:
npm install @paddle/paddle-node-sdk
Step 1: Create a notification destination
A notification destination tells Paddle "send these events to this URL." Each destination has its own secret — sandbox and production should be separate destinations with separate secrets, never shared.
- In the Paddle dashboard, go to Paddle > Developer tools > Notifications. (Or, if a Paddle MCP server is available to you, call
client.notificationSettings.create({ destination: "https://...", subscribed_events: [...], type: "url" }) inside an execute to create the destination programmatically — skip the dashboard steps below. CamelCase resource, snake_case body. See conventions above.)
- Click New destination.
- Set:
- Description:
Local dev (or Production).
- Type:
Webhook.
- URL: your endpoint (e.g.
https://your-app.com/api/webhook or your tunnelled local URL).
- Events: select the events you need. Start with
transaction.completed, subscription.created, subscription.updated, subscription.canceled, customer.created, customer.updated. You can subscribe to more later.
- Save and copy the secret key. You'll only see it once. This goes in
PADDLE_NOTIFICATION_WEBHOOK_SECRET.
Step 2: Create a Paddle SDK helper
Create a single instance of the Node SDK so you don't initialize it on every request.
import { Environment, LogLevel, Paddle, type PaddleOptions } from "@paddle/paddle-node-sdk";
export function getPaddleInstance() {
const options: PaddleOptions = {
environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as Environment) ?? Environment.sandbox,
logLevel: LogLevel.error,
};
if (!process.env.PADDLE_API_KEY) {
throw new Error("PADDLE_API_KEY is not set");
}
return new Paddle(process.env.PADDLE_API_KEY, options);
}
Step 3: Write the Route Handler
Two rules. Pre-validate inputs you can check cheaply (return 400). Wrap everything else in a single try/catch that returns a non-2xx on any throw — including signature failures — so Paddle retries. Any non-2xx works (this skill uses 500; 401 is equally valid); the only response that loses the event is a 2xx.
import { NextRequest } from "next/server";
import { getPaddleInstance } from "@/utils/paddle/get-paddle-instance";
import { processEvent } from "@/utils/paddle/process-webhook";
export async function POST(request: NextRequest) {
const signature = request.headers.get("paddle-signature") ?? "";
const rawBody = await request.text();
const secret = process.env.PADDLE_NOTIFICATION_WEBHOOK_SECRET ?? "";
if (!signature || !rawBody) {
return Response.json({ error: "Missing signature or body" }, { status: 400 });
}
try {
const paddle = getPaddleInstance();
const eventData = paddle..(rawBody, secret, signature);
(eventData) {
(eventData);
}
.({ : });
} (e) {
.(, e);
.({ : }, { : });
}
}
paddle.webhooks.unmarshal() does three things in one call:
- Verifies the HMAC signature in the
paddle-signature header against the raw body using your secret.
- Throws if the signature is invalid, the timestamp is too old, or the payload is malformed.
- Returns a typed
EventEntity with the deserialized payload.
Why a single catch returning one non-2xx, even for signature failures? A thrown unmarshal doesn't tell you why it threw — a tampered request, a wrong/rotated secret, an expired timestamp, and a malformed event all surface as the same generic error. Every non-2xx is retried on the same budget, so 401 and 500 are equally event-safe: a rotated secret recovers automatically once you redeploy, whichever you pick. Don't try to split them (401 for "forged", 500 for "transient") — you can't tell those cases apart, so choose one non-2xx and use it for the whole catch. The only choice that loses events is returning 2xx on a failure.
Step 4: Route the event to handlers
Keep the route handler thin. Move event-specific logic into a separate function and switch on eventType using the EventName enum so TypeScript narrows event.data correctly:
import {
type EventEntity,
EventName,
type SubscriptionCreatedEvent,
type SubscriptionUpdatedEvent,
type SubscriptionCanceledEvent,
type TransactionCompletedEvent,
type CustomerCreatedEvent,
type CustomerUpdatedEvent,
} from "@paddle/paddle-node-sdk";
export async function processEvent(event: EventEntity) {
switch (event.eventType) {
case EventName.SubscriptionCreated:
case EventName.SubscriptionUpdated:
case EventName.SubscriptionCanceled:
return handleSubscription(event);
case EventName.TransactionCompleted:
return handleTransactionCompleted(event);
.:
.:
(event);
:
;
}
}
() {
}
() {
}
() {
}
The Node SDK exports EventName (the string discriminator) and a typed event for each — TypeScript narrows event.data correctly when you switch on eventType. String literal comparisons like event.eventType === "subscription.created" work at runtime but lose narrowing and are fragile to spec changes.
Step 5: Make handlers idempotent
The default path: write handlers in an UPSERT shape keyed on the Paddle resource id. Then duplicate deliveries become idempotent for free, and you don't need any extra bookkeeping.
async function handleSubscription(
event: SubscriptionCreatedEvent | SubscriptionUpdatedEvent | SubscriptionCanceledEvent,
) {
await db.subscriptions.upsert({
where: { id: event.data.id },
update: {
status: event.data.status,
currentBillingPeriod: event.data.currentBillingPeriod,
},
create: {
},
});
}
Most webhook work fits this shape — subscription state, customer records, transaction records all have stable Paddle ids you can key on. The official starter kit (paddle-nextjs-starter-kit, see src/utils/paddle/process-webhook.ts) uses supabase.from("subscriptions").upsert({...}) exactly this way and has no explicit dedup ledger.
When UPSERT isn't enough: an event-id ledger
Some side effects aren't naturally idempotent — sending a receipt email, granting one-time credits, calling a third-party API that bills per request. For those, dedupe explicitly on event.eventId:
import { db } from "@/lib/db";
export async function processEvent(event: EventEntity) {
const seen = await db.processedWebhooks.findUnique({
where: { eventId: event.eventId },
});
if (seen) return;
await db.$transaction(async (tx) => {
await routeEvent(event, tx);
await tx.processedWebhooks.create({ data: { eventId: event.eventId } });
});
}
The minimal table:
CREATE TABLE processed_webhooks (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Use the ledger only when UPSERT-shaping doesn't cover the side effect. For most subscription/customer/transaction sync work, UPSERT is enough.
Step 6: Acknowledge fast — queue heavy work
The 5-second timeout is real. If your handler takes longer, Paddle treats it as a connection timeout, marks the delivery as timed out, and counts the attempt against your retry budget.
Pattern:
- Verify the signature.
- Record
event.eventId (or upsert the resource synchronously if it's cheap).
- Push anything heavier — sending emails, calling third-party APIs, fetching from
paddle.transactions.list(), generating PDFs — onto a background queue.
- Return
200.
Concretely: don't run paddle.transactions.list() inline inside a webhook handler. Don't send an email inline. Don't call Stripe or any other third-party from inside the route handler. Verify, queue, ack.
If you don't have a queue yet, a Vercel Queue, AWS SQS, or even a simple "insert into a pending_jobs table and process from a cron" is enough.
Local testing
Two patterns:
A. Tunnel a public URL to localhost. Use ngrok, cloudflared, or vercel dev --listen 0.0.0.0 then set up a tunnel.
ngrok http 3000
Real Paddle events from the sandbox will then hit your local route handler.
B. Use the webhook simulator. No tunnel needed for the simulator's "single event" mode targeted at a public URL, but for local testing the easiest flow is:
- Tunnel to localhost.
- In the dashboard, go to Paddle > Developer tools > Simulations.
- Pick an event type or a scenario (
subscription.created, transaction.paid).
- Paddle constructs a payload, signs it with your destination's secret, and POSTs it to your URL.
See sandbox-testing for the full sandbox + simulator workflow.
Common pitfalls
- Returning
2xx on a failed verification. This is the one status mistake that loses events: Paddle considers only 2xx responses as delivered, so a 2xx on a failed unmarshal marks the event delivered and it's never retried. Every non-2xx — 400, 401, 500, 503 — is retried on the same budget, so returning 401 on a signature failure does not lose events (a rotated secret still gets the full retry window to recover). The only status that stops retries from the integrator side is 2xx. If you have actual abuse to fend off, do it at the edge with rate limits, not inside the handler.
- Splitting the catch into "signature failure → 401" and "handler error → 500."
unmarshal throws indistinguishably for a tampered request, a wrong/rotated secret, an expired timestamp, and a malformed event, so the split is illusory — you can't actually tell which case you're in. Pick one non-2xx and use it for the whole catch. One catch, one status, one operational story.
- Parsing the body before verification. If you read JSON with
request.json(), then re-serialize to verify, the byte sequence won't match what Paddle signed. Always use await request.text() and pass the raw string to unmarshal().
- Wrong secret. Each notification destination has its own secret. Mixing the sandbox secret with a production destination (or vice versa) results in
unmarshal throwing on every delivery. PADDLE_NOTIFICATION_WEBHOOK_SECRET must match the destination you're targeting — and is not the same value as PADDLE_API_KEY.
- Slow handlers. 30 seconds of work in the route handler will time out at 5 seconds, count as a failed delivery, and burn a retry attempt. Verify, queue, ack.
- Treating webhooks as ordered. They aren't.
subscription.updated can arrive before the corresponding subscription.created if the first delivery is being retried. Use occurred_at if you need ordering, but the cleanest approach is to make handlers convergent — UPSERT to latest state.
- Missing events after extended downtime. If your endpoint is down past the 3-day retry window, those events are gone. On next deploy, query the API for current state instead of waiting for replays — or replay specific events from the notification log.
Verify the integration
- Add a
console.log(event.eventType, event.eventId) at the top of processEvent.
- Tunnel localhost (
ngrok http 3000) and update the destination URL to the tunnel.
- In the dashboard, go to Paddle > Developer tools > Simulations and run a
subscription.created simulation against your destination.
- Confirm:
- The route handler logs the event type.
- The dashboard shows a 200 response under Paddle > Developer tools > Notifications > [your destination] > Logs. (If a Paddle MCP server is available,
client.notifications.logs.list(notificationSettingId, { per_page: 50 }) returns the same — note the path is nested under notifications, not a top-level resource, and notificationSettingId is a positional path param.)
- Deliberately tamper with the secret in
.env.local and re-simulate. Confirm:
- The handler returns a non-2xx (this skill's handler returns 500).
- The dashboard log shows the failed delivery and a queued retry.
- Restore the correct secret afterwards — Paddle will retry the failed delivery and it should succeed.
- Trigger a real flow: complete a sandbox checkout (see
checkout-web) and confirm the resulting transaction.completed and subscription.created events arrive.
Related docs