Paddle

@crvouga/mockingbird-service-paddle

Stateful emulator of the Paddle Billing API (customers, addresses, businesses, products, prices, transactions with computed totals, subscriptions with pause/resume/cancel and renewals, events) with Paddle-Signature webhooks and admin routes that simulate checkout, renewals and failed payments (Mockingbird service contract).

Parity: Billing, subscriptions, and webhooksPayments42 of 50 operationsIn-process fetchNode servermockingbird-paddle
$
npm install -D @crvouga/mockingbird-service-paddle

Playground

Requests go to the real emulator running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-paddle into this tab on first send
https://sandbox-api.paddle.com

`Authorization: Bearer pdl_sdbx_apikey_…` (any key; none is 403 `authentication_missing`)

Send a request to see the emulator's response.

Operations

42 of 50 operations in the vendored contract are emulated. Hand-authored from https://developer.paddle.com/api-reference/overview and the wire types of @paddle/paddle-node-sdk@3.10.0 (dist/types/**). Every entity carries the fields the SDK reads; enums are trimmed to representative values so generated requests stay meaningful.

MethodPathOperationStatus
GET/customersListCustomers
POST/customersCreateCustomer
GET/customers/{customer_id}GetCustomer
PATCH/customers/{customer_id}UpdateCustomer
GET/customers/{customer_id}/credit-balancesGetCustomerCreditBalances
POST/customers/{customer_id}/auth-tokenCreateCustomerAuthToken
GET/customers/{customer_id}/addressesListAddresses
POST/customers/{customer_id}/addressesCreateAddress
GET/customers/{customer_id}/addresses/{address_id}GetAddress
PATCH/customers/{customer_id}/addresses/{address_id}UpdateAddress
GET/customers/{customer_id}/businessesListBusinesses
POST/customers/{customer_id}/businessesCreateBusiness
GET/customers/{customer_id}/businesses/{business_id}GetBusiness
PATCH/customers/{customer_id}/businesses/{business_id}UpdateBusiness
GET/productsListProducts
POST/productsCreateProduct
GET/products/{product_id}GetProduct
PATCH/products/{product_id}UpdateProduct
GET/pricesListPrices
POST/pricesCreatePrice
GET/prices/{price_id}GetPrice
PATCH/prices/{price_id}UpdatePrice
GET/transactionsListTransactions
POST/transactionsCreateTransaction
POST/transactions/previewPreviewTransaction
GET/transactions/{transaction_id}GetTransaction
PATCH/transactions/{transaction_id}UpdateTransaction
GET/transactions/{transaction_id}/invoiceGetTransactionInvoice
POST/transactions/{transaction_id}/reviseReviseTransactionNot emulated
GET/subscriptionsListSubscriptions
GET/subscriptions/{subscription_id}GetSubscription
PATCH/subscriptions/{subscription_id}UpdateSubscription
PATCH/subscriptions/{subscription_id}/previewPreviewSubscriptionUpdateNot emulated
POST/subscriptions/{subscription_id}/activateActivateSubscription
POST/subscriptions/{subscription_id}/pausePauseSubscription
POST/subscriptions/{subscription_id}/resumeResumeSubscription
POST/subscriptions/{subscription_id}/cancelCancelSubscription
POST/subscriptions/{subscription_id}/chargeCreateSubscriptionCharge
POST/subscriptions/{subscription_id}/charge/previewPreviewSubscriptionChargeNot emulated
GET/subscriptions/{subscription_id}/update-payment-method-transactionGetSubscriptionPaymentMethodChangeTransactionNot emulated
GET/eventsListEvents
GET/notification-settingsListNotificationSettings
POST/notification-settingsCreateNotificationSetting
GET/notification-settings/{notification_setting_id}GetNotificationSetting
PATCH/notification-settings/{notification_setting_id}UpdateNotificationSetting
GET/discountsListDiscountsNot emulated
POST/discountsCreateDiscountNot emulated
GET/adjustmentsListAdjustmentsNot emulated
POST/adjustmentsCreateAdjustmentNot emulated
POST/customers/{customer_id}/portal-sessionsCreateCustomerPortalSession

