paddle-webhooks
Receive and verify Paddle webhooks in a Next.js Route Handler — signature verification, idempotency, retry semantics, and local testing.
How do I install this agent skill?
npx skills add https://developer.paddle.com --skill paddle-webhooksIs this agent skill safe to install?
No partner audit is available yet. Read the source before installing.
What does this agent skill do?
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
2xxwithin 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.eventIdon 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
301or302is 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 anexecutecall (note:notificationIdis 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: 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). Pagination is{ pagination: { hasMore }, data: [...] }with{ after: "<last_id>" }— not.next()/.hasMore. Chain multi-step workflows inside oneexecute; 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-setupif 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 # 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 destination
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 anexecuteto create the destination programmatically — skip the dashboard steps below. CamelCase resource, snake_case body. See conventions above.) - Click New destination.
- Set:
- Description:
Local dev(orProduction). - Type:
Webhook. - URL: your endpoint (e.g.
https://your-app.com/api/webhookor 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.
- Description:
- 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.
// 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);
}
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.
// 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:
- Verifies the HMAC signature in the
paddle-signatureheader 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
EventEntitywith 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:
// 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. The same event.eventId arrives on every retry,
* so every handler below MUST be idempotent — UPSERT keyed on the Paddle
* resource id, 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: idempotent UPSERT keyed on event.data.id. See subscription-sync.
// e.g. db.subscriptions.upsert({ where: { id: event.data.id }, update: {...}, create: {...} })
}
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 — 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; // 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, 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
# Use the https URL as your notification destination URL: https://abc123.ngrok.app/api/webhook
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
2xxon a failed verification. This is the one status mistake that loses events: Paddle considers only 2xx responses as delivered, so a2xxon a failedunmarshalmarks the event delivered and it's never retried. Every non-2xx —400,401,500,503— is retried on the same budget, so returning401on 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 is2xx. 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."
unmarshalthrows 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 useawait request.text()and pass the raw string tounmarshal(). - Wrong secret. Each notification destination has its own secret. Mixing the sandbox secret with a production destination (or vice versa) results in
unmarshalthrowing on every delivery.PADDLE_NOTIFICATION_WEBHOOK_SECRETmust match the destination you're targeting — and is not the same value asPADDLE_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.updatedcan arrive before the correspondingsubscription.createdif the first delivery is being retried. Useoccurred_atif 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.
- Forgetting to subscribe to the right events. A destination only sends what you've ticked in the dashboard. Adding a new handler in code without ticking the matching event will leave it silently never firing.
- Body parsing on Pages Router. Pages Router examples disabled
bodyParserto access the raw body. App Router Route Handlers don't need this —request.text()always returns raw bytes.
Verify the integration
- Add a
console.log(event.eventType, event.eventId)at the top ofprocessEvent. - 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.createdsimulation 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 undernotifications, not a top-level resource, andnotificationSettingIdis a positional path param.)
- Deliberately tamper with the secret in
.env.localand 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 resultingtransaction.completedandsubscription.createdevents arrive.
Related docs
- Webhooks overview
- Signature verification
- Notification destinations
- Respond to webhooks (retry semantics)
- Test webhooks with the simulator
- Subscription event reference
- Node SDK reference
- Reference implementation: paddle-nextjs-starter-kit — see
src/app/api/webhook/route.tsandsrc/utils/paddle/process-webhook.ts.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/developer.paddle.com/paddle-webhooks">View paddle-webhooks on skillZs</a>