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. Paddle retries on an exponential backoff schedule. Sandbox: 3 attempts within 15 minutes. Live: 60 attempts within 3 days. 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 ordering guarantee. A retry or replay can arrive after a newer event. Store
event.occurredAtand only apply an event that's newer than the last one you applied (Step 5). - No redirect following. A
301or302is treated as a failed delivery, not followed. - Failed notifications can be replayed for about 90 days. Once retries run out, the notification is marked
failed. Replay it from Paddle > Events > Notifications (open the destination, then Retry on the notification), or via the Paddle MCP server withclient.notifications.replay("ntf_…")inside anexecutecall (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 withGET /eventsand itsfrom/tofilters, 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.
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
Verifying 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-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 > Events > Notifications.
- Click New destination.
- Set:
- Notification type:
Webhook. - URL: your endpoint (e.g.
https://your-app.com/api/webhookor your tunnelled local URL). - Description:
Local dev(orProduction). - Usage type:
Bothfor a dev destination, so it receives real sandbox events and simulator events.Platform(real events only) suits production. - 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.
- Notification type:
- Save, then copy the destination's Secret key into
PADDLE_NOTIFICATION_WEBHOOK_SECRET. You can copy it again later from the destination's edit view, or read it asendpoint_secret_keyfromGET /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.
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. 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.
Step 5: Make handlers idempotent and order-safe
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.
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, the guarded 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. The simulator sends to a notification destination, so localhost still needs a tunnel:
- Tunnel to localhost and point a destination at it. Its Usage type must be
SimulationorBoth, and it must be subscribed to the events you simulate; otherwise the simulated event is markedabortedand not sent. - In the dashboard, go to Paddle > Events > Simulations.
- Pick a single event, named like the event type (
subscription.created,transaction.paid), or a scenario:subscription_creation,subscription_renewal,subscription_pause,subscription_resume,subscription_cancellation. - Paddle constructs a payload, signs it with your destination's secret, and POSTs it to your URL once. Simulated events aren't retried and don't appear in notification logs; check the simulation run's events instead, where you can replay them.
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, and a retried or replayed older event can land after a newer one. A plain UPSERT lets that older event overwrite newer state. Storeevent.occurredAtand only apply newer events (Step 5); dedupe onevent.eventIdfor side effects. - Missing events after extended downtime. Live retries stop after 3 days (sandbox: 15 minutes). After that, replay failed notifications (up to about 90 days old) from Paddle > Events > Notifications or with
client.notifications.replay, list missed events withGET /events(from/to), or query the API for current state. - Clock skew. The SDK rejects a signature whose
tsis more than 5 seconds old, so a server clock that runs ahead makes everyunmarshalthrow. Keep server clocks in sync (NTP). - IP allowlisting. If your firewall only admits known sources, allowlist the addresses from
GET /ips. Sandbox and live return different lists, so fetch each from its own API base URL. Still verify signatures. - 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. The destination's Usage type must beSimulationorBoth. - In the dashboard, go to Paddle > Events > Simulations and run a
subscription.createdsimulation against your destination. - Confirm:
- The route handler logs the event type.
- The simulation run's event shows a 200 response. Simulated events get one attempt and don't appear in notification logs. (If a Paddle MCP server is available,
client.simulations.runsEvents.list("ntfsim_…", "ntfsimrun_…")returns the run'sevents, each with its status, request and response.)
- Deliberately tamper with the secret in
.env.localand replay the simulated event. Confirm:- The handler returns a non-2xx (this skill's handler returns 500).
- The run's event shows the failed delivery. Simulated events aren't retried.
- Restore the correct secret, then replay the event again (in the dashboard, or with
client.simulations.runsEvents.replay(simulationId, runId, eventId)). It should succeed.
- Trigger a real flow: complete a sandbox checkout (see
checkout-web) and confirm the resultingtransaction.completedandsubscription.createdevents arrive. Real deliveries show under Paddle > Events > Notifications: open your destination, then View Logs on a notification. (With the Paddle MCP: find notification IDs withclient.notifications.list({ notification_setting_id: ["ntfset_…"] }), thenclient.notifications.logs.list("ntf_…", { per_page: 50 }), which returns{ pagination, logs }. Logs are nested undernotifications, and the notification ID is a positional path param.)
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>