Skip to main content

Paddle

Paddle subscription access control should follow the lifecycle, not one webhook

A Paddle subscription can move from trialing to active, paused, resumed, and canceled. If your app gates access from one event, it will eventually grant or revoke access at the wrong time.

FetchSandbox EngineeringFetchSandbox Engineering

The bug shows up as a customer who should not have access anymore.

Their Paddle subscription was paused, resumed, then canceled. Your app saw one webhook in the middle and kept the account enabled. Billing says canceled. Your product still says active.

That is not a checkout bug. It is a lifecycle bug.

The access check is too simple

The tempting handler is direct:

type PaddleWebhook = {
  event_type:
    | "subscription.activated"
    | "subscription.paused"
    | "subscription.resumed"
    | "subscription.canceled";
  data: {
    id: string;
    status: "trialing" | "active" | "paused" | "canceled";
    customer_id: string;
  };
};

export async function handlePaddleWebhook(event: PaddleWebhook) {
  await accountAccess.set(event.data.customer_id, {
    subscriptionId: event.data.id,
    canUseProduct: event.data.status === "active",
  });
}

That works only if the event you are processing is the current subscription state.

In real systems, that is the risky assumption. Webhooks can retry. Workers can lag. Your app can process subscription.resumed after it already received a later cancellation somewhere else. A single event is a signal. It is not the full access-control decision.

Reconcile before changing access

Treat Paddle lifecycle webhooks as a reason to re-read the subscription before you grant or revoke access.

type PaddleSubscriptionSignal = {
  event_type:
    | "subscription.activated"
    | "subscription.paused"
    | "subscription.resumed"
    | "subscription.canceled";
  data: {
    id: string;
  };
};

export async function handlePaddleSubscriptionEvent(event: PaddleSubscriptionSignal) {
  const subscription = await paddle.subscriptions.get(event.data.id);

  await accountAccess.reconcile({
    subscriptionId: subscription.id,
    customerId: subscription.customerId,
    status: subscription.status,
    canUseProduct: subscription.status === "active",
    reconciledAt: new Date(),
  });
}

The exact SDK shape is not the point. The rule is: access follows the latest subscription resource, not whichever webhook your worker happened to process first.

That rule also makes retries boring. If subscription.paused is delivered twice, the second read returns the same subscription state. If subscription.resumed is followed by subscription.canceled, the later read wins.

What to store locally

At minimum, your local subscription record should keep:

  • Paddle subscription ID
  • Paddle customer ID
  • current status
  • last reconciled time
  • last event type seen
  • whether access is currently enabled

Do not make subscription.activated the only path that grants access and subscription.canceled the only path that removes it. Paused and resumed states are part of the contract too.

The test most examples skip

Most billing examples stop at "subscription is active."

The test that catches access drift walks the lifecycle:

  1. read the trialing subscription
  2. activate it and grant access
  3. pause it and remove access
  4. resume it and grant access again
  5. cancel it and remove access
  6. read it back before trusting the final local state

That is the branch that proves your app can survive more than the first paid moment.

Receipts

Here's a real Paddle subscription lifecycle workflow run against the FetchSandbox sandbox, captured 2026-06-12:

GET  /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp          → 200
  status: trialing

POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/activate → 200
  status: active

POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/pause    → 200
  status: paused

POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/resume   → 200
  status: active

POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/cancel   → 200
  status: canceled

GET  /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp          → 200
  status: canceled

webhook verification → 4/4 verified:
subscription.activated, subscription.paused, subscription.resumed, subscription.canceled

Full timeline: fetchsandbox.com/runs/9a316831cb?flow=run_fdb1a2eb-91d7-44b1-9c24-330706fbf456

The subscription ID sub_01hv8xqmay5w5rfsnzkxzgy0yp is the value your webhook handler should reconcile against. The final GET is the part that keeps your product access from depending on a stale lifecycle event.

How to test this before touching real billing

For Paddle, the finish line is not "checkout worked." It is "the app knows what access should be after the subscription changes state."

We use FetchSandbox to run the subscription lifecycle before wiring production billing logic. The point is not to replace Paddle's real sandbox. The point is to test the access-control states before your first paused or canceled customer exposes the gap.