payable

Express Adapter

@akira-io/payable/express exposes createExpressPayableRoutes(payable, options?), which builds an Express Router wired to a Payable instance. The router is mounted under a base path of your choosing; every route is relative to that mount point.

Purpose

Translate HTTP requests into Payable facade calls and PayableError instances into HTTP responses. The adapter owns request parsing (including raw-body parsing for webhooks) and error mapping; it owns no business logic.

The adapter does not register a generic audit-write route. Authenticate and authorize host domain operations in the application, then call payable.audit(tenantId) directly. See Custom domain audit.

API

function createExpressPayableRoutes(
  payable: Payable,
  options?: ExpressPayableOptions,
): Router;

interface ExpressPayableOptions {
  webhookSignatureHeader?: string; // default: 'stripe-signature'
  authenticate?: RequestHandler; // optional auth middleware, applied after webhook routes
  resolveTenant?: (req: Request) => string | null | undefined;
  resolveAuthorization?: (req: Request) => AuthorizationContext | undefined;
}

createExpressPayableRoutes registers the route groups in this order, then attaches the error handler last:

  1. registerWebhookRoutes - raw-body routes, registered first.
  2. registerCheckoutRoutes
  3. registerSubscriptionRoutes
  4. registerCustomerRoutes
  5. registerInvoiceRoutes
  6. registerPaymentRoutes
  7. registerRefundRoutes
  8. registerCatalogRoutes
  9. payableErrorHandler (via router.use)

Routes mounted

Every method and path below is registered by the adapter. Paths are relative to the mount point.

MethodPathStatus (success)Behavior
POST/webhooks200Default-provider webhook receipt
POST/webhooks/:provider200Provider-scoped webhook receipt
POST/checkout201Create a subscription checkout session
POST/subscriptions/:name/cancel200Cancel at period end
POST/subscriptions/:name/cancel-now200Cancel immediately
POST/subscriptions/:name/resume200Resume a canceled subscription
POST/subscriptions/:name/swap200Swap to a new price
POST/refunds201Refund a payment
POST/customers201Create or get a logical customer in local storage
PATCH/customers200Update a logical customer’s email or name in local storage
POST/customers/sync200Synchronize a logical customer with the required provider name
GET/customers200Get a customer by billableType+billableId (query)
GET/invoices200List a billable’s invoices (query: billableType, billableId, limit?)
GET/invoices/:id/pdf200Download an invoice PDF (application/pdf; 404 if absent, 422 if the provider lacks invoicePdf)
GET/payments200List a billable’s payments (query: billableType, billableId)
GET/products200List products (query: limit?, cursor?, active?)
GET/products/:id200Retrieve a product by provider id
POST/products201Create a product at the provider
PATCH/products200Update a product
POST/products/:id/activate200Activate a product
POST/products/:id/archive200Archive a product without deleting it
GET/prices200List prices (query: limit?, cursor?, active?, providerProductId?)
GET/prices/:id200Retrieve a price by provider id
POST/prices201Create a price for a product
POST/prices/:id/activate200Activate a price
POST/prices/:id/archive200Archive a price without deleting it
GET/subscriptions200List a billable’s subscriptions (query: billableType, billableId, limit?)
GET/subscriptions/:name200Get one subscription by name (404 if absent)
GET/refunds200List a payment’s refunds (query: paymentId, limit?)

Canonical collection routes

The following storage-only routes do not resolve or call a payment provider:

ResourcePage routeExact route
CustomersGET /canonical/customersGET /canonical/customers/:id
ProductsGET /canonical/productsGET /canonical/products/:id
PricesGET /canonical/pricesGET /canonical/prices/:id
SubscriptionsGET /canonical/subscriptionsGET /canonical/subscriptions/:id
PaymentsGET /canonical/paymentsGET /canonical/payments/:id

Page routes return { items, nextCursor, hasMore }, default to 25 items, and accept at most 100. Pass an opaque cursor with the same tenant and filters. includeBindings=true is available for customers, products, prices, and subscriptions. The unprefixed product and price routes remain provider-native; the unprefixed subscription and payment reads retain their array-returning billable compatibility contract.

Canonical subscription price migration routes

MethodPathBehavior
POST/canonical/subscription-price-migrationsCreate one immutable preview
GET/canonical/subscription-price-migrationsList a bounded page by subscription or status
GET/canonical/subscription-price-migrations/:idRetrieve one tenant-scoped migration
POST/canonical/subscription-price-migrations/:id/approveExecute an immediate preview or schedule delayed work
POST/canonical/subscription-price-migrations/:id/cancelCancel a previewed, scheduled, or failed migration
POST/canonical/subscription-price-migrations/:id/retryRetry a confirmed recoverable failure

