Manual contentsPaymentsBrowse 113 chapters
Manual 20 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. 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:

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 two 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 delete a customer. Archive the customer with PaddleProvider::archive_customer instead.

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: None, // Paddle rejects client-supplied keys.
        metadata: None,
    }).await?;

    Ok(session)
}

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.

{
  "flow": "paddle_inline",
  "transaction_id": "txn_01h...",
  "customer_token": null,
  "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.

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_url or cancel_return_url to the transaction API. A browser callback is not proof of payment or an active subscription.
  • Poll server state. Use session_status to 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:

  • true sends subscription_cancel with effective_from set to next_billing_period. The subscription stays Active with cancel_at_period_end == true, and the user keeps access until the current billing period ends. Then Paddle fires subscription.canceled and the mirror flips status to Canceled.
  • false cancels at once. The adapter makes two requests. It reads the subscription with subscription_get, then sends subscription_cancel with effective_from set to immediately. If the read fails, the adapter returns the error and cancels nothing. The result has status == Canceled, cancel_at_period_end == false, and the billing period the subscription was in.
let sub = provider.cancel("sub_123", /* at_period_end */ true).await?;
// sub.status == Active, sub.cancel_at_period_end == true

let sub = provider.cancel("sub_123", /* at_period_end */ false).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 suprnova::payments::{Proration, UpdateSubscriptionRequest};

let sub = provider.update(UpdateSubscriptionRequest {
    provider_subscription_id: "sub_123".into(),
    new_price_refs: Some(vec!["pri_pro_yearly".into()]),
    proration: Some(Proration::ProrateAtRenewal),
    cancel_at_period_end: None,
    idempotency_key: None, // Paddle rejects a key for a price change.
}).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("ctm_123").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 std::sync::Arc;
use suprnova::App;
use suprnova::payments::PaymentProviderRegistry;
use suprnova_payments_paddle::PaddleProvider;

pub async fn bootstrap() {
    let paddle = Arc::new(PaddleProvider::from_env().expect("Paddle env vars not set"));
    PaymentProviderRegistry::bind("paddle", paddle.clone());
    App::bind::<PaddleProvider>(paddle);
}

Then resolve it where you archive:

let paddle = App::make::<PaddleProvider>().expect("PaddleProvider not bound");
paddle.archive_customer("ctm_123").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(CreateCustomerRequest {
    user_id: "user_42".into(),       // your app's user id
    email: "alice@example.com".into(),
    name: Some("Alice".into()),
    metadata: None,                  // optional custom_data string map
}).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"
  • delete_customer: "archive with PaddleProvider::archive_customer"

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 instead. `paddle` is the concrete
        // provider, see "Reach the Paddle provider" above.
        paddle.archive_customer(&cus_id).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 a price change with cancel_at_period_end or an idempotency_key return NotSupported - 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