Full-stack software engineer building useful tools. Constantly learning, with a habit of connecting dots across different domains.
If you’re integrating Paddle as your billing provider and need to keep an internal system (a licensing service, an entitlements table, a user database) in sync with subscription state, the webhook layer is where most of the real complexity lives. Paddle’s documentation covers the happy path well, but production traffic has a way of surfacing edge cases that aren’t documented anywhere. This post walks through a battle-tested approach to handling the three webhooks that matter most for subscription-based provisioning: transaction.completed, subscription.paused, and customer.created.
Paddle emits a long list of events, but for most provisioning use cases you don’t need all of them. A minimal, robust integration can be built around:
Everything else (new subscription created, subscription updated, etc.) tends to be either redundant with transaction.completed or not directly actionable for provisioning. Keeping the surface area small makes the integration easier to reason about and test.
This single webhook does a lot of work, and the way you interpret it depends on two fields: origin and subscription_id.
If subscription_id is not null and origin is web or api, treat this as a brand-new subscription purchase. At this point you should:
If origin is subscription-recurring, this is a renewal of an existing subscription. The licenses tied to that subscription get their expiry extended according to the subscription’s interval.
If origin is subscription-update, treat it as a resume event. The handling here is meaningfully different from a renewal:
The reason this distinction matters: a license can sit suspended for an extended period (months, in some cases) while a subscription is paused. If you resumed it by just applying the standard renewal logic (current expiry + interval), the new expiry could land in the past or be wildly out of sync with what the customer actually paid for. Anchoring to next_billed_at gives you the correct forward-looking expiry regardless of how long the suspension lasted.
Here’s the part that doesn’t show up in the docs: Paddle’s documentation states that resume transactions use origin = subscription-update. In practice, some resume transactions arrive with origin = subscription-recurring instead — indistinguishable at the field level from a normal renewal.
The practical fix is to handle the resume logic under both origin values. When you see subscription-recurring, check whether any licenses for that subscription are currently suspended. If so, run the resume path (fetch licenses, resume suspended ones, set expiry to next_billed_at) instead of or in addition to the standard renewal path. This means your renewal handler needs to be “suspension-aware” rather than assuming a clean linear renewal every time.
Takeaway: don’t treat origin as a strict enum you can switch on blindly. Treat it as a strong signal, but always check actual license state before deciding which code path to run.
This one is comparatively simple. When subscription.paused arrives, fetch every license linked to that Paddle subscription ID and transition them all to a suspended state. No expiry changes happen here — suspension is purely an access-control flip. The expiry recalculation happens later, on resume, as described above.
When a subscription is cancelled outright (as opposed to paused), the licenses tied to it need to leave the active pool entirely. Whether that means deleting, archiving, or revoking depends on what your system needs to retain.
One thing to watch for: don’t confuse subscription.paused and subscription.canceled in your handlers just because both result in “the customer loses access.” Pause is reversible by design — your resume logic depends on suspended licenses still existing with their state intact so they can be resumed and have their expiry recalculated from next_billed_at. Cancellation is (mostly) terminal, so it’s fine to move those licenses out of the active set permanently. If a cancelled customer resubscribes later, that’s a new transaction.completed with a new subscription_id, and should be provisioned as a new purchase rather than a resume of the old one.
This webhook creates or updates a user record in your system, mapped to the Paddle customer_id, using the customer’s email and name. Simple in isolation, but it interacts with the next problem in an important way.
Some payment providers (Stripe, FastSpring) bundle customer details directly into the transaction/subscription-creation event, so you always have an email address available at the moment you need to create a user. Paddle doesn’t guarantee this. It’s entirely possible for transaction.completed to arrive before customer.created for the same purchase.
If your provisioning logic requires a user record to attach licenses to, you can’t simply wait — purchases need to be provisioned promptly. The pragmatic solution:
In rare cases, both webhooks can arrive close enough together that a race condition occurs — the placeholder-creation and the real-customer-update happen in an order or timing that isn’t deterministic. In the worst case, the user ends up retaining the synthetic {customer_id}@yourdomain-paddle.com email rather than their real one.
This is worth calling out explicitly rather than pretending it can’t happen: depending on your tolerance for stale placeholder emails, you may want a periodic reconciliation job that re-checks placeholder-pattern emails against Paddle’s customer API and corrects any stragglers. For most products, this affects a tiny fraction of signups and a low-priority background job is sufficient — but don’t assume the webhook ordering will always self-heal.
Paddle’s documentation describes failed-payment subscriptions transitioning to either paused or cancelled after retries are exhausted. Operationally, you may also observe subscriptions sitting in a past_due state for some time before resolving one way or the other.
The good news: if your integration only reacts to the three webhooks above, past_due doesn’t require special handling. A customer with multiple transactions in past_due will, once they pay, generate transaction events that your renewal/resume logic already covers. Paddle settles all outstanding dues, and your existing handlers process the result normally. The state is worth knowing about for support and reporting purposes, but it doesn’t need its own webhook handler.
If you boil this down, the integration really comes down to a small number of rules layered on top of each other:
None of these rules are individually complicated, but Paddle’s documentation doesn’t surface the interactions between them particularly the origin-field unreliability and the customer/transaction ordering issue. Building explicit handling (and tests) for these edge cases up front will save you from subtle provisioning bugs that only show up days or months later, when a long-suspended subscription resumes or a placeholder user never gets its real email.