payable

Fastify Adapter

@akira-io/payable/fastify exposes createFastifyPayablePlugin(payable, options?), which returns a FastifyPluginAsync. Register it on a Fastify instance, optionally under a route prefix.

Purpose

Bridge Fastify requests to the Payable facade and PayableError instances to HTTP replies. The plugin sets a Fastify error handler and registers the webhook routes inside an isolated scope so it can swap the content-type parser without affecting the rest of the application.

The plugin 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 createFastifyPayablePlugin(
  payable: Payable,
  options?: FastifyPayableOptions,
): FastifyPluginAsync;

interface FastifyPayableOptions {
  webhookSignatureHeader?: string; // default: 'stripe-signature'
  authenticate?: onRequestHookHandler;
  resolveTenant?: (request: FastifyRequest) => string | null | undefined;
  resolveAuthorization?: (request: FastifyRequest) => AuthorizationContext | undefined;
  rateLimit?: RateLimitPluginOptions;
}

The plugin performs, in order:

  1. fastify.setErrorHandler(payableErrorReply).
  2. Registers @fastify/rate-limit with the plugin defaults, merging options.rateLimit over them.
  3. Registers webhook routes inside a nested fastify.register(...) scope.
  4. Registers checkout, subscription, refund, customer, read (invoices/payments/subscriptions/refunds), and catalog (products/prices) routes inside an authenticated scope. When options.authenticate is set, it is added as an onRequest hook on that scope.

Routes registered

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/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)
POST/refunds201Refund a payment
GET/refunds200List a payment’s refunds (query: paymentId, limit?)

Canonical collection routes

Fastify exposes the same provider-neutral storage routes as Express:

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 responses use { items, nextCursor, hasMore }, default to 25 items, and accept at most 100. These routes use the request tenant and never resolve a payment provider. The unprefixed catalogue routes remain provider-native, while unprefixed subscription and payment reads preserve their existing array contract.

Canonical subscription price migration routes

Fastify exposes the same six routes and provider-neutral DTOs as Express:

MethodPath
POST/canonical/subscription-price-migrations
GET/canonical/subscription-price-migrations
GET/canonical/subscription-price-migrations/:id
POST/canonical/subscription-price-migrations/:id/approve
POST/canonical/subscription-price-migrations/:id/cancel
POST/canonical/subscription-price-migrations/:id/retry

The create body requires canonical subscription and target-price IDs, explicit timing and policies, and rejects unknown keys. Scheduled requests require an RFC 3339 effectiveAt; immediate and next-renewal requests reject it. Every POST requires one Idempotency-Key header. All routes require a non-empty resolved tenant and a matching allowed authorization context.

Mutation routes use Fastify’s 64 KiB body limit and route rate-limit configuration. Pages accept limits from 1 through 100 and an opaque cursor. Responses exclude provider identifiers, execution tokens, request hashes, internal execution evidence, and stored provider diagnostics. Automatic execution and due-page work remain core resource operations and are not HTTP routes.

Parity with Express

This adapter exposes the same route set as Express: webhooks, checkout, subscription management (cancel, cancel-now, resume, swap), subscription reads, customers, invoices, payments, products, prices, refunds (create and list), and canonical subscription price migrations.

Every JSON route parses its body or query with the shared Zod schemas in src/presentation/shared/schemas.ts via parseBody, so a malformed body is rejected with VALIDATION_FAILED (HTTP 422), the same as Express. List endpoints that accept a limit cap it at MAX_LIST_LIMIT = 100; 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 plugin exposes activation and archival instead of product or price delete routes. Changing price monetary terms requires creating a replacement price.

Raw-body handling for webhooks

The webhook routes are registered inside a dedicated fastify.register(...) scope. Within that scope, the plugin removes all content-type parsers and installs a single buffer parser, so the webhook handler receives the raw request Buffer:

scope.removeAllContentTypeParsers();
scope.addContentTypeParser('*', { parseAs: 'buffer' }, (_request, body, done) => {
  done(null, body);
});

