Contributing an adapter

Synced from billing-kit-adapters/CONTRIBUTING.md — the repo is canonical.

An adapter teaches billing-kit to talk to one billing provider. It implements a single interface, BillingProvider<Caps>, and declares what the provider can do so the core can decide — at compile time — which operations are even offered.

Start from the template

cp -r adapters/_template adapters/<your-provider>

adapters/_template/index.ts already type-checks against billing-kit's real interface. Rename createTemplateProvider, set the capabilities to the truth about your provider, and replace each todo(...) with a real call. Your editor will show you the exact shape of every argument and return value — the types are the spec.

Declare capabilities honestly

billing-kit branches on these, never on the provider's name. Getting one wrong is not a cosmetic bug; it changes which calls compile and how the ledger reads a result.

Capability Say true / value when…
settlement the modes you accept: 'lines' (you send priced lines) and/or 'quantity' (the provider holds the price)
merchantOfRecord the provider is the legal seller — its total is authoritative, its tax is its own, and the ledger posts the difference to a variance account
capturesPayment settling actually collects money. False if you only invoice and a PSP captures later — then status: 'settled' is not paid
refundsAreAsynchronous a refund is a request a human may decline
idempotency the provider has its own request-idempotency (a header + retention), else null
createsSubscriptions a subscription can be created by an API call. False on checkout-driven providers, where it only appears on a webhook — and then the ensureSubscription method is not even on the type
customerLookup what findCustomer can search by: 'key', 'email', or both

The one rule that matters most: creates are idempotent

ensureCustomer (and every create) is idempotent by our key. "Already exists" is success — find the existing record and return it. A provider that answers a retry with a conflict, and an adapter that propagates that conflict, makes every retry after a network timeout permanently fatal, discovered by a customer at checkout.

Where the provider has no request-idempotency of its own, findCustomer / findSettlement are the recovery path: after an ambiguous create you must be able to ask "did it land?". null means it did not — never return null for "I can't tell", because the caller will create a duplicate. If the provider can't search by the field the ref carries, throw ProviderError with kind unsupported.

Two more rules the reviewer will check

  • Never fall back between settlement modes. A silent linesquantity fallback changes who owns the price without telling the ledger, and the drift surfaces a month later with no source.
  • verifyWebhook returns only a verified event. Throw signature_invalid / signature_stale on failure. There is no "unverified" variant of the return type, so there is nothing to accidentally trust.

Test it

Add an index.test.ts beside your adapter. Drive it against recorded fixtures, never a live account — a contributor and CI must be able to run it offline. Prove the properties that matter:

  • ensureCustomer returns the existing record on a second call (idempotent).
  • verifyWebhook accepts a correctly-signed payload and rejects a tampered one.
  • settle refuses a mode you did not declare.
pnpm typecheck    # must be clean — the adapter satisfies BillingProvider<Caps>
pnpm test

Open the PR

Fill in the checklist in the PR template. An adapter is merged when it type-checks, its tests pass offline, and its capabilities match what the provider actually does. Add a row to the table in README.md.