Why each gap exists is recorded in SUPPORT.md.

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

Local emulators. Real API contracts. Part of Mockingbird.

Stateful emulator of the Paddle Billing API for test suites. Customers, addresses, businesses, products and prices behave as Paddle's do (validation, invalid_field errors, include=, cursor pagination). Transactions carry computed totals. Subscriptions are created the way Paddle creates them, when a transaction with recurring prices is paid, and the emulator's admin routes stand in for the hosted checkout and the billing engine: pay a transaction, complete a checkout in one call, run a renewal, fail a payment. Every change produces the event Paddle would emit, listed at GET /events and delivered as a Paddle-Signature webhook that the official SDK's paddle.webhooks.unmarshal verifies.

  • Operation coverage: SUPPORT.md
  • openapi.yaml is hand-authored from Paddle's API reference and the wire types of @paddle/paddle-node-sdk@3.10.0.

Install

Shell
npm install -D @crvouga/mockingbird-service-paddle

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-paddle serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

The SDK maps environment to a base URL and otherwise uses the value verbatim, so point it at the emulator by passing the emulator's URL as the environment. Give the app the same pdl_ntfset_… secret as --webhook-secret.

Shell
npx mockingbird-paddle serve --port 8795 --fixtures \
  --webhook-url http://127.0.0.1:3000/webhooks/paddle \
  --webhook-secret "$PADDLE_WEBHOOK_SECRET" \
  --payment-link https://pay.example.com/checkout
PADDLE_API_BASE_URL=http://127.0.0.1:8795 node app.js
TypeScript
import { createServer } from "@crvouga/mockingbird-service-paddle/server"
import { type Environment, Paddle } from "@paddle/paddle-node-sdk"

const emulator = await createServer({ paymentLink: "https://pay.example.com/checkout" })
// The app's own Paddle client. Only its environment changes.
const paddle = new Paddle("pdl_sdbx_apikey_test", { environment: emulator.url as Environment })

const product = await paddle.products.create({ name: "Pro plan", taxCategory: "saas" })
const price = await paddle.prices.create({
  productId: product.id,
  description: "Pro monthly",
  unitPrice: { amount: "2900", currencyCode: "USD" },
  billingCycle: { interval: "month", frequency: 1 },
})
const customer = await paddle.customers.create({ email: "ada@example.com" })
const address = await paddle.addresses.create(customer.id, { countryCode: "US" })
const transaction = await paddle.transactions.create({
  items: [{ priceId: price.id, quantity: 2 }],
  customerId: customer.id,
  addressId: address.id,
})
// transaction.status === "ready", transaction.details.totals.grandTotal === "5800",
// transaction.checkout.url === "https://pay.example.com/checkout?_ptxn=txn_…"

// The customer pays on the hosted checkout: the emulator's admin route stands in for it.
await fetch(`${emulator.url}/__admin/transactions/${transaction.id}/pay`, { method: "POST" })
const [subscription] = await paddle.subscriptions.list({ customerId: [customer.id] }).next()
// subscription.status === "active", subscription.nextBilledAt one month out
await emulator.close()

With fixtures: true the catalog is already seeded, and one admin call completes a checkout:

TypeScript
import { createServer } from "@crvouga/mockingbird-service-paddle/server"
import { type Environment, Paddle } from "@paddle/paddle-node-sdk"

const emulator = await createServer({ fixtures: true })
const paddle = new Paddle("pdl_sdbx_apikey_test", { environment: emulator.url as Environment })
const [product] = await paddle.products.list({ include: ["prices"] }).next()

// Test setup: one admin call stands in for a customer completing the hosted checkout.
const completed = await fetch(`${emulator.url}/__admin/checkout`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email: "new@example.com", items: [{ price_id: product?.prices?.[0]?.id }] }),
})
const checkout = (await completed.json()) as { transaction: { id: string }; subscription: { id: string } }

const transaction = await paddle.transactions.get(checkout.transaction.id)
const subscription = await paddle.subscriptions.get(checkout.subscription.id)
// transaction.status === "completed", subscription.status === "trialing" (the fixture price has a trial)
await emulator.close()

