Receive and verify Paddle webhooks in a Next.js Route Handler — signature verification, idempotency, retry semantics, and local testing.
71
82%
Does it follow best practices?
Impact
100%
1.20x1 of 3 eval scenarios. Add 2 more for a full score.
Medium
Suggest reviewing before use
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.Every design choice in this skill follows from these facts about how Paddle delivers webhooks. Read this section first; everything else is mechanics.
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.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.event.occurredAt and only apply an event that's newer than the last one you applied (Step 5).301 or 302 is treated as a failed delivery, not followed.failed. Replay it from Paddle > Events > Notifications (open the destination, then Retry on the notification), or via the Paddle MCP server with client.notifications.replay("ntf_…") inside an execute call (the notification ID is a positional path param, not a body field). A replay creates a new notification ID for the same event ID and gets one attempt. Events are kept for 90 days: after extended downtime, list what you missed with GET /events and its from/to filters, or query the API for current state.The Paddle MCP exposes three tools per server (
search,execute,report_missing_tool). Workflow: callsearchto confirm the exact method name and parameter shapes, then callexecutewith an async function that callsclient.<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). List methods return{ pagination: { hasMore, estimatedTotal }, <resource>: [...] }, notdata. The array's key is the resource's API path segment, so it's kebab-case where the method path is camelCase:(await client.products.list()).products,(await client.clientTokens.list())["client-tokens"]. Page with{ after: "<last_id>" }whilepagination.hasMoreis true, not.next(). Lists that aren't paginated, likeclient.currencies.list(), return a plain array. Single-entity methods return the entity. Chain multi-step workflows inside oneexecute; variables don't persist between calls. Hard caps: 50 API calls per execute, 30s timeout, and 32,000 characters of code.
You need:
catalog-setup if you don't have one yet.NEXT_PUBLIC_PADDLE_ENV=sandbox # or "production"
PADDLE_API_KEY=pdl_sdbx_apikey_... # server-side; never expose to the browser
PADDLE_NOTIFICATION_WEBHOOK_SECRET=pdl_ntfset_... # secret for THIS notification destinationVerifying webhooks doesn't call the API, so the route handler needs no API key permissions. Managing webhooks through the API or the Paddle MCP does: notification_setting.write to create or update destinations (notification_setting.read to list them or read a secret), notification.read for notifications, logs and events, notification.write to replay, and notification_simulation.write to run simulations (notification_simulation.read to check results). Create API keys under Paddle > My account > Settings > Authentication.
Install the server SDK:
npm install @paddle/paddle-node-sdkA 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.
Webhook.https://your-app.com/api/webhook or your tunnelled local URL).Local dev (or Production).Both for a dev destination, so it receives real sandbox events and simulator events. Platform (real events only) suits production.transaction.completed, subscription.created, subscription.updated, subscription.canceled, customer.created, customer.updated. You can subscribe to more later.PADDLE_NOTIFICATION_WEBHOOK_SECRET. You can copy it again later from the destination's edit view, or read it as endpoint_secret_key from GET /notification-settings/{id}.When your tunnel URL changes, edit the existing destination's URL rather than creating a new destination, so the secret stays the same.
If a Paddle MCP server is available to you, create the destination inside an execute call instead of using the dashboard (camelCase resource, snake_case body; see conventions above):
async (client) =>
client.notificationSettings.create({
description: "Local dev", // required
destination: "https://abc123.ngrok.app/api/webhook",
type: "url",
subscribed_events: ["transaction.completed", "subscription.created", "subscription.updated"],
traffic_source: "all", // also receive simulator events; defaults to "platform"
});The response includes endpoint_secret_key. A 409 notification_setting_cannot_be_duplicate means a destination with that URL already exists: find it in (await client.notificationSettings.list())["notification-settings"] and reuse it, or change it with client.notificationSettings.update("ntfset_…", { ... }), instead of creating another.
Create a single instance of the Node SDK so you don't initialize it on every request.
// utils/paddle/get-paddle-instance.ts
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);
}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.
// app/api/webhook/route.ts
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 ?? "";
// Pre-validation: a request with no signature header or empty body
// can't be verified or processed. 400 is fine here — Paddle will still
// retry, but that's okay; this is a "you sent us nothing" case.
if (!signature || !rawBody) {
return Response.json({ error: "Missing signature or body" }, { status: 400 });
}
try {
const paddle = getPaddleInstance();
// Throws on signature mismatch, expired timestamp, or malformed event.
const eventData = await paddle.webhooks.unmarshal(rawBody, secret, signature);
if (eventData) {
await processEvent(eventData);
}
// Acknowledge fast. Heavy work belongs in a queue (see Step 6).
return Response.json({ received: true });
} catch (e) {
console.error("Webhook error:", e);
// Any non-2xx tells Paddle to retry — 401 and 500 are equally fine here;
// only a 2xx would mark the event delivered and lose it. A thrown
// unmarshal could be a tampered request OR a rotated secret that hasn't
// been redeployed — they're indistinguishable, so retrying recovers the
// second case automatically and the first is harmless. We return 500.
return Response.json({ error: "Internal error" }, { status: 500 });
}
}paddle.webhooks.unmarshal() does three things in one call:
paddle-signature header against the raw body using your secret.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.
Keep the route handler thin. Move event-specific logic into a separate function and switch on eventType using the EventName enum. TypeScript narrows event.data in each case:
// utils/paddle/process-webhook.ts
import {
type EventEntity,
EventName,
type SubscriptionCreatedEvent,
type SubscriptionUpdatedEvent,
type SubscriptionCanceledEvent,
type TransactionCompletedEvent,
type CustomerCreatedEvent,
type CustomerUpdatedEvent,
} from "@paddle/paddle-node-sdk";
/**
* Paddle delivers at-least-once and in no guaranteed order. The same
* event.eventId arrives on every retry, so every handler below MUST be
* idempotent: UPSERT keyed on the Paddle resource id and guarded by
* event.occurredAt (Step 5), or dedupe on event.eventId before non-DB
* side effects.
*/
export async function processEvent(event: EventEntity) {
// Idempotency: handlers may run multiple times for the same event.eventId.
switch (event.eventType) {
case EventName.SubscriptionCreated:
case EventName.SubscriptionUpdated:
case EventName.SubscriptionCanceled:
return handleSubscription(event);
case EventName.TransactionCompleted:
return handleTransactionCompleted(event);
case EventName.CustomerCreated:
case EventName.CustomerUpdated:
return handleCustomer(event);
default:
// Subscribed to events you don't handle yet? No-op. Better than throwing.
return;
}
}
async function handleSubscription(
event: SubscriptionCreatedEvent | SubscriptionUpdatedEvent | SubscriptionCanceledEvent,
) {
// TODO: UPSERT keyed on event.data.id, applied only if event.occurredAt is
// newer than the stored value. See Step 5 and subscription-sync.
}
async function handleTransactionCompleted(event: TransactionCompletedEvent) {
// TODO: idempotent UPSERT keyed on event.data.id, OR dedupe on event.eventId
// before non-DB side effects (sending receipts, granting credits).
}
async function handleCustomer(event: CustomerCreatedEvent | CustomerUpdatedEvent) {
// TODO: idempotent UPSERT keyed on event.data.id.
}The Node SDK exports EventName (the string discriminator) and a typed event for each, so TypeScript narrows event.data when you switch on eventType. String literal comparisons like event.eventType === "subscription.created" narrow too; EventName is easier to read and safer to refactor.
The default path: write handlers in an UPSERT shape keyed on the Paddle resource id, and store the event's occurredAt with the row. Paddle doesn't guarantee delivery order, so a retried or replayed older event can arrive after a newer one; only apply an event when its occurredAt is newer than the stored value. Do the check and the write in one atomic statement so concurrent deliveries can't interleave:
async function handleSubscription(
event: SubscriptionCreatedEvent | SubscriptionUpdatedEvent | SubscriptionCanceledEvent,
) {
// Insert, or update only if this event is newer than the last one applied.
await db.query(
`INSERT INTO subscriptions (id, status, next_billed_at, last_event_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
next_billed_at = EXCLUDED.next_billed_at,
last_event_at = EXCLUDED.last_event_at
WHERE subscriptions.last_event_at IS NULL
OR subscriptions.last_event_at < EXCLUDED.last_event_at`,
[event.data.id, event.data.status, event.data.nextBilledAt, event.occurredAt],
);
}last_event_at is a TIMESTAMPTZ column; event.occurredAt is an ISO 8601 string. Compare against when the event happened, not when you received it. Duplicate and late deliveries then change nothing, with no extra bookkeeping. Most webhook work fits this shape — subscription state, customer records, transaction records all have stable Paddle ids you can key on. See subscription-sync for a Supabase version.
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; // Already handled — return 200 from the route.
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, the guarded UPSERT is enough.
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:
event.eventId (or upsert the resource synchronously if it's cheap).paddle.transactions.list(), generating PDFs — onto a background queue.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.
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
# Use the https URL as your notification destination URL: https://abc123.ngrok.app/api/webhookReal Paddle events from the sandbox will then hit your local route handler.
B. Use the webhook simulator. The simulator sends to a notification destination, so localhost still needs a tunnel:
Simulation or Both, and it must be subscribed to the events you simulate; otherwise the simulated event is marked aborted and not sent.subscription.created, transaction.paid), or a scenario: subscription_creation, subscription_renewal, subscription_pause, subscription_resume, subscription_cancellation.See sandbox-testing for the full sandbox + simulator workflow.
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.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.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().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.subscription.updated can arrive before the corresponding subscription.created if the first delivery is being retried, and a retried or replayed older event can land after a newer one. A plain UPSERT lets that older event overwrite newer state. Store event.occurredAt and only apply newer events (Step 5); dedupe on event.eventId for side effects.client.notifications.replay, list missed events with GET /events (from/to), or query the API for current state.ts is more than 5 seconds old, so a server clock that runs ahead makes every unmarshal throw. Keep server clocks in sync (NTP).GET /ips. Sandbox and live return different lists, so fetch each from its own API base URL. Still verify signatures.bodyParser to access the raw body. App Router Route Handlers don't need this — request.text() always returns raw bytes.console.log(event.eventType, event.eventId) at the top of processEvent.ngrok http 3000) and update the destination URL to the tunnel. The destination's Usage type must be Simulation or Both.subscription.created simulation against your destination.client.simulations.runsEvents.list("ntfsim_…", "ntfsimrun_…") returns the run's events, each with its status, request and response.).env.local and replay the simulated event. Confirm:
client.simulations.runsEvents.replay(simulationId, runId, eventId)). It should succeed.checkout-web) and confirm the resulting transaction.completed and subscription.created events arrive. Real deliveries show under Paddle > Events > Notifications: open your destination, then View Logs on a notification. (With the Paddle MCP: find notification IDs with client.notifications.list({ notification_setting_id: ["ntfset_…"] }), then client.notifications.logs.list("ntf_…", { per_page: 50 }), which returns { pagination, logs }. Logs are nested under notifications, and the notification ID is a positional path param.)src/app/api/webhook/route.ts and src/utils/paddle/process-webhook.ts.PaddleHQ/paddle-agent-skills
70cb70d
Also appears in
since Oct 8, 2026
since Sep 25, 2026
since Sep 13, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.