The Paddle adapter (suprnova-payments-paddle) wires Paddle into Suprnova's
generic payments surface. Reach for it when you want a payment provider that
also handles sales tax, VAT, GST, dunning, invoicing, and refunds on your
behalf - Paddle is a Merchant of Record (MoR), which means it is the seller
of record to your customers and absorbs the compliance surface that a
direct-capture gateway like Stripe leaves to you.
That choice changes the mental model. Your domain code does not own the
subscription - Paddle does. You open a checkout, the customer completes it,
and the SubscriptionCreated webhook tells you the subscription now exists.
You cannot create a subscription via API, and you cannot swap its price set
after the fact. You can cancel, you can read state, you can update billing
metadata. The rest is Paddle's.
This chapter assumes you've read Payments for the generic five-trait surface. Here we cover what is true only for Paddle.
When to pick Paddle
Pick Paddle when one or more is true:
- You sell digital products globally and tax compliance (VAT, GST, US sales tax) is a real cost on your roadmap.
- You don't want to manage failed-payment retries, dunning emails, or receipt-issuing yourself.
- You want a single invoice from a single seller-of-record for accounting.
- Your business model is subscription-first, and you accept that the provider drives the subscription lifecycle.
Pick Stripe instead when you want direct control over
charge capture, you handle your own tax, or you need server-side
charge/capture/refund calls from your own code paths.
Setup
Add the crate:
Set the four environment variables:
PADDLE_API_KEY=pdl_sdbx_apikey_...
PADDLE_WEBHOOK_KEY=pdl_ntfset_...
PADDLE_CLIENT_TOKEN=test_...
PADDLE_ENVIRONMENT=sandbox
| Variable | What it is | Where it comes from |
|---|---|---|
PADDLE_API_KEY |
Server-side API key (pdl_live_apikey_… / pdl_sdbx_apikey_…) |
Paddle dashboard → Developer Tools → Authentication |
PADDLE_WEBHOOK_KEY |
Notification destination secret (pdl_ntfset_…) |
Paddle dashboard → Developer Tools → Notifications → your endpoint |
PADDLE_CLIENT_TOKEN |
Browser-safe client token (live_… / test_…) |
Paddle dashboard → Developer Tools → Authentication → Client-side tokens |
PADDLE_ENVIRONMENT |
sandbox (default) or production |
Your call |
Register the provider at bootstrap. Both forms are valid:
use Arc;
use PaymentProviderRegistry;
use ;
pub async
The webhook ingress route is registered by the framework's
webhook_routes(db.clone()) helper - see Payments.
Both from_env() and new() return Result because the underlying
paddle_rust_sdk::Paddle::new validates the API key shape and the
endpoint URL at construction time.
The MoR mental model
The shape that surprises Stripe users:
Stripe (gateway):
your app ─────────► Stripe ──► card network
│ ▲
└────── webhook ─────┘
you own the subscription state in your DB; Stripe is the executor
Paddle (Merchant of Record):
your app ─► checkout link ─► customer ──► Paddle ──► card network
│
◄────────────────── webhook ──────────────────┘
Paddle owns the subscription state; your DB is the mirror
In code, the difference shows up at three points:
- You cannot create a subscription via API. Call
Checkout::start_sessionwith a recurring price; the customer completes the Paddle widget; theSubscriptionCreatedwebhook hydrates your mirror. - You cannot swap a subscription's price set via API. Paddle reserves plan changes for its own dashboard or for migration flows it owns.
- You cannot delete a customer. Archive via update is the supported workaround.
Suprnova surfaces these constraints as PaymentError::NotSupported rather
than papering over them - see the capability matrix
below.
Checkout flow
Checkout::start_session is the only way to start a payment with Paddle.
The frontend opens the resulting transaction_id with paddle.js using the
client_token you set at bootstrap:
use Arc;
use *;
pub async
The returned SessionPayload::PaddleInline carries everything the frontend
needs:
See Payments - Frontend Integration for the paddle.js mounting code in Svelte / React / Vue.
Paddle dispatches on price kind, not SessionMode
A genuine Paddle-specific gotcha: the SessionMode::OneOff /
SessionMode::Subscription field on StartSessionRequest is ignored by
the Paddle adapter. Paddle's API has a single transaction_create
endpoint, and the provider inspects the supplied price IDs to infer the
flow - a recurring price starts a subscription, a one-off price starts a
single charge. With Stripe the field drives the flow; with Paddle the
price does. Set up your Paddle catalog with the correct price kinds
before pointing the adapter at them.
Subscriptions arrive via webhook
Because Paddle owns the subscription lifecycle, your domain code only learns about a subscription when Paddle tells you. The flow:
your app Paddle customer
│ │ │
│ start_session(price=pri_…) │ │
├─────────────────────────────►│ │
│ PaddleInline { txn_id, … } │ │
│◄─────────────────────────────┤ │
│ │ paddle.js │
│ │◄─────────────────────────┤
│ │ complete checkout │
│ ├─────────────────────────►│
│ │ │
│ subscription.created webhook │
│◄─────────────────────────────┤ │
│ │ │
▼ │ │
mirror tables hydrated; │ │
payments_subscriptions row │ │
has provider_subscription_id │ │
The framework's webhook_routes(db) handler does the hydration for you:
it calls WebhookHandler::extract_payload_ids to find the
subscription_id, calls Subscription::get(id) to read the canonical
state, and upserts payments_subscriptions + payments_subscription_items
inside one transaction. By the time the webhook returns 200, your mirror
is consistent with Paddle.
There is a brief window between the customer completing the widget and
the webhook arriving in which payments_subscriptions has no row for
the new subscription. Two patterns cover it:
- Use the redirect URL for immediate UX.
success_return_urlfires client-side as soon as Paddle confirms the transaction, so you can show "Subscription active" without waiting for the server-side webhook. - Poll-and-render. After the redirect, refresh the page after a short delay so the Inertia controller can read the now-hydrated mirror.
Capability matrix
Not every method on every trait does what its Stripe equivalent does. The
table below is the truth. subscribe() and update() with
new_price_refs.is_some() are the only methods that always fail; the
rest work, with the noted caveats.
| Trait method | Behavior |
|---|---|
Checkout::start_session |
Works. Dispatches one-off vs subscription on price kind, not SessionMode. |
Subscription::subscribe |
Always NotSupported. Subscriptions are born from checkout completion + webhook. |
Subscription::update(cancel_at_period_end: Some(true), new_price_refs: None) |
Works. Wires to subscription_cancel with default EffectiveFrom::NextBillingPeriod. |
Subscription::update(new_price_refs: Some(...)) |
NotSupported in v1. Paddle reserves price-set replacement for its own migration flows. |
Subscription::update (no-op) |
Works. Re-fetches current state via subscription_get. |
Subscription::cancel |
Works, but at_period_end is ignored - always schedules to next billing period. See below. |
Subscription::get |
Works. |
CustomerStore::create_customer |
Works. |
CustomerStore::update_customer |
Works. |
CustomerStore::get_customer |
Works. |
CustomerStore::delete_customer |
NotSupported. Use update_customer with archived status if needed. |
Payment::* |
Trait is not implemented. provider.as_payment() returns None. |
WebhookHandler::* |
Works. |
The invariants Payment not being implemented, subscribe/delete_customer
returning NotSupported, and webhook signature rejection are pinned by
always-on tests in crates/suprnova-payments-paddle/tests/integration.rs,
so the matrix above won't drift silently.
Cancellation is always scheduled
Subscription::cancel(id, at_period_end) accepts the bool for trait
compatibility but always behaves as scheduled cancellation -
Paddle's EffectiveFrom enum is private in paddle_rust_sdk 0.18, so
immediate cancel is not viable in v1. The user keeps access until the
current billing period ends, at which point Paddle fires
subscription.canceled and the mirror flips status to Canceled.
If you want a UX-level "cancel now" that revokes app access immediately
while letting Paddle wind down billing in the background, gate access on
your own subscription.status != Canceled && subscription.cancel_at_period_end == false
flag and update the UI right after cancel() returns - the next webhook
will confirm.
Customer deletion is "archive via update"
delete_customer returns PaymentError::NotSupported because Paddle's
public API does not expose a delete endpoint at all. If you need to
suppress a customer record in Paddle, call update_customer with the
archived status. The framework adapter does not wrap this directly -
the metadata field is the escape hatch:
provider.update_customer.await?;
Confirm the exact field path against your Paddle API version when shipping
this - the SDK does not currently model the status enum directly.
Webhook signature verification
Paddle signs every webhook with HMAC. The Paddle-Signature header looks
like ts=1716000000,h1=abcdef…. The adapter delegates verification to
Paddle::unmarshal from the SDK, which:
- Parses the header
- Recomputes the HMAC using your
PADDLE_WEBHOOK_KEY - Rejects signatures whose timestamp is outside
MaximumVariance::default()(5 seconds at time of writing - replays older than that are dropped)
The framework's webhook_routes handler calls verify before doing
anything else; a failure returns 401 invalid-signature with no body
leak. You don't write any of this code yourself, but it's worth knowing
the verification is HMAC + timestamp-tolerance, not a static secret
compare.
Webhook payload shape
The adapter's extract_payload_ids, extract_payment_snapshot, and
extract_customer_snapshot methods know Paddle's payload shape so the
framework can hydrate mirror tables. Quick mapping:
| Webhook event_type | NeutralEventKind |
Mirror effect |
|---|---|---|
transaction.completed, transaction.paid |
PaymentSucceeded |
Upsert payments_transactions |
transaction.payment_failed |
PaymentFailed |
Upsert payments_transactions (failed) |
transaction.billed |
InvoicePaid |
Upsert payments_transactions with provider_subscription_id linked |
adjustment.created, adjustment.updated |
PaymentRefunded |
Upsert payments_transactions (refunded) |
subscription.created |
SubscriptionCreated |
Subscription::get → upsert payments_subscriptions + items |
subscription.updated, .activated, .paused, .resumed, .trialing |
SubscriptionUpdated |
Same as above |
subscription.canceled |
SubscriptionCanceled |
Same; sets canceled_at, flips status |
customer.created |
CustomerCreated |
Update-only: refreshes email/metadata if the mirror row exists |
customer.updated |
CustomerUpdated |
Same |
| anything else | None (unmapped) |
Audit row only - no mirror change |
Paddle puts the entity object directly under data (not data.object like
Stripe). Amounts arrive as strings of minor units ("1234" = 12.34 in
the major unit), not decimals - the adapter parses both string and
numeric shapes for forward-compatibility. Currency arrives as
currency_code, lower-case, and the snapshot upper-cases it.
Inclusive-tax amounts
Paddle reports transaction amounts inclusive of tax. The framework's
payments_transactions mirror splits this:
amount_total_minor- the full amount the customer paid (tax included)amount_tax_minor- the tax component
Net of tax is amount_total_minor - amount_tax_minor. This differs from
Stripe (which reports exclusive of tax with amount_tax_minor = 0). Code
that sums revenue across both providers needs to be tax-aware:
let net_revenue_minor = txn.amount_total_minor - txn.amount_tax_minor;
Customer creation
CreateCustomerRequest maps directly to Paddle's customer_create:
let cus = provider.create_customer.await?;
// cus.provider_customer_id == "ctm_01h..."
Store cus.provider_customer_id alongside your user record. Every
subsequent call (start a checkout, look up a subscription, etc.) takes
the Paddle customer ID, not the app's user ID. The mirror table
payments_customers carries both columns so a single index lookup gets
you either direction.
update_customer and get_customer pass through to the equivalent SDK
methods. update_customer accepts email / name updates and returns
the refreshed CustomerRef. get_customer fetches a snapshot from
Paddle (not from the mirror) - use this when you need a fresh read after
an out-of-band change in the Paddle dashboard.
The intentional NotSupported shape
A reader unfamiliar with the codebase might assume PaymentError::NotSupported
on subscribe() and delete_customer() is a deferred TODO. It is not.
The constraints are part of Paddle's product surface, and Suprnova
encodes them rather than emulating local mutations the provider will
never honor.
Each NotSupported error message points at the supported workflow:
subscribe: "useCheckout::start_sessionwithSessionMode::Subscriptionand await theSubscriptionCreatedwebhook"updatewithnew_price_refs: "Paddle price-set replacement on existing subscription not in v1"delete_customer: "useUpdateCustomerwitharchivedstatus"
Branch on this error explicitly when you're writing provider-agnostic domain code:
match provider.delete_customer.await
Why Suprnova diverges
Laravel Cashier is Stripe-only and models subscriptions as
app-owned: $user->newSubscription('default', 'pri_pro')->create() is
shaped as if the application is initiating the subscription. With a
direct-capture gateway that's accurate. With an MoR, it's a lie - the
provider is the actor, not your app.
Suprnova's payments surface is provider-neutral, so it doesn't take a
side. The trait surface (subscribe, update, cancel, get) is the
generic shape; each adapter implements what its provider exposes and
returns NotSupported where the provider's product model differs. The
Stripe adapter implements subscribe. The Paddle adapter does not,
because Paddle does not let it. Hiding the difference behind a fake
local "create" would have the adapter lie to you - Suprnova prefers
the typed NotSupported with a migration message in the error string.
The same divergence applies to Payment (server-side capture). Stripe
implements it; Paddle does not, and provider.as_payment() returns
None. Code that needs charge/capture/refund must check
as_payment().is_some() rather than calling blindly - see
Payments.
Testing your integration
The crate includes always-on invariant tests (no network access needed) plus an env-gated integration test against Paddle's sandbox API:
# Always-on invariants (signature rejection, NotSupported shapes):
# Plus sandbox integration (requires PADDLE_API_KEY etc.):
PADDLE_API_KEY=pdl_sdbx_apikey_... \
The invariant tests are the ones to mirror in your own code if you build adapter-specific abstractions. Three test shapes worth copying:
use *;
use ;
async
For local end-to-end testing without hitting Paddle at all, the framework
ships MockPaymentProvider. Like Paddle, the mock's as_payment()
returns None (no server-side capture), so code that branches on
as_payment().is_some() follows the same path under the mock as it will
under Paddle. The mock's subscribe() returns Ok (unlike Paddle), so
tests that need to assert the NotSupported branch should use the real
PaddleProvider. Bind the mock in tests instead of the real provider:
use Arc;
use ;
async
Production checklist
Before flipping PADDLE_ENVIRONMENT=production:
- All four env vars are set in production secrets, not committed
- The webhook endpoint URL is registered in the Paddle dashboard
Notifications settings, and the destination secret you generated
there matches
PADDLE_WEBHOOK_KEY - The catalog has live (not sandbox) price IDs, and the IDs you
reference in
price_refsexist in the live catalog - Your
success_return_urlandcancel_return_urlpoint at HTTPS endpoints (Paddle rejects HTTP in production) - You've decided how your app responds when
subscribe(),delete_customer(), orupdate(price_refs)returnNotSupported- either branch in code or document that those flows are MoR-only - You've stress-tested the cancellation UX: cancellation is always scheduled, so "you cancelled but you still have access until DATE" is the message your UI should show
- You've stress-tested the subscription-arrival webhook: there is a window where the customer has paid but the mirror has no row yet
- You're aggregating revenue correctly: Paddle amounts are tax-inclusive, Stripe amounts are tax-exclusive
Next
- Payments - the generic five-trait surface and the webhook handler's mirror-hydration contract
- Payments - Frontend Integration - paddle.js inline checkout in Svelte / React / Vue
- Payments - Provider Guide - write your own adapter crate end-to-end
- Configuration - typed config registration the Paddle env vars plug into
- Application Bootstrap - where
PaymentProviderRegistry::bindactually lives in your app