Routes

Every response is Paddle's envelope: {data, meta: {request_id}}, lists add meta.pagination: {per_page, next, has_more, estimated_total}. Lists take after=<id> (the cursor in next, an absolute URL the SDK follows as-is), per_page (default 50, max 200; transactions 30; more than the maximum gets the maximum, as Paddle documents) and order_by=id[ASC]|id[DESC] (default newest first). Errors are {error: {type, code, detail, documentation_url, errors?: [{field, message}]}, meta}.

Route Behaviour
GET/POST /customers, GET/PATCH /customers/{id} {email, name?, custom_data?, locale?}; a second customer with the same email is 409 customer_already_exists. Filters: id, email, search, status. PATCH {status: "archived"} archives.
GET /customers/{id}/credit-balances Always [] (no credit is modelled).
POST /customers/{id}/auth-token {customer_auth_token: "pca_…", expires_at} (30 minutes out).
…/customers/{id}/addresses, …/businesses (+/{id}) Nested under their customer, as in Paddle; another customer's address is 404. country_code is required on an address, name on a business.
GET/POST /products, GET/PATCH /products/{id} {name, tax_category, description?, image_url?, custom_data?}; include=prices embeds prices.
GET/POST /prices, GET/PATCH /prices/{id} {product_id, description, unit_price: {amount, currency_code}, billing_cycle?, trial_period?, quantity?, unit_price_overrides?, tax_mode?}; include=product. Filters: product_id, recurring, type, status.
POST /transactions {items: [{price_id | price: {…non-catalog}, quantity}], customer_id?, address_id?, business_id?, currency_code?, collection_mode?, billing_details?, status?, custom_data?}. Status is ready with a customer and address, else draft; collection_mode: manual needs billing_details.payment_terms and can be created billed (invoice number, billed_at). details holds line items and totals; checkout.url is <paymentLink>?_ptxn=<id> when a payment link is configured. Recurring items must share one billing cycle; prices must match the transaction currency; an address or business must belong to the customer. Non-catalog price objects create type: custom prices (and products), only once the whole request has validated.
GET /transactions, GET /transactions/{id} Filters: id, customer_id, subscription_id, status, origin, collection_mode, invoice_number, created_at[GTE]-style datetime bounds (LT, LTE, GT, GTE on created_at, billed_at, updated_at); include=customer,address,business.
PATCH /transactions/{id} draft and ready transactions take every create field again (totals are recomputed); billed and past_due ones only {status: "canceled"}; anything else is 400 transaction_immutable.
POST /transactions/preview Totals for {items, customer_id?, address_id?, currency_code?, address?: {country_code}} without storing anything (non-catalog price objects are priced, not created); include_in_totals: false items are listed, not summed.
GET /transactions/{id}/invoice {url} for billed, paid and completed transactions; else 400 transaction_invoice_not_available.
GET /subscriptions, GET /subscriptions/{id} Filters: id, customer_id, address_id, price_id, status, collection_mode, scheduled_change_action; include=next_transaction,recurring_transaction_details embeds the previews. management_urls are placeholder links on the emulator's origin.
PATCH /subscriptions/{id} custom_data, next_billed_at, collection_mode + billing_details, customer_id/address_id/business_id, scheduled_change: null (removes a scheduled pause or cancel), and items with a required proration_billing_mode: *_immediately modes bill what the change adds (new prices, quantity increases) in full at once as a subscription_update transaction, with no proration and no credit for what it removes; the other modes bill nothing now. Canceled subscriptions are 400 subscription_update_when_canceled.
POST /subscriptions/{id}/activate trialing → active: bills the first period now and starts the billing cycle.
POST /subscriptions/{id}/pause {effective_from?: next_billing_period (default) | immediately, resume_at?}. Scheduled: scheduled_change: {action: "pause", effective_at: next_billed_at}. Immediate: paused, paused_at, no next_billed_at; with resume_at a resume change is scheduled.
POST /subscriptions/{id}/resume {effective_from: "immediately" | <datetime>, on_resume?}. Immediate: active with a fresh billing period, billed now; a datetime schedules the resume. On an active subscription with a scheduled pause, it removes the pause.
POST /subscriptions/{id}/cancel {effective_from?: next_billing_period (default) | immediately}. Scheduled: scheduled_change: {action: "cancel", effective_at: next_billed_at}, applied by the next renewal. Immediate: canceled, canceled_at, items inactive.
POST /subscriptions/{id}/charge {effective_from: immediately | next_billing_period, items} of non-recurring prices. Immediate: a completed subscription_charge transaction; otherwise the items are added to the next renewal's transaction.
GET /events Every event the account produced, newest first: {event_id, event_type, occurred_at, notification_id: null, data}.