Preview bodies use canonical subscriptionId, targetPriceId, optional canonical itemId, optional positive quantity, explicit effectiveTiming, prorationPolicy, and paymentFailurePolicy. scheduled also requires an RFC 3339 effectiveAt; other timings reject that field. Every body is strict, so unknown keys fail validation.

All six routes require a non-empty tenant from resolveTenant and an allowed authorization context from resolveAuthorization whose tenantId matches. Every POST requires exactly one valid Idempotency-Key header. The create, approve, cancel, and retry bodies are limited to 64 KiB by default and use the configured fixed-window mutation rate boundary. List limits are 1 through 100 and cursors are opaque.

The adapter returns allow-listed canonical fields only. Provider identifiers, execution tokens, request hashes, internal execution evidence, and provider diagnostics are not response fields. There is no HTTP execute, due-page, scheduler, worker, or queue route; applications run due work through the core resource.

All routes above are wired to working implementations. /customers (POST/PATCH/GET), /invoices, and /payments resolve a Payable resource for the request’s billable (and tenant, when tenancy is on). The GET read routes take billableType and billableId as query parameters; /invoices also accepts an optional limit.

List endpoints that accept a limit cap it at MAX_LIST_LIMIT = 100 (src/presentation/shared/schemas.ts); a larger value fails validation with VALIDATION_FAILED (422).

Catalog lists accept an opaque cursor, default to active=true, and return { data, nextCursor }. Product lists accept limit, cursor, and active; price lists also accept providerProductId. The adapter exposes activation and archival instead of product or price delete routes. Changing price monetary terms requires creating a replacement price.

Request bodies

Checkout and subscription routes parse and validate their JSON bodies with the shared Zod schemas in src/presentation/shared/schemas.ts (checkoutBodySchema, manageSubscriptionBodySchema, swapSubscriptionBodySchema). A validation failure throws PayableError with code VALIDATION_FAILED, mapped to HTTP 422.

The refund route validates the body with refundBodySchema via parseBody (Zod): an invalid or missing paymentId throws VALIDATION_FAILED (422). The body shape is { paymentId: string, amount?: { amount: number, currency: string }, reason?: string }; amount is converted to a Money value object before reaching payable.refund(...).

Raw-body handling for webhooks

The webhook routes install their own body parser; you do not add one. Each route uses express.raw({ type: '*/*', limit: '1mb' }) so the handler receives the unparsed request Buffer:

router.post('/webhooks', raw({ type: '*/*', limit: WEBHOOK_BODY_LIMIT }), handler);
router.post('/webhooks/:provider', raw({ type: '*/*', limit: WEBHOOK_BODY_LIMIT }), handler);

The handler verifies the body is a Buffer. If a JSON body parser ran first (for example a global express.json() mounted ahead of the router), req.body is no longer a Buffer and the handler throws PayableError with code INVALID_WEBHOOK_PAYLOAD (HTTP 400):

if (!Buffer.isBuffer(req.body)) {
  throw new PayableError(
    'Webhook body must be the raw request buffer; mount the webhook router before any JSON body parser',
    { code: 'INVALID_WEBHOOK_PAYLOAD' },
  );
}

Because the webhook routes are registered first inside the router, and the router installs its own raw parser, the raw body survives as long as no upstream parser consumes it. Mount the Payable router before any global JSON parser.

The signature is read from the header named by options.webhookSignatureHeader, defaulting to stripe-signature. Headers are flattened to Record<string, string> via flattenHeaders and forwarded to payable.receiveWebhook(...).

Error mapping

The router’s final middleware is payableErrorHandler, which delegates to the shared mappers in src/presentation/shared/payable-http.ts:

export function payableErrorHandler(error, _req, res, _next): void {
  res.status(payableErrorStatus(error)).json(payableErrorBody(error));
}
  • payableErrorStatus maps PayableError.code to an HTTP status via STATUS_BY_CODE; an unknown code falls back to 500. A non-PayableError is always 500.
  • payableErrorBody returns { error: string, message: string }: error is the error code, message is the error message. A non-PayableError returns { error: 'INTERNAL_ERROR', message: 'Unexpected error' }.

Code-to-status table:

