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. You can change its prices, you
can cancel it, and you can read its state. 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 two 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 delete a customer. Archive the customer with
PaddleProvider::archive_customerinstead.
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. The transaction already contains the customer ID. customer_token
is None: a ctm_ identifier is not a Paddle customer authentication token.
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.
Correlation and recovery
metadata is forwarded as transaction custom_data using the adapter's
existing string-map policy: strings pass through, other non-null values
become JSON strings, and null values are omitted. Supply an object with your
checkout-attempt and domain identifiers. The pinned SDK accepts string maps,
so nested JSON values do not retain their original type.
Paddle copies custom data from a checkout transaction to its subscription and renewal transactions. An attempt identifier on a later payment therefore does not prove that it is the original checkout payment.
session_status(transaction_id) reads the transaction from Paddle:
| Paddle status | CheckoutSessionState |
|---|---|
draft, ready, billed, past_due |
Open |
paid, completed |
Complete { paid: true, payment_ref, amount_total } |
canceled |
Expired |
payment_ref is the transaction ID; the amount comes from
details.totals.total in minor units. Invalid totals or provider errors
return an error. paid confirms collection but may precede subscription
creation; wait for the subscription webhook or retrieve the subscription
before claiming that subscription setup is complete.
Paddle rejects a supplied idempotency_key before network I/O. Save the
transaction ID as soon as creation succeeds. If the create response is lost,
reconcile provider state before creating another transaction. Metadata is
for correlation and does not make repeated creates idempotent. A create that
runs out of time leaves the provider outcome unknown; it does not prove that
no transaction exists. See Deadlines and unknown outcomes.
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:
- Show a pending state after checkout. Configure the return navigation
in Paddle.js; the adapter does not forward
success_return_urlorcancel_return_urlto the transaction API. A browser callback is not proof of payment or an active subscription. - Poll server state. Use
session_statusto verify collection, and read the hydrated subscription mirror before showing subscription access.
Capability matrix
Not every method on every trait does what its Stripe equivalent does. The
table below is the truth. subscribe() and delete_customer() are the
only methods that always fail; the rest work, with the noted caveats.
| Trait method | Behavior |
|---|---|
Checkout::start_session |
Dispatches on price kind; forwards metadata; rejects a supplied idempotency key. |
Checkout::session_status |
Retrieves the transaction and reports collection state. |
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 effective_from set to next_billing_period. A supplied idempotency_key is NotSupported. |
Subscription::update(cancel_at_period_end: Some(false), new_price_refs: None) |
NotSupported. The adapter cannot rescind a scheduled cancellation. |
Subscription::update(new_price_refs: Some(...)) |
Works. Replaces the subscription's items with the new prices, billed as proration says. With cancel_at_period_end or an idempotency_key, NotSupported. See Change the prices of a subscription. |
Subscription::update (no-op) |
Works. Re-fetches current state via subscription_get. |
Subscription::cancel(id, true) |
Works. Cancels at the end of the billing period. See below. |
Subscription::cancel(id, false) |
Works. Cancels at once. See below. |
Subscription::get |
Works. |
CustomerStore::create_customer |
Works. |
CustomerStore::update_customer |
Works. |
CustomerStore::get_customer |
Works. |
CustomerStore::delete_customer |
NotSupported. Archive with PaddleProvider::archive_customer. |
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.
Cancel at the period end or at once
Subscription::cancel(id, at_period_end) follows the flag:
truesendssubscription_cancelwitheffective_fromset tonext_billing_period. The subscription staysActivewithcancel_at_period_end == true, and the user keeps access until the current billing period ends. Then Paddle firessubscription.canceledand the mirror flipsstatustoCanceled.falsecancels at once. The adapter makes two requests. It reads the subscription withsubscription_get, then sendssubscription_cancelwitheffective_fromset toimmediately. If the read fails, the adapter returns the error and cancels nothing. The result hasstatus == Canceled,cancel_at_period_end == false, and the billing period the subscription was in.
let sub = provider.cancel.await?;
// sub.status == Active, sub.cancel_at_period_end == true
let sub = provider.cancel.await?;
// sub.status == Canceled
update with cancel_at_period_end: Some(true) sends the same request as
cancel(id, true).
Change the prices of a subscription
Subscription::update with new_price_refs changes the prices of a
subscription. The list is the set of prices the subscription has after the
call:
- An item whose price is in the list keeps its quantity.
- An item whose price is not in the list is removed.
- A price that has no item is added with a quantity of 1, except in a swap. When the change removes exactly one item and adds exactly one price, the new price takes the quantity of the removed item.
- An empty list, or a list that names a price twice, is
PaymentError::Validation, and no request is sent.
use ;
let sub = provider.update.await?;
Paddle takes the complete list of items with a quantity for each. So the
adapter reads the subscription with subscription_get first, then sends one
subscription_update request with the items in the order of your list and a
proration_billing_mode. If the read fails, nothing is sent. If the list
names the prices the subscription already has, the adapter sends no update
and returns the subscription it read.
The change is computed from that read and is not atomic. Because the adapter sends the whole list of items, a change made at Paddle between the read and the write can be undone.
proration says how the change is billed. It is read only when
new_price_refs is Some:
proration |
Paddle proration_billing_mode |
|---|---|
None or Some(Proration::ProrateAtRenewal) |
prorated_next_billing_period |
Some(Proration::ProrateNow) |
prorated_immediately |
Some(Proration::DoNotProrate) |
do_not_bill |
Two combinations are PaymentError::NotSupported, and no request is sent:
- A price change together with
cancel_at_period_end: Some(_). Paddle schedules a cancellation through its own endpoint, so the two changes cannot be one request. Send them as two updates. - A price change with an
idempotency_key. The adapter cannot forward the key to Paddle.
An item quantity that Paddle reports outside the range of u32 is a
PaymentError::Provider error, and nothing is sent.
Error text
The text of a PaymentError::Provider that the adapter builds from an SDK
error names the operation, such as paddle customer_get. It keeps the type
and the code of a Paddle API error, and a transport error without its URL. It
carries no id and no URL, so it is safe to log. An error of another kind has a
fixed text, such as response body is not the expected JSON.
Deadlines and unknown outcomes
Every call of the adapter to Paddle has a deadline of 30 seconds, from the
request to the whole answer. A call that runs out of time returns
PaymentError::Provider. The text depends on what the call does:
| Call | Error text |
|---|---|
A read (subscription_get, customer_get, transaction_get) |
paddle <operation> timed out |
A call that changes something (transaction_create, subscription_update, subscription_cancel, customer_create, customer_update) |
paddle <operation> timed out; outcome is unknown, reconcile before retrying |
After a read times out, you can read again. After a change times out, Paddle
may have carried out the change, so the outcome is unknown. Read the state at
Paddle before you send the call again: Subscription::get for a
subscription, CustomerStore::get_customer for a customer, session_status
for a transaction.
The adapter has no idempotency key to retry under. It rejects a supplied
idempotency_key on start_session, on a price change, and on update with
cancel_at_period_end: Some(true), and CreateCustomerRequest, update_customer
and cancel have no key field. A retry of a change that timed out can repeat it.
Customer deletion is "archive"
delete_customer returns PaymentError::NotSupported because Paddle's
public API does not expose a delete endpoint at all. Archiving is Paddle's
only way to take a customer out of use, and the Paddle provider exposes it
directly:
paddle.archive_customer.await?;
archive_customer(provider_customer_id) sends the customer's status as
archived and nothing else, so the name, email and custom_data of the
customer stay as they are. The customer keeps its history and can't be used
for new checkouts. A customer that Paddle does not know is
PaymentError::NotFound. Any other Paddle error is PaymentError::Provider.
The call has the deadline of every call of the adapter and is a change: see
Deadlines and unknown outcomes.
Don't try to archive through update_customer's metadata: that field is
Paddle's custom_data, so a "status" key there is stored as data and
changes nothing.
Reach the Paddle provider
archive_customer is a method of PaddleProvider, not of a payments trait,
so PaymentProviderRegistry::get("paddle") cannot give it to you: it returns
an Arc<dyn PaymentProvider>. Keep the concrete provider. Bind the same
Arc in the registry and in the container when you bootstrap:
use Arc;
use App;
use PaymentProviderRegistry;
use PaddleProvider;
pub async
Then resolve it where you archive:
let paddle = .expect;
paddle.archive_customer.await?;
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 in paddle-rust-sdk 0.18, the version the adapter pins - 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 |
None |
Invoice issuance only; no paid mirror update |
| Approved refund adjustment | PaymentRefunded |
Update the referenced transaction as refunded |
| Approved chargeback or chargeback warning adjustment | PaymentDisputed |
Update the referenced transaction as disputed |
| Pending/rejected refund, credit, or reversal adjustment | None |
Raw provider event only |
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. The snapshot upper-cases
currency_code. Payment time comes from the latest captured payment attempt;
if none has a capture timestamp, paid_at stays absent. billed_at is invoice
issuance time and is never used as payment time.
The event-name-only paddle_event_to_neutral helper returns None for
adjustments because approval status and action require the payload. Use
WebhookHandler::parse_event for their classification. Raw reversal events
remain available to application reconciliation; they do not imply a new
payment or refund.
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"delete_customer: "archive withPaddleProvider::archive_customer"
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(), or a price change withcancel_at_period_endor anidempotency_keyreturnNotSupported- either branch in code or document that those flows are MoR-only - You've decided what your app does when a change times out with an unknown outcome: read the state at Paddle before you send the call again
- You've stress-tested the cancellation UX:
cancel(id, true)schedules the cancellation, so "you cancelled but you still have access until DATE" is the message your UI should show for it - 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