Auth is Authorization: Bearer <key>; any key works. No header is 403 authentication_missing, a non-Bearer header is 403 authentication_malformed. Validation failures are 400 invalid_field with errors: [{field, message}] (email: required field); a malformed JSON body is 400 invalid_json; unknown entities are 404 not_found (Entity ctm_… not found). Ids look like Paddle's (ctm_01…, pri_01…, txn_01…, sub_01…, evt_01…: a prefix and 26 lower-case alphanumerics) and are deterministic for a given history.

Checkout and billing (admin)

Paddle has no API to pay a transaction or create a subscription: the hosted checkout does that, and the billing engine renews. These routes stand in for them (all under /__admin, namespaced like everything else):

Route Effect
POST /__admin/transactions/:id/pay {card?: {type, last4, expiry_month, expiry_year, cardholder_name}} A ready, billed or past_due transaction is paid: a captured card payment, completed, an invoice number. Recurring items create the subscription (trialing when a price has a trial_period, else active, with current_billing_period and next_billed_at); a past_due renewal being paid returns its subscription to active. → {transaction, subscription}
POST /__admin/checkout {email | customer_id, name?, country_code?, postal_code?, address_id?, business_id?, items: [{price_id, quantity?}], custom_data?} A hosted-checkout completion in one call: finds or creates the customer (by email) and an address, creates a web transaction and pays it. → 201 {transaction, subscription}
POST /__admin/subscriptions/:id/renew Runs the next billing date. A scheduled cancel or pause takes effect instead ({subscription, transaction: null}); a paused subscription with a scheduled resume resumes, billed from its effective_at; otherwise a completed subscription_recurring transaction for the recurring items (plus queued one-time charges), the billing period advances, and a trial ends into active.
POST /__admin/subscriptions/:id/payment-failed The renewal's payment is declined: a past_due transaction with an error payment attempt, and the subscription goes past_due. Pay that transaction to recover.
POST /__admin/seed The fixture account (below). → 201 with every created record.
GET /__admin/events?type= The events list, oldest first, optionally one type.

--fixtures (or createRuntime({fixtures: true})) seeds every namespace on first use, and again after every reset, with: Ada Lovelace (ada@example.com, a US address, a business), a Pro plan product with monthly ($29), yearly ($290) and one-time onboarding ($99) prices, a Starter plan with a 14-day trial ($19/month), an active monthly subscription, a trialing Starter subscription (Grace Hopper, grace@example.com), an unpaid ready transaction and a billed manual invoice. seedFixtures() returns the records; seeding a namespace twice clashes on the emails. Fixture events are listed (GET /events) but never delivered as notifications: they are the account's pre-existing state, not activity.

Webhooks

Every event is published to the configured endpoints as Paddle's notification body:

JSON
{"event_id": "evt_01…", "event_type": "subscription.created", "occurred_at": "…",
 "notification_id": "ntf_01…", "data": {"id": "sub_01…", "status": "active", "transaction_id": "txn_01…", "items": [ … ], … }}

