Skip to content
Payment Emulator LabDocumentationOpen console

Providers and icons

Adding a provider

A provider is one directory under apps/api/src/providers/<id>/ and one row inapps/api/src/core/providers/plugin-registry.ts. Nothing else in the codebaselearns its name — no switch

5 min read

Índice / Index · Español

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.

ts
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

  1. 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.
  2. 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.
  3. Register it: add it to PLUGINS in plugin-registry.ts. That is the whole registration.
  4. 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.
  5. 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.
  6. 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 in apps/checkout/app/components/. Add the real provider URL shape to the checkout app's routing/kit resolution without importing backend source.
  7. 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.
  8. Run npm run typecheck, npm run lint, npm test, npm run build and npm run smoke. If the provider crosses the checkout or worker boundary, run the Compose stack and npm run smoke:stack too.

#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.

Payment Emulator Lab · RonuSoftwareMIT