Paddle Provider
PaddleProvider (src/infrastructure/providers/paddle/paddle-provider.ts) implements the base
PaymentProvider contract. Unlike Stripe, it implements none of the optional interfaces
(ChargeCapable, DirectSubscriptionCapable, InvoiceCapable). Its registry name is 'paddle'.
Construction and options
export interface PaddleProviderOptions {
apiKey: string;
webhookSecret: string;
}
new PaddleProvider(options: PaddleProviderOptions, client?: PaddleClient);
apiKey- the Paddle API key used to lazily construct the SDK client.webhookSecret- passed toPaddleWebhookVerifier.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 {
checkout: true,
subscriptions: true,
trials: true,
refunds: true,
coupons: true,
billingPortal: true,
meteredBilling: false,
invoicePdf: false,
};
}
Capability gaps versus Stripe
The differences are:
invoicePdf: false(Stripe istrue). 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.isDirectSubscriptionCapable(paddleProvider)returnsfalse. - Partial refunds are not supported.
refundthrows wheninput.amountis set (see Failure scenarios).meteredBilling: false, the same as Stripe.
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.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 alwaysnull- Paddle’s subscription entity does not surface a trial-end timestamp here, a difference from Stripe.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 | Retry the operation. Note Paddle calls do not forward an idempotency key (the provider methods take no ctx). |
Caution: Paddle provider methods (e.g. createCustomer, refund) do not receive an OperationContext,
so unlike Stripe they do not pass an idempotency key to the provider API. Idempotency at the engine
boundary is handled separately by the idempotency store.
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' });