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
lines→quantityfallback changes who owns the price without telling the ledger, and the drift surfaces a month later with no source. verifyWebhookreturns only a verified event. Throwsignature_invalid/signature_staleon 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:
ensureCustomerreturns the existing record on a second call (idempotent).verifyWebhookaccepts a correctly-signed payload and rejects a tampered one.settlerefuses 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.