Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

Paddle

Configure the Paddle provider — API key, sandbox, the Paddle.js checkout requirement, webhooks, and its limitations.

Paddle is a merchant of record. Import the factory from revenue-sdk/paddle.

import { createClient } from 'revenue-sdk';
import { paddle } from 'revenue-sdk/paddle';

const client = createClient({
  provider: paddle({ apiKey: process.env.PADDLE_API_KEY! }),
});

Factory options

PropType
apiKey?string

Paddle API key, sent as a Bearer credential.

Typestring
server?'production' | 'sandbox'

Selects api.paddle.com or sandbox-api.paddle.com.

Type'production' | 'sandbox'
Defaultproduction
baseUrl?string

Overrides server; used verbatim.

Typestring
fetch?typeof fetch

Custom fetch implementation.

Typetypeof fetch

Authentication

Create an API key in the Paddle dashboard under Developer tools → Authentication. The SDK pins Paddle-Version: 1 on every request.

Sandbox & test mode

Paddle’s sandbox is a separate environment with its own dashboard, its own keys, and its own catalog:

paddle({ apiKey: process.env.PADDLE_SANDBOX_API_KEY!, server: 'sandbox' });

Limitations

  • No API-hosted checkout. hostedCheckout is false and checkouts.create({ successUrl }) throws unsupported — the returned URL only works through your own Paddle.js page (see below).
  • No license keys. licenseKeys is false and all four client.licenseKeys methods throw unsupported: Paddle Billing dropped the Classic license feature with no equivalent. See License keys.
  • No usage API. usage.report throws unsupported. Paddle’s own guidance is to meter externally and bill the result as a one-time charge — POST /subscriptions/{id}/charge with a price you compute yourself. See Usage-based billing.
  • No checkout expiry. A transaction carries no expiry field, so checkouts.create({ expiresAt }) throws unsupported and Checkout.expiresAt is always undefined.
  • No pay-what-you-want. checkouts.create({ customAmount }) throws unsupported.
  • No cancellation reasons. Passing reason or comment throws unsupported.
  • orders.list({ limit }) clamps to 30. /transactions rejects per_page > 30, unlike every other Paddle collection (200).

Full values for every capability: capability matrix.

Checkout requires Paddle.js on your own domain

const checkout = await client.checkouts.create({
  items: [{ product: 'pri_123', quantity: 1 }],
  customerEmail: '[email protected]',
  metadata: { userId: 'user_123' },
});

// Do NOT redirect blindly. Open your own Paddle.js page and pass the transaction.
renderPaddleCheckout(checkout.id);

On your page:

<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
  Paddle.Environment.set('sandbox');
  Paddle.Initialize({ token: 'live_or_test_client_side_token' });
  Paddle.Checkout.open({
    transactionId: transactionId,
    settings: { successUrl: 'https://example.com/thanks' },
  });
</script>

Gate on the capability in provider-agnostic code:

if (client.capabilities.hostedCheckout) {
  redirect(checkout.url);
} else {
  renderPaddleCheckout(checkout.id);
}

Webhooks

Create a notification destination in Developer tools → Notifications, choose “Webhook”, and copy the secret key Paddle generates for it. Verification and parsing come from the subpath:

import { parseWebhookEvent, verifyWebhook } from 'revenue-sdk/paddle';

See Handle webhooks for the full handler.

Events worth subscribing to: subscription.created, subscription.activated, subscription.updated, subscription.imported, subscription.trialing, subscription.past_due, subscription.paused, subscription.resumed, subscription.canceled, transaction.completed.

  • Scheduling a cancellation emits subscription.updated (carrying scheduled_change), never subscription.canceled — that only fires on the effective date. Undoing one arrives the same way, so subscriptionChange is undefined for both: read event.subscription.cancelAtPeriodEnd instead.
  • transaction.paid is left unmapped so transaction.completed is the single “money received” signal and payments don’t fire twice.
  • subscription.imported covers subscriptions migrated in from another system and normalizes to subscription.updated — upsert, don’t insert.

Orders

See Orders for the model. A unified Order is a Paddle transaction — the same entity a unified Checkout maps to, read later in its life. Paddle specifics:

  • Only completed transactions are listed. draft and ready transactions are abandoned checkouts, so the SDK filters them out and every listed order is paid; the rest are only visible through a direct orders.get.
  • Invoice URLs live for one hour, so they are fetched per click and never stored.
  • A transaction that was never billed, or whose total is zero, has no invoice and orders.getInvoiceUrl throws not_found.

Provider notes

  • Price.checkoutRef is the price ID (pri_…), not the product ID (pro_…), and a unified Checkout is a Paddle transaction — checkouts.get({ id }) reads GET /transactions/{id}.
  • customerEmail resolves to a customer. Paddle transactions take a customer_id, so the SDK looks the email up and creates the customer if it doesn’t exist — one or two extra requests.
  • A taken customer email is a 409. customers.create with an existing address surfaces as RevenueError { code: 'conflict' } with the existing customer’s ID in the message. Paddle’s own checkout flow silently reuses that customer; the direct API call does not.
  • PATCH list fields are full replacements. items is replaced wholesale, so a plan change swaps every item for the new price. The unified model targets single-product subscriptions.
  • custom_data is replaced, never merged. customers.update({ metadata }) sends the object as given. The SDK deliberately does not read-then-merge: that would make clearing a key impossible and would resurrect entries the caller left out.
  • Proration is always sent on a plan change, because Paddle requires the field whenever items changes. Omitting prorationBehavior behaves as prorate.
  • subscriptions.resume always takes effect immediately, and Paddle’s on_resume option is not exposed. Both pause behaviors are supported; a scheduled pause leaves status active with pauseAtPeriodEnd: true until it takes effect.
  • management_urls is absent from webhook payloads, so portal links must come from customerPortal.createSession. returnUrl is unsupported.

Last updated on August 8, 2026

Was this page helpful?