Paddle Provider
PaddleProvider (src/infrastructure/providers/paddle/paddle-provider.ts) implements the base
PaymentProvider contract. It implements catalog, subscription-management, lifecycle-pause, webhook,
customer, and billing-portal capability interfaces, but not ChargeCapable,
DirectSubscriptionCapable, or InvoiceCapable. Its registry name is 'paddle'.
Construction and options
export interface PaddleProviderOptions {
apiKey: string;
webhookSecret: string;
environment?: 'sandbox' | 'production';
logger?: Logger;
}
new PaddleProvider(options: PaddleProviderOptions, client?: PaddleClient);
apiKey- the Paddle API key used to lazily construct the SDK client.webhookSecret- passed toPaddleWebhookVerifier.environment(optional) - selects the Paddlesandboxorproductionenvironment when constructing the SDK client.logger(optional) - aLoggerforwarded toPaddleEventNormalizer.client(optional) - an injectedPaddleClientfor tests. When omitted, the SDK is loaded on first use viaimport('@paddle/paddle-node-sdk')andnew Paddle(apiKey), keeping the dependency optional.
PaddleClient is a narrow structural interface declared in paddle-types.ts rather than the full SDK
type. The provider only depends on the methods it calls (customers, products, prices,
transactions, subscriptions, adjustments, customerPortalSessions, webhooks).
Declared capabilities
capabilities(): ProviderCapabilities {
return new Set([
'checkout',
'subscriptions',
'refunds',
'billingPortal',
'webhooks',
'customers',
'catalog',
'catalogRead',
'catalogLifecycle',
]);
}
Capability gaps versus Stripe
Paddle’s set omits charges, trials, coupons, meteredBilling, and invoicePdf. The differences
are:
- No
invoicePdf(Stripe declares it). Paddle does not implementInvoiceCapable, so there is nolistInvoices/downloadInvoicePdf.isInvoiceCapable(paddleProvider)returnsfalse. - No
ChargeCapable. Paddle has nochargemethod; one-off direct charges are not available.isChargeCapable(paddleProvider)returnsfalse. - No
DirectSubscriptionCapable. Paddle has nocreateSubscriptionmethod. Subscriptions are created through the checkout/transaction flow, not a direct API call. Paddle still declaressubscriptionsfor subscription management and webhook reconciliation.isDirectSubscriptionCapable(paddleProvider)returnsfalse. - Partial refunds are not supported.
refundthrows wheninput.amountis set (see Failure scenarios).meteredBillingis absent, the same as Stripe. - No
priceLookupKeys. Paddle does not implementPriceLookupKeyCapable; keyed price creation, lookup-key list filtering, and atomic lookup-key transfer are unavailable.
Subscription lifecycle pause and resume
PaddleProvider implements SubscriptionPauseCapable, PausedSubscriptionResumeCapable, and
ScheduledSubscriptionChangeCapable. Payable sends every policy field explicitly instead of relying
on Paddle defaults:
| Payable policy | Paddle request |
|---|---|
pause effectiveTiming: 'immediate' | effective_from: 'immediately' |
pause effectiveTiming: 'nextRenewal' | effective_from: 'next_billing_period' |
resumeAt: Date / null | RFC 3339 resume_at / null |
startNewBillingPeriod | on_resume: 'start_new_billing_period' |
continueExistingBillingPeriod | on_resume: 'continue_existing_billing_period' |
| immediate resume | effective_from: 'immediately' |
| scheduled resume | RFC 3339 effective_from |
An immediate pause maps the returned status to paused. A next-renewal pause remains active until
Paddle applies its scheduled_change. A scheduled resume remains paused until its effective date.
The mapper persists scheduled_change.action, effective_at, and resume_at; webhook reconciliation
updates the same normalized fields.
Removing a scheduled change is a separate explicit operation:
cancelScheduledSubscriptionChange() updates the subscription with scheduled_change: null.
Replacement workflows must cancel the existing change and then submit the new policy. Normal pause
and resume requests never clear a scheduled change implicitly.
Official Paddle subscription references:
- Preview a subscription update
- Subscription proration
- Pause a subscription
- Resume a paused subscription
- Update a subscription
- Pause subscription workflows
Catalog lifecycle
PaddleProvider implements CatalogCapable, CatalogReadCapable, and
CatalogLifecycleCapable. Product and price list calls map the portable cursor to after, the limit
to perPage, and active state to Paddle’s active or archived status filter. Price lists also map
providerProductId to Paddle’s product filter. When the SDK collection has another page, Payable
returns the final entity id as nextCursor; callers pass that cursor back unchanged.
| Payable operation | Paddle call | Notes |
|---|---|---|
products().retrieve(id) | products.get(id) | A missing product maps to PRODUCT_NOT_FOUND. |
products().list(input) | products.list({ after, perPage, status }) | Reads the SDK collection page with next(). |
products().activate(id) | products.update(id, { status: 'active' }) | Restores an archived product. |
products().archive(id) | products.update(id, { status: 'archived' }) | Keeps the Paddle product record. |
prices().retrieve(id) | prices.get(id) | A missing price maps to PRICE_NOT_FOUND. |
prices().list(input) | prices.list({ after, perPage, productId, status }) | Supports product and active-state filters. |
prices().activate(id) | prices.update(id, { status: 'active' }) | Restores an archived price. |
prices().archive(id) | prices.update(id, { status: 'archived' }) | Keeps the Paddle price record. |
Paddle product creation does not accept a status field. Payable creates an active product with
products.create. When CreateProductInput.active is false, it then archives the returned product
with products.update. An error from that second request is propagated, so the application can
reconcile the product before retrying.
Payable exposes no portable delete method for Paddle products or prices. Existing price monetary terms are not updateable through the contract. Create a replacement price and archive the old price when the amount, currency, billing interval, or interval count changes.
Paddle custom_data is metadata. It is not an equivalent lookup-key alias and does not provide an
atomic price-transfer mechanism. Do not use it as a substitute for priceLookupKeys.
Official Paddle references:
- List products
- Get a product
- Update a product
- List prices
- Get a price
- Update a price
- API errors
- Archive entities
- Custom data
- SDK libraries and retry guidance
Catalog idempotency
Paddle does not declare Payable’s catalogIdempotency capability. Paddle’s official SDK guidance
states that the API does not support client-supplied idempotency keys for arbitrary operations and
advises checking the entity with a list or get operation before retrying a create after a timeout or
network failure. See Paddle SDK libraries.
A keyed Paddle product or price mutation therefore requires an engine idempotency store. Without
one, Payable returns CATALOG_IDEMPOTENCY_STORAGE_REQUIRED before calling Paddle. With a store,
Payable prevents concurrent duplicate execution and replays completed results. If the Paddle call
fails with an ambiguous outcome, retrying the same key returns
IDEMPOTENCY_RECONCILIATION_REQUIRED without another provider call. List or retrieve the entity,
compare it with the intended mutation, and use a new key only for a new intentional operation.
Mappers
paddle-mappers.ts converts Paddle entities (typed in paddle-types.ts) to domain DTOs:
toMinorUnitsparses Paddle’s string amounts. Paddle returns money as a decimal string in minor units; the mapper validates it against^-?\d+$and throwsPayableError(PROVIDER_AMOUNT_INVALID) for any non-integer value. It also rejects values outside the safe-integer range (Number.isSafeInteger) with the samePROVIDER_AMOUNT_INVALIDcode.Money.ofthen rebuilds the value object with an upper-cased currency.toSubscriptionDTOmaps Paddle status throughSUBSCRIPTION_STATUS(active,trialing,past_due,paused,canceled), defaulting unknown values toincomplete.currentPeriodEndcomes fromcurrentBillingPeriod.endsAt.trialEndsAtis derived viareadTrialEndsAt, which reads the subscription’strialEndsAtfirst, then falls back to the first item carrying a trial end (trialDates.endsAt/trial_dates.ends_at), returningnullwhen none is present.toProductDTOderivesactivefromstatus === 'active'.toRefundResultDTOmaps a Paddle adjustment:statusissucceededwhen the adjustment isapproved, otherwisepending. Amount falls back to0/USDwhen totals are absent.
Event normalization
PaddleEventNormalizer (paddle-event-normalizer.ts) maps Paddle event types to NormalizedEventName:
| Paddle event type | Normalized name |
|---|---|
customer.created | customer.created |
customer.updated | customer.updated |
subscription.created | subscription.created |
subscription.activated | subscription.created |
subscription.updated | subscription.updated |
subscription.canceled | subscription.cancelled |
subscription.resumed | subscription.resumed |
transaction.completed | payment.succeeded |
transaction.paid | payment.succeeded |
transaction.payment_failed | payment.failed |
transaction.billed | invoice.created |
adjustment.created | refund.created |
Unmapped types normalize to null. Note subscription.activated and subscription.created both
collapse to subscription.created, and the two transaction-success events both map to
payment.succeeded.
Webhook verification
PaddleWebhookVerifier (paddle-webhook-verifier.ts) delegates to the SDK’s webhooks.unmarshal:
private async unmarshal(client, payload, signature) {
try {
return await client.webhooks.unmarshal(payload, this.secret, signature);
} catch (error) {
throw new InvalidWebhookSignatureError('paddle', { cause: error });
}
}
unmarshal receives the raw body, the configured webhookSecret, and the Paddle signature header. It
returns a PaddleWebhookEvent (eventId, eventType, data) or null. The verifier treats a thrown
error and a null result as a signature failure, throwing InvalidWebhookSignatureError with
provider: 'paddle'. verifyWebhook then returns a VerifiedWebhook built from those fields.
Failure scenarios and recovery
| Scenario | Symptom | Recovery |
|---|---|---|
| Partial refund requested | ProviderCapabilityNotSupportedError('paddle', 'partial refund') thrown by refund when input.amount is set | Issue a full refund (omit amount). Paddle adjustments are created with type: 'full'. |
| Invalid webhook signature | InvalidWebhookSignatureError (provider: 'paddle') on a thrown error or a null unmarshal result | Verify webhookSecret matches the Paddle notification setting and the raw body is forwarded unmodified. |
| Non-integer amount from Paddle | PayableError PROVIDER_AMOUNT_INVALID from toMinorUnits | Indicates an unexpected amount format; inspect the offending entity. |
| Paddle API error | The SDK error propagates from the called method | Before retrying a create, reconcile by listing or fetching the entity to confirm whether the first attempt reached Paddle. |
Paddle receives no idempotency key from Payable. If customer creation fails ambiguously before the
response supplies a customer id, Payable records reconciliation_required and blocks another
automatic create. Reconcile against Paddle by listing or retrieving the customer, then repair the
local binding before retrying synchronization.
Configuration example
import { createPayable } from '@akira-io/payable';
import { PaddleProvider } from '@akira-io/payable';
const paddle = new PaddleProvider({
apiKey: process.env.PADDLE_API_KEY!,
webhookSecret: process.env.PADDLE_WEBHOOK_SECRET!,
});
const payable = createPayable({
providers: { paddle },
// storage, queue, events, clock ...
});
// Full refund (partial throws ProviderCapabilityNotSupportedError):
await payable.refund({ paymentId: 'txn_123' });