Types: customer|address|business|product|price.created|updated, transaction.created|ready| billed|paid|completed|canceled|payment_failed|past_due|updated, subscription.created| activated|trialing|updated|paused|resumed|canceled|past_due. Each delivery carries Paddle-Signature: ts=<unix seconds>;h1=<hex HMAC-SHA256(secret, "<ts>:<raw body>")> with a wall-clock ts (even when the emulator clock moves), which paddle.webhooks.unmarshal(body, secret, signature) and isSignatureValid accept. Non-2xx answers are retried (immediately, 5 s, 5 min, 30 min, 2 h). GET /__admin/webhooks, …/events, …/flush, …/:id/replay and PUT /__admin/webhook-endpoints (per-namespace receivers, events: ["subscription.*"]-style filters are exact types or *) come with the contract.

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets): invalid_token (403 on every request), rate_limited (429 too_many_requests with retry-after: 2, the SDK's ApiError.retryAfter), transactions_500 (POST /transactions answers 500 internal_error), bad_gateway_html (reads answer a 502 HTML page), network_drop (POST /transactions drops the connection), webhook_duplicate, webhook_reorder, webhook_drop.

Namespaces

new Paddle(key) cannot add a namespace header on its own (it can with customHeaders), so map API keys to namespaces: PUT /__admin/credentials {"credentials": {"<PADDLE_API_KEY>": "<namespace>"}}. Also x-mockingbird-namespace, or a /__admin/ns/<name> prefix on the base URL (meta.pagination.next keeps it).

Deliberately not modelled

  • Tax and discounts. Totals carry tax: "0" and discount: "0" whatever the address or tax_mode; there are no discounts, discount groups or adjustments (refunds, credits), and no credit balances. unit_price_overrides are honoured for the address's country.
  • Proration. Changing a subscription's items with a *_immediately mode bills what the change adds in full, credits nothing for what it removes; the next-period modes bill nothing now. PATCH /subscriptions/{id}/preview and …/charge/preview are unsupported.
  • The hosted checkout, customer portal pages and Paddle.js. checkout.url, management_urls and portal-session links are URLs, not pages; POST /__admin/checkout and …/pay replace the checkout. POST /customers/{id}/portal-sessions returns the temporary https session links.
  • Payment methods and payment method changes, payouts, reports, simulations, invoice revisions, the imported events, API key events and client tokens.
  • Notification destinations are the vendor API (GET|POST /notification-settings, GET|PATCH /notification-settings/{id}). An active url destination is delivered with its own pdl_ntfset_… secret. The secret is returned on create only. --webhook-url and PUT /__admin/webhook-endpoints stay as extra receivers and are not replaced by the vendor list.
  • Time. Nothing renews on its own: call …/renew (or …/payment-failed) when the test's clock reaches next_billed_at. Retry schedules for past-due subscriptions are not modelled.
  • Rate limits, except through rate_limited. Legacy (pre-2025) API key formats are accepted like any other key.

API

Export Kind Description
PaddleAPI class The in-process emulator: fetch(request), reset(), events(), state, and the billing methods the admin routes call: createCustomer, createAddress, createBusiness, createProduct, createPrice, createTransaction, payTransaction, checkout, renewSubscription, failPayment, seedFixtures. Options: sqlite, now, namespace, publicNamespace, paymentLink, onEvent, fixtures.
createRuntime function The emulator with the full service contract (health, admin, namespaces, credentials, presets, Paddle-Signature webhooks, checkout and billing routes). Options: webhooks: {url, secret, events?, retryDelaysMs?, fetch?}, paymentLink, fixtures, clock, seed, adminKey, onLog, sqlite.
paddleSigner function The Paddle-Signature webhook signer (ts=…;h1=…).
PADDLE_PRESETS object Every named fault preset.
PADDLE_NAMESPACE string The service name, "paddle".
AUTH_TOKEN_TTL_MS number The advertised lifetime of a customer auth token (30 min).
PaddleError class The error a billing method throws: status, code, detail, errors?, toResponse(requestId).
PaddleState class The SQLite-backed collections (customers, addresses, businesses, products, prices, transactions, subscriptions, events, notificationSettings, portalSessions) and nextId(kind).
ID_PREFIX object Paddle's id prefix per entity (customer: "ctm", price: "pri", …).
document, operationIds, supportedOperationIds values The vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server) Node Serve over node:http; the serve CLI target (--webhook-url, --webhook-secret, --payment-link, --fixtures); port 8795.

Part of mockingbird.

↑↓ to navigate↵ to open