Read Provider contracts, the provider's official specification and the architecture rules before creating files. Another provider's plugin is an implementation example, never the source of truth for a new provider's field names or behaviour.
A provider is one directory under apps/api/src/providers/<id>/ and one row in
apps/api/src/core/providers/plugin-registry.ts. Nothing else in the codebase
learns its name — no switch, no if (provider === …). If you find yourself
editing the core to add a provider, the seam is in the wrong place and the fix is
to move it, not to add the branch.
A plugin is a plain object with no injected dependencies. It cannot reach the
database, the ledger or the queue, and that is the shape that proves the boundary
holds: a plugin that needed the EntityManager would be doing the core's work.
#The two halves
Declarations are data. What a provider is: its routes, the words it uses for each state, how it signs a webhook, what it charges. These can be described.
Behaviour is code. validate, toCanonical, toWire. These translate
between the provider's wire and the canonical model, and they cannot be
configuration — an editable toWire is a generator of lies, and fidelity is the
product.
#What a plugin declares
| Field | What it is |
|---|---|
id, displayName |
The URL prefix, and what the console shows |
routes |
Method and path pattern → canonical operation, verbatim from the provider's documentation |
resource |
What its resource is called, and how its identifiers are minted |
idempotency |
The header it reads, and which operations require it |
credentials |
How it mints and recognises a credential |
statuses |
Its own word for every canonical state |
errors |
Its own shape for every canonical refusal — see below |
webhooks |
Its topic names, payload shape, and how it signs and verifies |
checkout |
The buyer-facing URL shape, and the payment methods it offers |
ledger |
Fees, hold period, currencies, and whether a refund returns the fee |
successStatus |
Optional HTTP status overrides for canonical operations |
requestPatch, validateResource |
Pure request persistence mapping and existing-resource validation; the core writes |
publicDocuments |
Optional static public assets, such as the local PayPal webhook certificate |
credentials.exchange optionally declares client-credentials OAuth. Checkout can
separate buyer approval from payment and supply return/cancel URLs. See
ProviderPlugin in apps/api/src/core/providers/provider-plugin.ts for the
current type contract, including payment.update and canonical approved.
statuses and errors are total, and the conformance suite checks it. A
state or a refusal the plugin cannot phrase is one that reaches an integration in
the emulator's words instead of the provider's — and an integration branches on
both.
#Errors
The core raises a ProviderError carrying one of the CANONICAL_ERRORS; your
errors.shape() turns it into your provider's status and body. The gateway does
that once, on the way out.
export const yourErrors: ErrorVocabulary = {
shape(detail) {
const mapped = CODES[detail.code];
return { statusCode: mapped.status, body: { /* your provider's shape */ } };
},
};
Your plugin decides what a refusal is called. It can never decide whether there is one: a plugin that could would be able to make the emulator agree to something the real provider refuses.
detail.param names the field at fault, when there is one. Use it — it is how an
integration highlights the input that was wrong.
#The steps
- Read the official documentation, and store what you read under
contracts/<id>/. Routes, headers, field names and status vocabularies come from the provider's own specification, never from another plugin. - Create
apps/api/src/providers/<id>/with the plugin and its declarations. Split the vocabularies into their own files —<id>.errors.ts,<id>.webhooks.ts,<id>.wire.ts— so the plugin file stays readable. - Register it: add it to
PLUGINSinplugin-registry.ts. That is the whole registration. - Run the conformance suite. It is parameterised by plugin, so a new provider is held to everything the existing ones are, without writing a test per provider.
- Add official-SDK tests where the SDK can be pointed at a custom base URL. Stripe's takes a host and a port; Mercado Pago's hard-codes its own and cannot be redirected, which is itself documented rather than worked around.
- Add the buyer-facing half under
apps/checkout/providers/<id>/when the provider has a checkout surface. Keep provider flow and visual identity there; only provider-neutral shell elements belong inapps/checkout/app/components/. Add the real provider URL shape to the checkout app's routing/kit resolution without importing backend source. - Write
docs/en/fidelity/<id>.md, and say plainly what is not emulated. A known gap is a design decision; an unknown gap is a trap. The console reads this document and shows those gaps to an operator, so it is not paperwork. - Run
npm run typecheck,npm run lint,npm test,npm run buildandnpm run smoke. If the provider crosses the checkout or worker boundary, run the Compose stack andnpm run smoke:stacktoo.
#A frontier is allowed
A provider with no routes is a declared frontier, and it is held to everything except having an implementation: it still declares its statuses, its errors, its webhooks and its accounting. A placeholder that cannot is a placeholder hiding work the third provider will pay for.
implemented is not something you write. It is granted by the conformance suite
passing against a plugin that routes; the gateways table cannot promote one.
#Provider icons
Use the local marks and the Iconify keyword search workflow in Provider icon assets. Keep icons paired with visible provider names in the console and checkout.