CtrlK
BlogDocsLog inGet started
Tessl Logo

paddle-webhooks

Receive and verify Paddle webhooks in a Next.js Route Handler — signature verification, idempotency, retry semantics, and local testing.

86

1.28x
Quality

80%

Does it follow best practices?

Impact

98%

1.28x

Average score across 3 eval scenarios

SecuritybySnyk

Advisory

Suggest reviewing before use

Optimize this skill with Tessl

npx tessl skill review --optimize ./skills/webhooks/SKILL.md
SKILL.md
Quality
Evals
Security

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's notification-service hard-codes 200–299 as the success window; everything outside that triggers the retry path.
  • 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             # 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.

  1. 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.)
  2. Click New destination.
  3. 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.
  4. 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 500 on any throw — including signature failures.

// 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);
    // 500: Paddle treats any non-2xx as a retry. We want that here.
    // A thrown unmarshal could be a tampered request OR a rotated secret
    // that hasn't been redeployed — they're indistinguishable. Retrying
    // recovers the second case automatically; the first is harmless.
    return Response.json({ error: "Internal error" }, { status: 500 });
  }
}

paddle.webhooks.unmarshal() does three things in one call:

  1. Verifies the HMAC signature in the paddle-signature header against the raw body using your secret.
  2. Throws if the signature is invalid, the timestamp is too old, or the payload is malformed.
  3. Returns a typed EventEntity with the deserialized payload.

Why a single catch returning 500, even for signature failures? See the comment in the code: a thrown unmarshal doesn't tell you why it threw. If you split the catch and return 401 on signature failures, a routine secret rotation silently drops every event until someone notices. 500 + retry covers tampered, rotated, expired, and malformed alike.

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's notification-service treats it as a connection timeout, marks the delivery TIMED_OUT, and counts the attempt against your retry budget.

Pattern:

  1. Verify the signature.
  2. Record event.eventId (or upsert the resource synchronously if it's cheap).
  3. Push anything heavier — sending emails, calling third-party APIs, fetching from paddle.transactions.list(), generating PDFs — onto a background queue.
  4. 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:

  1. Tunnel to localhost.
  2. In the dashboard, go to Paddle > Developer tools > Simulations.
  3. Pick an event type or a scenario (subscription.created, transaction.paid).
  4. 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 anything but 2xx to "stop retries." Paddle's notification-service treats every non-2xx response as a failed delivery — including 401. There is no 4xx code that means "stop trying" from the integrator side. Returning 401 on a signature failure means a rotated secret silently drops events for 3 days. Always return 500 from the catch around unmarshal. 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" and "handler error." Sophisticated engineers reach for 401 on tampered requests and 500 on handler throws. Don't. unmarshal throws indistinguishably for a tampered request, a wrong/rotated secret, an expired timestamp, and a malformed event. One catch, one 500, 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.
  • 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 bodyParser to access the raw body. App Router Route Handlers don't need this — request.text() always returns raw bytes.

Verify the integration

  1. Add a console.log(event.eventType, event.eventId) at the top of processEvent.
  2. Tunnel localhost (ngrok http 3000) and update the destination URL to the tunnel.
  3. In the dashboard, go to Paddle > Developer tools > Simulations and run a subscription.created simulation against your destination.
  4. 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.)
  5. Deliberately tamper with the secret in .env.local and re-simulate. Confirm:
    • The 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.
  6. Trigger a real flow: complete a sandbox checkout (see checkout-web) and confirm the resulting transaction.completed and subscription.created events arrive.

Related docs

Repository
PaddleHQ/paddle-agent-skills
Last updated
Created

Is this your skill?

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.