All guidesMore providers: databases, auth, hosting, email and payments
On this page

Paddle question

Paddle

Which Paddle webhook event should grant access: transaction.completed or subscription.activated?

By Ohad Krispin, founder of VibeRaven · LinkedIn

For a subscription, grant access on subscription.created and keep it right from the status on every subscription.updated. Paddle's provisioning guide says to save the customer and subscription IDs against your user and grant access on subscription.created, and to reread your cached fields on subscription.updated. A trialing subscription gets the same access as an active one, and access ends when the status becomes canceled, unless a scheduled change keeps it until that change takes effect. subscription.activated comes only after a trial has ended and Paddle has billed the customer, so waiting for it does not unlock a trial. Use transaction.completed for one-off charges, dedupe on event_id and compare occurred_at before you apply an event.

What Paddle's provisioning guide tells you to listen for

The guide lists two events for subscription billing: subscription.created, "a new subscription exists", where you save the customer and subscription IDs and grant access, and subscription.updated, for a change to the status, the items, a scheduled change or the billing period. It says you do not need separate events for renewals, upgrades, downgrades or status changes, because subscription.updated covers them. customer.created and customer.updated are optional, and transaction.completed is listed for apps that create one-off charges and need to record them locally. The webhooks overview describes transaction.completed as the event to confirm payment and start fulfillment.

Why subscription.activated is the wrong gate for a trial

Paddle describes subscription.activated as the event for a subscription that becomes active, after any trial has elapsed and Paddle has successfully billed the customer. After subscription.created, either subscription.trialing or subscription.activated typically follows, and the docs do not say in which case or in what order. An app that grants access only on subscription.activated therefore keeps a trial customer out until the trial is over. The status field fixes this: a subscription can be active, canceled, past_due, paused or trialing.

transaction.completed also fires for subscription payments. Paddle creates the subscription from a completed, automatically collected transaction, and a transaction goes through paid and updated before it is completed. That is why the guide keeps it for one-off charges and leaves subscription access to the subscription events.

A route that follows the guide

Verify the signature first, as in the signature fix. Then store the status with the event ID and time, so a retried or late event cannot overwrite a newer one. Paddle does not guarantee the order of delivery, retries when it does not get a 200 within five seconds, and may send the same event more than once. The field names below are those of @paddle/paddle-node-sdk 3.10.0, checked on 2026-10-31 by unmarshalling a locally signed payload.

import { Paddle, Environment, EventName } from "@paddle/paddle-node-sdk"

const paddle = new Paddle(process.env.PADDLE_API_KEY!, {
  environment: process.env.PADDLE_ENV === "sandbox" ? Environment.sandbox : Environment.production,
})

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get("paddle-signature") ?? ""
  const event = await paddle.webhooks.unmarshal(body, process.env.PADDLE_WEBHOOK_SECRET!, signature)

  if (event.eventType === EventName.SubscriptionCreated || event.eventType === EventName.SubscriptionUpdated) {
    const sub = event.data
    await saveSubscription({
      subscriptionId: sub.id,
      customerId: sub.customerId,
      status: sub.status,
      hasAccess: sub.status === "active" || sub.status === "trialing",
      occurredAt: event.occurredAt,
      eventId: event.eventId,
    })
  }
  return new Response("ok")
}

Checklist

Set it up, then check a real delivery

  1. 01In Paddle, add a notification destination for your deployed URL and select subscription.created and subscription.updated, plus transaction.completed if you sell one-off items. Sandbox and live are separate accounts, so each has its own destination and its own secret key.
  2. 02To know which user bought, pass customData such as your user ID to Paddle.Checkout.open(). Paddle adds it to the transaction and copies it to the subscription created from it. Also save the customer and subscription IDs on subscription.created.
  3. 03Save the status, the event ID and occurred_at with each update. Skip an event whose occurred_at is older than the one you stored, and skip an event ID you already handled.
  4. 04Let access follow the status: full for trialing and active, revoked on canceled unless a scheduled change keeps it until it takes effect. Decide for past_due and paused yourself, and write the rule down.
  5. 05Answer 200 within five seconds, and move slow work to a background job after you store the event.
  6. 06Run a test purchase in the Paddle sandbox. Your route should answer 200, and your user should have access.

Related

Sources

Official docs behind this checklist

FAQ

FAQ for builders before launch

Do I need subscription.activated at all?

Not to grant access. The provisioning guide covers a subscription with subscription.created and subscription.updated. Handle subscription.activated only if you want a record of the moment billing began.

Can I just call the Paddle API instead of trusting the webhook?

Yes. The guide says to fetch richer billing data from the Paddle API on demand, to run a periodic job that lists subscriptions for active customers, and, if your app cannot keep local state, to drive access from the API using the customer ID.

What if a retry arrives after a newer event?

Compare occurred_at with the value you last stored before you apply an update. Paddle says it cannot guarantee the order of delivery.