Manual contentsPaymentsBrowse 103 chapters
Manual 14 min read

Payments - Paddle Adapter

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:

cargo add suprnova-payments-paddle

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 std::sync::Arc;
use suprnova::payments::PaymentProviderRegistry;
use suprnova_payments_paddle::{PaddleEnvironment, PaddleProvider};

pub async fn bootstrap() {
    // From env (recommended):
    let paddle = PaddleProvider::from_env()
        .expect("Paddle env vars not set");

    // Or construct directly:
    let paddle = PaddleProvider::new(
        "pdl_sdbx_apikey_...",
        "pdl_ntfset_...",
        "test_...",
        PaddleEnvironment::Sandbox,
    ).expect("Paddle client init failed");

    PaymentProviderRegistry::bind("paddle", Arc::new(paddle));
}

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:

  1. You cannot create a subscription via API. Call Checkout::start_session with a recurring price; the customer completes the Paddle widget; the SubscriptionCreated webhook hydrates your mirror.
  2. You cannot swap a subscription's price set via API. Paddle reserves plan changes for its own dashboard or for migration flows it owns.
  3. 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 std::sync::Arc;
use suprnova::payments::*;

pub async fn start_checkout(
    user_id: String,
    email: String,
) -> PaymentResult<SessionPayload> {
    let provider = PaymentProviderRegistry::get("paddle")
        .expect("paddle provider not registered");

    // 1. Create the customer in Paddle (or reuse an existing one).
    let cus = provider.create_customer(CreateCustomerRequest {
        user_id: user_id.clone(),
        email,
        name: None,
        metadata: None,
    }).await?;

    // 2. Open a checkout session. Paddle dispatches one-off vs subscription
    //    on the *price kind*, not on the SessionMode field below.
    let session = provider.start_session(StartSessionRequest {
        mode: SessionMode::Subscription,           // ignored by Paddle (see note)
        customer_ref: cus.provider_customer_id,
        price_refs: vec!["pri_pro_monthly".into()],
        success_return_url: "https://app.example/billing/success".into(),
        cancel_return_url: "https://app.example/billing/cancel".into(),
        amount_hint: None,
        idempotency_key: Some(format!("checkout_{user_id}")),
        metadata: None,
    }).await?;

    Ok(session)
}

The returned SessionPayload::PaddleInline carries everything the frontend needs:

{
  "flow": "paddle_inline",
  "transaction_id": "txn_01h...",
  "customer_token": "ctm_01h...",
  "client_token": "test_..."
}

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_url fires 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(UpdateCustomerRequest {
    provider_customer_id: customer_id,
    email: None,
    name: None,
    metadata: Some(serde_json::json!({ "status": "archived" })),
}).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(CreateCustomerRequest {
    user_id: "user_42".into(),       // your app's user id
    email: "alice@example.com".into(),
    name: Some("Alice".into()),
    metadata: None,                  // not forwarded to Paddle in v1
}).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: "use Checkout::start_session with SessionMode::Subscription and await the SubscriptionCreated webhook"
  • update with new_price_refs: "Paddle price-set replacement on existing subscription not in v1"
  • delete_customer: "use UpdateCustomer with archived status"

Branch on this error explicitly when you're writing provider-agnostic domain code:

match provider.delete_customer(&cus_id).await {
    Ok(()) => { /* Stripe path */ }
    Err(PaymentError::NotSupported(_)) => {
        // Paddle path - archive via update instead
        provider.update_customer(UpdateCustomerRequest {
            provider_customer_id: cus_id,
            email: None,
            name: None,
            metadata: Some(serde_json::json!({ "status": "archived" })),
        }).await?;
    }
    Err(e) => return Err(e),
}

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):
cargo test -p suprnova-payments-paddle

# Plus sandbox integration (requires PADDLE_API_KEY etc.):
PADDLE_API_KEY=pdl_sdbx_apikey_... \
PADDLE_WEBHOOK_KEY=pdl_ntfset_... \
PADDLE_CLIENT_TOKEN=test_... \
PADDLE_ENVIRONMENT=sandbox \
  cargo test -p suprnova-payments-paddle

The invariant tests are the ones to mirror in your own code if you build adapter-specific abstractions. Three test shapes worth copying:

use suprnova::payments::*;
use suprnova_payments_paddle::{PaddleEnvironment, PaddleProvider};

#[test]
fn paddle_does_not_implement_payment_trait() {
    let p = PaddleProvider::new(
        "pdl_sdbx_apikey_test",
        "pdl_ntfset_test",
        "test_client",
        PaddleEnvironment::Sandbox,
    ).expect("provider construction");
    assert!(p.as_payment().is_none());
}

#[tokio::test]
async fn paddle_subscribe_returns_not_supported() {
    let p = /* ...as above... */;
    let err = p.subscribe(SubscribeRequest {
        customer_ref: "ctm_test".into(),
        price_refs: vec!["pri_test".into()],
        trial_days: None,
        idempotency_key: None,
        metadata: None,
    }).await.unwrap_err();
    assert!(matches!(err, PaymentError::NotSupported(_)));
}

#[test]
fn webhook_verify_rejects_bad_signature() {
    let p = /* ...as above... */;
    let mut headers = http::HeaderMap::new();
    headers.insert("paddle-signature", "ts=1234,h1=deadbeef".parse().unwrap());
    let ctx = WebhookContext {
        body: b"{}",
        headers: &headers,
        remote_addr: None,
    };
    assert!(matches!(p.verify(&ctx).unwrap_err(), PaymentError::WebhookSignature(_)));
}

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 std::sync::Arc;
use suprnova::payments::{MockPaymentProvider, PaymentProviderRegistry};

#[suprnova_test]
async fn checkout_flow() {
    PaymentProviderRegistry::bind("paddle", Arc::new(MockPaymentProvider::new()));
    // ...exercise your controller against the mock...
}

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_refs exist in the live catalog
  • Your success_return_url and cancel_return_url point at HTTPS endpoints (Paddle rejects HTTP in production)
  • You've decided how your app responds when subscribe(), delete_customer(), or update(price_refs) return NotSupported - 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