Because this is done inside an isolated scope, the buffer parser applies only to the webhook routes; checkout and subscription routes keep Fastify’s default JSON parsing. The handler converts the buffer to a UTF-8 string (or an empty string if it is not a buffer) and forwards payload, signature (from options.webhookSignatureHeader, default stripe-signature), and flattened headers to payable.receiveWebhook(...).

Rate limiting

The plugin registers @fastify/rate-limit before any routes, with these defaults:

await fastify.register(rateLimit, {
  global: false,
  max: 100,
  timeWindow: '1 minute',
  ...options.rateLimit,
});
DefaultValueEffect
globalfalseThe limiter is opt-in per route, not applied to every route automatically.
max100Maximum requests per timeWindow for routes that enable the limiter.
timeWindow'1 minute'The rolling window the max count applies over.

Pass options.rateLimit (typed RateLimitPluginOptions from @fastify/rate-limit) to override or extend any of these; the supplied object is spread over the defaults, so it wins on conflicts.

@fastify/rate-limit (>=9) is declared an optional peer. It is imported and registered unconditionally by the plugin, so it must be installed for the Fastify adapter to load - install it alongside fastify when using this adapter.

Error mapping

payableErrorReply is set as Fastify’s error handler and delegates to the shared mappers:

export function payableErrorReply(error, _request, reply): void {
  reply.status(payableErrorStatus(error)).send(payableErrorBody(error));
}

Status and body follow the same STATUS_BY_CODE table and { error, message } shape documented in docs/adapters/23-express.md. INVALID_WEBHOOK_SIGNATURE maps to 400 and VALIDATION_FAILED to 422.

Authentication and catalog authorization

As with Express, the plugin installs no authentication middleware. Checkout and legacy subscription routes are unprotected; webhook routes are protected only by provider signature verification. The caller must authenticate the request and verify ownership of the billable. Canonical subscription price migration routes also fail closed unless resolveTenant and resolveAuthorization return a matching tenant and allowed actor. See docs/28-security.md.

Use the authenticate hook to establish the caller before a catalog route runs. For each catalog mutation, resolveAuthorization runs once and returns an AuthorizationContext from that trusted identity. Fastify forwards the 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.

Catalog idempotency

Product and price mutation routes accept one Idempotency-Key header. Fastify validates it and forwards the value as CatalogMutationOptions.idempotencyKey. Invalid or duplicate header lines 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 value for the same tenant, provider, catalog operation, and body. The shared error mapper returns IDEMPOTENCY_RECONCILIATION_REQUIRED as HTTP 409 and CATALOG_IDEMPOTENCY_STORAGE_REQUIRED as HTTP 500. See Idempotency for recovery guidance.

Registration example

import Fastify from 'fastify';
import { createPayable } from '@akira-io/payable';
import { createFastifyPayablePlugin } from '@akira-io/payable/fastify';

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

const app = Fastify();
await app.register(createFastifyPayablePlugin(payable), { prefix: '/billing' });
await app.ready();

With catalog authorization enabled:

await app.register(
  createFastifyPayablePlugin(payable, {
    authenticate: requireApiKey,
    resolveAuthorization: (request) => ({
      allowed: true,
      actorId: request.user.id,
      tenantId: request.user.tenantId,
    }),
  }),
  { prefix: '/billing' },
);

With a custom signature header:

await app.register(
  createFastifyPayablePlugin(payable, { webhookSignatureHeader: 'paddle-signature' }),
  { prefix: '/billing' },
);

The prefix option is Fastify’s standard register option; all routes above are mounted beneath it (for example POST /billing/webhooks).

Edge cases

  • Multiple registered providers with no :provider segment surface WEBHOOK_PROVIDER_AMBIGUOUS (400) from the facade.
  • Webhook receipt requires a storage driver (WEBHOOK_STORAGE_REQUIRED, 500, when absent).
  • GET /subscriptions/:name returns 404 when the named subscription does not exist.