payable payable Docs
Coming soon

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 to PaddleWebhookVerifier.
  • client (optional) - an injected PaddleClient for tests. When omitted, the SDK is loaded on first use via import('@paddle/paddle-node-sdk') and new 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 is true). Paddle does not implement InvoiceCapable, so there is no listInvoices / downloadInvoicePdf. isInvoiceCapable(paddleProvider) returns false.
  • No ChargeCapable. Paddle has no charge method; one-off direct charges are not available. isChargeCapable(paddleProvider) returns false.
  • No DirectSubscriptionCapable. Paddle has no createSubscription method. Subscriptions are created through the checkout/transaction flow, not a direct API call. isDirectSubscriptionCapable(paddleProvider) returns false.
  • Partial refunds are not supported. refund throws when input.amount is 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:

  • toMinorUnits parses Paddle’s string amounts. Paddle returns money as a decimal string in minor units; the mapper validates it against ^-?\d+$ and throws PayableError (PROVIDER_AMOUNT_INVALID) for any non-integer value. Money.of then rebuilds the value object with an upper-cased currency.
  • toSubscriptionDTO maps Paddle status through SUBSCRIPTION_STATUS (active, trialing, past_due, paused, canceled), defaulting unknown values to incomplete. currentPeriodEnd comes from currentBillingPeriod.endsAt. trialEndsAt is always null - Paddle’s subscription entity does not surface a trial-end timestamp here, a difference from Stripe.
  • toProductDTO derives active from status === 'active'.
  • toRefundResultDTO maps a Paddle adjustment: status is succeeded when the adjustment is approved, otherwise pending. Amount falls back to 0 / USD when totals are absent.

Event normalization

PaddleEventNormalizer (paddle-event-normalizer.ts) maps Paddle event types to NormalizedEventName:

Paddle event typeNormalized name
customer.createdcustomer.created
customer.updatedcustomer.updated
subscription.createdsubscription.created
subscription.activatedsubscription.created
subscription.updatedsubscription.updated
subscription.canceledsubscription.cancelled
subscription.resumedsubscription.resumed
transaction.completedpayment.succeeded
transaction.paidpayment.succeeded
transaction.payment_failedpayment.failed
transaction.billedinvoice.created
adjustment.createdrefund.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

ScenarioSymptomRecovery
Partial refund requestedProviderCapabilityNotSupportedError('paddle', 'partial refund') thrown by refund when input.amount is setIssue a full refund (omit amount). Paddle adjustments are created with type: 'full'.
Invalid webhook signatureInvalidWebhookSignatureError (provider: 'paddle') on a thrown error or a null unmarshal resultVerify webhookSecret matches the Paddle notification setting and the raw body is forwarded unmodified.
Non-integer amount from PaddlePayableError PROVIDER_AMOUNT_INVALID from toMinorUnitsIndicates an unexpected amount format; inspect the offending entity.
Paddle API errorThe SDK error propagates from the called methodRetry 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' });