CodeStatus
NOT_IMPLEMENTED501
INVALID_WEBHOOK_SIGNATURE400
INVALID_WEBHOOK_PAYLOAD400
WEBHOOK_PROVIDER_AMBIGUOUS400
VALIDATION_FAILED422
COLLECTION_CURSOR_INVALID400
COLLECTION_LIMIT_INVALID422
PROVIDER_NOT_FOUND404
CUSTOMER_NOT_FOUND404
SUBSCRIPTION_NOT_FOUND404
SUBSCRIPTION_MIGRATION_NOT_FOUND404
SUBSCRIPTION_MIGRATION_PREVIEW_STALE409
SUBSCRIPTION_MIGRATION_TARGET_INELIGIBLE422
SUBSCRIPTION_MIGRATION_STATE_CONFLICT409
SUBSCRIPTION_MIGRATION_RECONCILIATION_REQUIRED409
SUBSCRIPTION_MIGRATION_PREVIEW_STORAGE_REQUIRED500
SUBSCRIPTION_MIGRATION_OPERATION_FAILED500
PAYLOAD_TOO_LARGE413
RATE_LIMIT_EXCEEDED429
IDEMPOTENCY_CONFLICT409
IDEMPOTENCY_IN_PROGRESS409
INVALID_IDEMPOTENCY_KEY400
CATALOG_IDEMPOTENCY_STORAGE_REQUIRED500
IDEMPOTENCY_RECONCILIATION_REQUIRED409
IDEMPOTENCY_RESULT_PERSISTENCE_FAILED500
PROVIDER_CAPABILITY_NOT_SUPPORTED422
CHECKOUT_PRICE_REQUIRED422
CHECKOUT_LINE_ITEMS_REQUIRED422
SUBSCRIPTION_PRICE_REQUIRED422
PAYMENT_NOT_FOUND404
PRODUCT_NOT_FOUND404
PRICE_NOT_FOUND404
WEBHOOK_EVENT_NOT_FOUND404
WEBHOOK_REPLAY_DENIED403
AUTHORIZATION_DENIED403
WEBHOOK_STORAGE_REQUIRED500
(any other code, or non-PayableError)500

No built-in authentication

The adapter installs no authentication middleware. Most routes except webhooks are unprotected at the adapter level:

  • /checkout, /subscriptions/:name/*, and /refunds accept whatever billable or paymentId the request supplies. The adapter does not verify that the caller owns the billable record or the payment.
  • The webhook routes are protected only by provider signature verification (performed inside payable.receiveWebhook), not by request authentication.

Canonical subscription price migration routes fail closed unless resolveTenant and resolveAuthorization produce the matching tenant and an allowed actor. Authenticate the request before those resolvers run; adapter authorization does not establish caller identity.

Authenticating the request and verifying ownership of the billable or payment is the caller’s responsibility. Pass an authenticate middleware in ExpressPayableOptions to have it applied inside the router after the webhook routes and before checkout/subscription/refund, or mount your own middleware ahead of the Payable router. See docs/28-security.md.

Catalog authorization

Authenticate the caller before the catalog routes run, then use resolveAuthorization to derive an AuthorizationContext from that trusted identity. For each catalog mutation, resolveAuthorization runs once. Express forwards its returned object unchanged in CatalogMutationOptions; the core resource makes the final authorization decision. A denied catalog write returns AUTHORIZATION_DENIED before capability validation or provider calls.

app.use(
  '/payable',
  createExpressPayableRoutes(payable, { authenticate: requireApiKey }),
);

With catalog authorization enabled:

createExpressPayableRoutes(payable, {
  authenticate: requireApiKey,
  resolveAuthorization: (req) => ({
    allowed: true,
    actorId: req.user.id,
    tenantId: req.user.tenantId,
  }),
});

Catalog idempotency

Every product and price mutation accepts one Idempotency-Key header. Express validates the header before calling the core resource and forwards it as CatalogMutationOptions.idempotencyKey. Duplicate header lines, blank values, surrounding whitespace, and values longer than 255 Unicode scalar values return INVALID_IDEMPOTENCY_KEY with HTTP 400.

curl -X POST https://example.test/products \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: catalog-product-pro-v1' \
  -d '{"name":"Pro"}'

Reuse the header value only with the same method, route operation, tenant, provider, and request body. The core scopes the effective identity and returns HTTP 409 for an idempotency conflict or a reconciliation-required result. See Idempotency for the provider and storage matrix.

Mounting example

import express from 'express';
import { createPayable } from '@akira-io/payable';
import { createExpressPayableRoutes } from '@akira-io/payable/express';

const payable = createPayable({ providers: { stripe: stripeProvider }, storage });

const app = express();

// Mount the Payable router BEFORE any global JSON parser so the raw
// webhook body survives. The router installs its own raw parser for /webhooks.
app.use('/billing', createExpressPayableRoutes(payable));

// Any global body parser belongs after the Payable router.
app.use(express.json());

app.listen(3000);

With a custom signature header:

app.use(
  '/billing',
  createExpressPayableRoutes(payable, { webhookSignatureHeader: 'paddle-signature' }),
);

Edge cases

  • A webhook with multiple registered providers but no :provider segment surfaces WEBHOOK_PROVIDER_AMBIGUOUS (400) from the facade - route such webhooks to /webhooks/:provider.
  • Webhook receipt requires a storage driver; without one the facade throws WEBHOOK_STORAGE_REQUIRED (500).
  • GET /subscriptions/:name returns 404 when the named subscription does not exist.