# Handling the subscription lifecycle with Paddle webhooks

[Michael McGovern](https://www.linkedin.com/in/heymcgovern/), Staff Developer Advocate at [Paddle](https://www.paddle.com/), showed us how Paddle's webhooks carry a subscription through its lifecycle, from checkout through renewals, failed payments, pauses, and cancellation, and how your app turns them into access decisions. The example app is Paddle's [Next.js starter kit](https://github.com/PaddleHQ/paddle-nextjs-starter-kit), a SaaS template with a pricing page, Paddle Checkout, and a billing screen, using Supabase for auth and data. Its webhook handler stores each subscription's status and plan in Supabase for access checks to read.

This is episode 4 of Webhooks by Example, where we talk through how webhooks are used on a real platform. The first three covered the consumer side, with [Knock ingesting Orb's usage webhooks to send upgrade nudges](/blog/knock-orb-usage-based-upgrade-nudges), the producer side, with [GitLab webhooks driving a virtual pet for repo health](/blog/gitlab-webhooks-virtual-pet-repo-health), and a job-completion callback, with [Apify webhooks telling apps and agents when scraped data is ready](/blog/apify-webhooks-whole-web-apps-agents).

Paddle is a billing provider sending webhooks to the businesses that sell through it. If your app processes these webhooks out of order, handles one twice, or misses some, its record of a subscription drifts from Paddle's. A customer whose card was declined keeps using the product, or a customer who just renewed gets locked out.

<iframe src="https://www.youtube.com/embed/pJCnXPlqUJg" title="Handling the subscription lifecycle with Paddle webhooks | Webhooks by Example #4" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

## Paddle as merchant of record, and the events nobody is present for

Paddle is a merchant of record for SaaS, AI, mobile app, and digital product companies. As the seller of record, Paddle handles the parts of a billing stack around payments: subscription billing, metrics, retention, dunning (recovering failed payments), and tax and compliance.

For a subscription business, the checkout is the only moment the customer is present. Everything after it happens on Paddle's schedule.

Michael McGovern, Staff Developer Advocate @ Paddle:

> If you think about a checkout, I'm present there and I'm signed up for a subscription. But when my subscription renews, or when I cancel my subscription at the end of the billing period, or when I accept an upgrade offer, that all happens from the Paddle side and pushes to your app. And your app needs to respond to that. If my subscription cancels because my card declines, you need to be able to stop my access.

## Paddle's CRUD events, convenience events, and full-entity payloads

Every entity in the Paddle API has a corresponding set of webhooks, mostly created and updated events: `customer.created`, `address.updated`, `transaction.created`, and so on. [Paddle's webhook docs](https://developer.paddle.com/webhooks) list them by entity.

Paddle also sends dedicated events for some state changes, so an integration that only cares about one change doesn't have to inspect every `subscription.updated`. Michael calls them convenience webhooks. When a subscription cancels, Paddle sends `subscription.updated` and also `subscription.canceled`, so "if you wanna plug that into like a Slack workflow or something that pings every time a subscription cancels, you don't need to do any processing on the updated event." `subscription.past_due`, `subscription.paused`, `transaction.paid`, and `transaction.past_due` work the same way. Paddle's docs describe `subscription.updated` as the catch-all, covering "renewals, upgrades, downgrades, pauses, resumes, and cancellations."

Each notification, Paddle's name for a webhook delivery, carries the whole entity. The `data` object is a snapshot of it at the time of the event, so a `subscription.created` webhook contains the subscription itself rather than an ID to fetch. That's the opposite of the [ID-only payload in the Apify episode](/blog/apify-webhooks-whole-web-apps-agents#what-the-run-succeeded-payload-contains). Webhooks leave out related entities. As Michael described it, the API can include a transaction's customer when you fetch the transaction, but webhooks don't. The customer arrives in its own `customer.created` or `customer.updated` event, as the checkout sequence below shows.

## One checkout sends a dozen or so webhooks

Paddle's [how webhooks work](https://developer.paddle.com/webhooks/about/how-webhooks-work) page documents a typical sequence for a subscription checkout:

1. Opening the checkout creates a transaction: `transaction.created`.
2. Entering an email and address creates the customer and address: `customer.created`, `address.created`, plus `business.created` if the customer enters business details.
3. Paying marks the transaction paid, and Paddle creates a subscription for recurring items: `transaction.paid`, then `subscription.created`. Michael added that the subscription starts as `trialing` if the price has a trial, and `active` otherwise.
4. The transaction finishes: `transaction.completed`.

Paddle's webhook simulator models the same checkout as 13 deliveries; a real one varies with what the customer enters and whether the price has a trial. Around those steps, the transaction gets four `transaction.updated` events and a `transaction.ready` as it gains a customer, an address, and a payment, the new subscription gets `subscription.activated`, and `payment_method.saved` comes last. Here's where they go in an app that stores access state from webhooks, as the starter kit does:

```mermaid
sequenceDiagram
  actor Customer
  participant Checkout as Paddle Checkout (Paddle.js)
  participant Paddle
  participant Hook as Your webhook endpoint
  participant DB as Your database
  participant App as Your app's access check
  Customer->>Checkout: Opens checkout
  Checkout->>Paddle: Create transaction
  Paddle-->>Hook: transaction.created
  Customer->>Checkout: Enters email and address
  Checkout->>Paddle: Add customer and address
  Paddle-->>Hook: customer.created
  Hook->>DB: Store customer
  Customer->>Checkout: Pays
  Checkout->>Paddle: Submit payment details
  Paddle->>Paddle: Charge payment, create subscription
  Paddle-->>Checkout: Payment confirmed
  Checkout-->>Customer: checkout.completed in the browser
  Paddle-->>Hook: transaction.paid
  Paddle-->>Hook: subscription.created
  Hook->>DB: Store subscription status and plan
  Paddle-->>Hook: transaction.completed
  Customer->>App: Opens the app
  App->>DB: Read status and product ID
  Note over Paddle,Hook: Renewals and cancellations arrive later as subscription.updated

```

Most of those events are for systems other than your app.

Michael McGovern, Staff Developer Advocate @ Paddle:

> If you are building an integration like this, you probably would just listen to subscription created and updated. But you might integrate Paddle with a CRM solution or Slack, or have some sort of BI tooling where those webhooks would be useful. So you might want to track how many transactions were created or opened that didn't go to ready, so the customer abandoned.

For provisioning, Paddle's [guide to provisioning access with webhooks](https://developer.paddle.com/build/subscriptions/provision-access-webhooks) names `subscription.created` and `subscription.updated`, and adds: "You don't need to listen for separate events for renewals, upgrades or downgrades, or status changes."

## Verifying webhooks and storing access state

In the starter kit, the webhook route [`route.ts`](https://github.com/PaddleHQ/paddle-nextjs-starter-kit/blob/main/src/app/api/webhook/route.ts) reads the raw body and the `Paddle-Signature` header and hands both to the Paddle Node SDK:

```typescript
const signature = request.headers.get('paddle-signature') || '';
const rawRequestBody = await request.text();
const privateKey = process.env['PADDLE_NOTIFICATION_WEBHOOK_SECRET'] || '';

const paddle = getPaddleInstance();
const eventData = await paddle.webhooks.unmarshal(rawRequestBody, privateKey, signature);

```

`unmarshal` computes an HMAC-SHA256 over the `ts` value from the header, a colon, and the raw body, then compares it with `h1`, as Paddle's [signature verification](https://developer.paddle.com/webhooks/about/signature-verification) docs describe. The Node SDK also rejects a signature whose `ts` is more than five seconds old. That's the default tolerance Paddle documents for its SDKs, and the Node SDK doesn't let you change it. On failure it throws, and the starter kit's route returns a `500`. Paddle's SDKs for Node, Go, PHP, and Python all include verification.

Michael McGovern, Staff Developer Advocate @ Paddle:

> Don't skip it 'cause there's money on the line here. You need to be sure that these events definitely came from Paddle. Use the SDKs to do that.

Its [`process-webhook.ts`](https://github.com/PaddleHQ/paddle-nextjs-starter-kit/blob/main/src/utils/paddle/process-webhook.ts) upserts state from four event types into Supabase and ignores the rest:

```typescript
switch (eventData.eventType) {
  case EventName.SubscriptionCreated:
  case EventName.SubscriptionUpdated:
    await this.updateSubscriptionData(eventData);
    break;
  case EventName.CustomerCreated:
  case EventName.CustomerUpdated:
    await this.updateCustomerData(eventData);
    break;
}

```

The `subscriptions` row holds the subscription ID, status, price ID, product ID, the effective date of any scheduled change (such as a cancellation at the end of the billing period), and the customer ID. The `customers` row holds the customer ID and email.

Billing history and payment details aren't cached; the billing screen fetches them from the Paddle API when the customer opens it. Access is different, because "you might need to check that on every page load," Michael said, "or periodically. And that needs to be quite quick."

## The five-second window and retry schedule

Paddle expects a `200` [within five seconds](https://developer.paddle.com/webhooks/about/respond-to-webhooks). If it doesn't get one, it retries:

* **Sandbox:** 3 attempts in total, all within about two minutes.
* **Live:** up to 60 attempts over about two days, around 10 of them in the first hour.

Michael's advice was to "respond with that 200 and then start processing on your side. Don't do the processing and then do the 200." A handler that writes one row before it returns, as the starter kit does, fits inside five seconds. One that also updates a CRM, sends a welcome email, or provisions seats in another service may not. For that, verify the signature, enqueue the event, return `200`, and do the work in a worker. (If you'd rather keep the processing in the handler, [Hookdeck Event Gateway](#processing-inside-the-handler) answers Paddle straight away and lets you give your endpoint up to 15 minutes to respond.)

Paddle gives you two controls on the producer side:

* **Filtered destinations.** Each [notification destination](https://developer.paddle.com/webhooks/about/notification-destinations) subscribes to a chosen set of event types, so a Slack integration can receive only `subscription.canceled`. You can have up to 10 active destinations.
* **Replay.** A delivered or failed notification can be resent with [`POST /notifications/{notification_id}/replay`](https://developer.paddle.com/api-reference/notifications/replay-notification), after you've fixed a handler bug. A replay is a new notification for the same event.

## Event IDs, notification IDs, and out-of-order events

Paddle treats an event and its delivery as separate things. In Michael's words, "an event is the sort of canonical thing that happened, and a notification is an instance of that event being sent." Each payload carries an `event_id` prefixed `evt_` and a `notification_id` prefixed `ntf_` (`ntfsimevt_` and `ntfsimntf_` for simulated events).

Paddle guarantees at-least-once delivery, and its docs say to [use `event_id` as the deduplication key](https://developer.paddle.com/webhooks/about/how-webhooks-work). Duplicates come from a retry after your handler finished but responded too late, from replays, which get a new notification ID, and from two destinations pointed at the same handler, which each get their own. Deduplicating on `notification_id` catches the retry but misses the other two.

Order isn't guaranteed either, and the docs recommend using `occurred_at` rather than arrival time. That is the bigger risk for stored access state. An upsert keyed on the subscription ID, as in the starter kit, makes a duplicate `subscription.updated` harmless, because it rewrites the same values. A stale one is different. In our simulated failed renewal, the subscription got three `subscription.updated` events, carrying `active`, then `past_due`, then `canceled`; in production those arrive over days of payment recovery. If the `active` one arrives last, the row ends up `active`, and a customer whose payment failed keeps access.

![A simulated subscription.canceled webhook from Paddle in Hookdeck Inspect, showing the paddle-signature header and the event_id, event_type, occurred_at, and notification_id fields](./images/wbe-paddle/inspect-paddle-subscription-canceled.jpg)

Before going to production, store the `occurred_at` of the last event applied to each subscription and skip any incoming event that is older. The starter kit leaves this out to stay minimal. Keep a table of processed `event_id` values for handlers with side effects that a snapshot upsert doesn't absorb, such as emails or CRM updates.

## Reconciling against the event stream

Retries and replays cover a failed delivery. They don't cover an endpoint that was down longer than the retry schedule, or a bug that wrote the wrong value for a day before anyone noticed.

For those, use [`GET /events`](https://developer.paddle.com/api-reference/events/list-events). It returns a paginated list of everything that happened in the last 90 days, which Paddle's docs say is sometimes called the event stream. Events older than 90 days aren't retained.

Michael described teams running a cron against it that "checks against this and updates anything that's fallen out of sync." The same endpoint handles recovery after an outage. Page through the events for the window you missed and run them through the processing logic your handler uses after verification, such as the starter kit's `processEvent`. Events from `GET /events` carry no `Paddle-Signature`, so they can't go through the verifying route. Deduplicating on `event_id` and comparing `occurred_at` are what make this safe, because the recovery run will overlap with events you already processed.

## Simulating a failed renewal with the webhook simulator

You can't wait a billing period for each renewal, failed payment, or cancellation you want to test.

Paddle's [webhook simulator](https://developer.paddle.com/webhooks/simulator/test-webhooks) sends a single event or a scenario: `subscription_creation`, `subscription_renewal`, `subscription_pause`, `subscription_resume`, or `subscription_cancellation`. A renewal scenario sends the events a real renewal produces: `subscription.updated` for the new billing period, then a transaction that is created, billed, paid, and completed. You can configure the outcome:

* **`payment_outcome`:** `success`, `failed`, `recovered_existing_payment_method`, or `recovered_updated_payment_method`.
* **`dunning_exhausted_action`:** `subscription_canceled` or `subscription_paused`, used when the payment fails.

We ran a failed renewal that ends in cancellation in a Paddle sandbox account, pointed at a Hookdeck Inspect URL. It sent 11 webhooks, ending in `subscription.canceled`.

![Paddle's Configure scenario dialog for a Subscription renewed simulation, with Payment failed selected and Cancel subscription as the action after payment recovery fails](./images/wbe-paddle/paddle-simulator-failed-renewal-config.jpg)

A scenario can also take a real subscription ID, so the payloads carry that subscription's data. Run a failed renewal that ends in cancellation against your app, and its stored status changes to `canceled` while the subscription in Paddle stays `active`, because a simulation doesn't create real transactions. Any screen that reads from the Paddle API, such as the starter kit's billing page, still says active.

That gap is why simulated traffic has its own destination setting. Each destination has a `traffic_source` of `platform`, `simulation`, or `all`, and the default is `platform`. The dashboard calls it **Usage type** and shows it on the destination form the simulator opens, not the one under **Notifications**. In our sandbox, a destination created under **Notifications** didn't appear in the simulator's list. Paddle's docs say platform destinations "can only receive traffic for real events to avoid sending simulated data to production endpoints." Keep your real endpoint on `platform` and run the simulator against a separate test destination.

![The New destination form opened from Paddle's webhook simulator, with Usage type set to Simulation and the choices Simulation and Both](./images/wbe-paddle/paddle-simulator-destination-usage-type.jpg)

Simulations can be run again with `POST /simulations/{simulation_id}/runs`, so you can rerun a scenario while you build the handling for a specific flow, or in CI, where an end-to-end test runs the failed-renewal scenario and asserts that the subscription row is `canceled` and the app revokes access.

## Access decisions and subscription status

Before your app lets a customer use the product, it checks whether they've paid, which plan they're on, and whether their subscription is current, past due, or paused.

The check reads the subscription's product ID and status from your own database on every request that needs them. The product ID tells you the plan, and the status tells you whether the customer should get in. The starter kit stores both fields but leaves the check to you. An upgrade or downgrade arrives as a `subscription.updated` with a new product ID.

Paddle's provisioning guide has a table of what each status usually means for access:

* **`trialing`:** full access, treated the same as `active`.
* **`active`:** full access.
* **`past_due`:** for automatically collected subscriptions, full access, with a banner and a link to the customer portal so the customer can update their payment method.
* **`paused`:** none or read-only. Michael's suggestion is limited access, such as read-only or export but no writes, so the customer can see what they're missing and resume.
* **`canceled`:** revoke access. If a scheduled change is set, keep access until it takes effect, which is why the scheduled change's effective date is worth storing.

The mistake Michael sees most from teams new to Paddle is granting access from the browser.

Michael McGovern, Staff Developer Advocate @ Paddle:

> Checkout completes on the front end, Paddle.js will say checkout completed, and PSPs have similar things, and they'll take that as a grant access. It's unreliable to do that on the front end, to make those access decisions.

`checkout.completed` is a Paddle.js event that fires in the customer's browser when the checkout finishes. That makes it frontend state, which the person using the browser can fake. Paddle's [`checkout.completed` docs](https://developer.paddle.com/paddle-js/events/checkout-completed) also note that for deferred capture payment methods, payment is only authorized at that point and still pending capture. Use the event to show a success page, and grant access from backend state, when `subscription.created` reaches your server.

## Verifying and processing behind Hookdeck Event Gateway

[Hookdeck Event Gateway](/event-gateway) can sit between Paddle and your handler. It receives Paddle's webhooks on a source and delivers them to your endpoint.

### Verification at the edge

The Node SDK's five-second signature window limits replayed requests, and it also fails any delivery that waits in a queue. A retry from a queue, or a manual retry from the [Hookdeck CLI](/docs/cli), still carries Paddle's original `Paddle-Signature` header. More than five seconds after Paddle signed it, the Paddle Node SDK's `unmarshal` throws, and a handler like the starter kit's returns `500` on every attempt.

Event Gateway has a Paddle [source type](/docs/sources#source-types). Configure the source with the destination's secret key from Paddle, and Hookdeck verifies `Paddle-Signature` when the request arrives. Requests that fail are rejected with `VERIFICATION_FAILED` and never reach your endpoint. Verified events are delivered with an `x-hookdeck-verified: true` header and an [`x-hookdeck-signature`](/docs/authentication#hookdeck-webhook-signature-verification), an HMAC SHA-256 of the body signed with your project secret. That signature has no timestamp, so it still verifies on a retry hours later.

Your handler checks the Hookdeck signature instead of calling the SDK's `unmarshal`. It then builds the same typed event with the SDK's `Webhooks.fromJson`, so the starter kit's `processEvent` works unchanged. Here's our adaptation of the starter kit's route:

```typescript
import crypto from 'node:crypto';
import { NextRequest } from 'next/server';
import { Webhooks } from '@paddle/paddle-node-sdk';
import { ProcessWebhook } from '@/utils/paddle/process-webhook';

const webhookProcessor = new ProcessWebhook();

export async function POST(request: NextRequest) {
  const rawRequestBody = await request.text();
  const expected = crypto
    .createHmac('sha256', process.env['HOOKDECK_SIGNING_SECRET'] || '')
    .update(rawRequestBody)
    .digest('base64');

  // Production code should compare in constant time and also accept
  // x-hookdeck-signature-2 while a rolled secret is still active.
  if (request.headers.get('x-hookdeck-signature') !== expected) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const eventData = Webhooks.fromJson(JSON.parse(rawRequestBody));
  await webhookProcessor.processEvent(eventData);
  return Response.json({ received: true });
}

```

[Hookdeck's Paddle verification guide](/webhooks/platforms/how-to-secure-and-verify-paddle-webhooks-with-hookdeck) covers the source setup.

### Processing inside the handler

The source acknowledges Paddle immediately and queues the event, so Paddle's five-second limit applies to Hookdeck rather than to your handler. Delivery to your endpoint has its own [delivery timeout](/docs/destinations#set-a-delivery-timeout), 60 seconds by default and configurable up to 15 minutes, and its own retries. With that window, the handler can finish work that takes minutes rather than seconds before it responds, such as chaining several external API calls to provision a new account or running an AI agent task, instead of handing it to a queue and worker of your own.

Keep your own `event_id` and `occurred_at` checks. Hookdeck delivers at least once too, and it can't know which subscription state is newer.

## What Paddle's LLM benchmark grades in a webhook handler

Paddle publishes an [LLM benchmark](https://developer.paddle.com/llm-benchmark) that scores models on building a Paddle integration from a one-shot prompt. The October 2026 table covers 29 models across 10 tasks, each run with a baseline prompt, with Paddle's [agent skills](https://github.com/PaddleHQ/paddle-agent-skills), and with the [Paddle docs MCP server](https://developer.paddle.com/sdks/ai/docs-mcp).

Two of the tasks grade the patterns in this post. **Webhook setup** checks include verifying before processing side effects, passing the raw body unchanged, reading the webhook secret rather than the API key, and staying idempotent across Paddle retries. **Sync subscriptions** checks upserts keyed on the subscription ID that are safe under out-of-order delivery, and granting access to `trialing` customers.

The mistakes Michael sees tripping up smaller models overlap with those criteria: signature verification, event ordering, and slow `200` responses. On the baseline table, webhook setup scores between 83 and 100 across all 29 models. Paddle notes that a one-shot test understates what skills and MCP add in an agent loop.

## Try it with Hookdeck Inspect

1. Copy a URL from [Hookdeck Inspect](https://inspect.hookdeck.com). You don't need a Hookdeck account.
2. In a Paddle sandbox account, open the webhook simulator and create a simulation. Use **Add new destination** to add the Inspect URL with **Usage type** set to Simulation, and select the subscription and transaction events.
3. Choose the **Subscription renewed** scenario. Under **Configure scenario**, select **Payment failed** and **Cancel subscription**, then run it.
4. Open the events in Hookdeck Inspect. Compare each payload's `event_id`, `notification_id`, and `occurred_at`, and check the order they arrived in.

To receive the webhooks in an app instead, such as the starter kit on port 3000, start the Hookdeck CLI with `npx hookdeck-cli listen 3000 paddle --path /api/webhook`, the command Paddle's own [respond to webhooks](https://developer.paddle.com/webhooks/about/respond-to-webhooks) docs suggest for local development. Then set the source URL the CLI prints as the destination URL in Paddle. Hookdeck forwards the original request headers, so your handler's own signature check still runs. To verify at the edge, [sign up for a free Hookdeck account](https://dashboard.hookdeck.com/signup) and set the source type to Paddle.

After checkout, the subscription lifecycle runs on Paddle's side, and webhooks are how your app keeps up. Store the status and plan from `subscription.created` and `subscription.updated`, keep that copy correct with signature verification, an `occurred_at` guard, and the event stream, and use the simulator for the renewals and failures you can't wait for. If your app grants access from the browser's checkout event today, move that decision to `subscription.created`, then run a failed-renewal simulation against your handler to see what it does with a cancellation.

## Resources

* [How Paddle webhooks work](https://developer.paddle.com/webhooks/about/how-webhooks-work)
* [Provision access with webhooks](https://developer.paddle.com/build/subscriptions/provision-access-webhooks)
* [Paddle webhook simulator](https://developer.paddle.com/webhooks/simulator/test-webhooks)
* [Paddle Next.js starter kit](https://github.com/PaddleHQ/paddle-nextjs-starter-kit), with a landing page at [billing.new](https://billing.new)
* [Paddle MCP server](https://developer.paddle.com/sdks/ai/paddle-mcp) for your account, and the [Paddle docs MCP server](https://developer.paddle.com/sdks/ai/docs-mcp)
* [Hookdeck's guide to Paddle webhooks](/webhooks/platforms/guide-to-paddle-webhooks-features-and-best-practices)
* [How to implement webhook idempotency](/webhooks/guides/implement-webhook-idempotency)

Thanks to Michael for walking us through Paddle's webhooks and the starter kit. The full conversation, including how Paddle works as a merchant of record and the AI tooling in Paddle's docs, is in the video at the top of this post and [on YouTube](https://www.youtube.com/watch?v=pJCnXPlqUJg). The next episode of Webhooks by Example is coming soon.