Add a Payment Provider
Every payment provider in Evolve is one thing: a PaymentAdapter. Checkout
talks to that interface and nothing else — it never learns which PSP is behind
it, or whether the adapter runs in-process or in a separate service.
Adding a provider means implementing that interface, exposing the PSP’s webhook, and registering the adapter. Everything else (creating the commercetools payment, namespacing method ids, GraphQL) is already done by the framework.
When to Use
Section titled “When to Use”- Integrating a PSP that has no
@evolve-framework/commercetools-<psp>package yet. - Adding an existing provider package to a service and wiring its config/routes.
- Writing or fixing a webhook / redirect return route for a provider.
- Splitting a provider out into its own service, or pointing checkout at a remote provider service.
- Debugging: methods missing from
availablePaymentMethods, “Payment method not allowed”, a webhook returning 200 but leaving the payment untouched, provider routes not mounted.
The Architecture
Section titled “The Architecture” ┌──────────────────── checkout service ───────────────────┐ GraphQL │ CartModule / CheckoutModule / OrderModule / PaymentModule│ availablePayment │ │ │ Methods, checkout │ PaymentRegistry ── keyed by identifier │ Complete │ │ │ │ │ MolliePaymentAdapter RemotePaymentAdapter ────────┼──HTTP──┐ └──────────────────────────────────────────────────────────┘ │ ▼ ┌──── provider service ──────────────────┐ │ PaymentProviderModule │ │ ├ /healthcheck │ │ ├ RemotePaymentPublisher (internal) │ │ │ /payment-methods /create │ │ └ adapter.getRoutes() (external) │ │ /webhook /redirect ◄── PSP │ └────────────────────────────────────────┘Everything ships from @evolve-framework/commercetools/payments:
| Symbol | Role |
|---|---|
PaymentAdapter |
The interface checkout depends on. |
BasePaymentAdapter |
Base class for in-process providers — resolves cart/order/payment for you. |
PaymentError |
Throw this for provider-level failures. |
PaymentRegistry / Remote() |
Holds adapters keyed by identifier; Remote() builds an HTTP bridge. |
RemotePaymentAdapter |
Client half of the bridge (checkout side). |
RemotePaymentPublisher |
Server half — exposes an adapter over HTTP. |
createSuccessUrl / createFailureUrl / getRedirectServiceUrl |
Redirect URL helpers. |
PaymentProviderModule comes from @evolve-framework/commercetools.
Two hosting topologies, same adapter code. Co-hosted (register the adapter in
the checkout service’s PaymentRegistry — no loopback HTTP) or standalone (host
it with PaymentProviderModule and register Remote(identifier, url) in
checkout). Decide this when wiring, not when writing the adapter.
Before Writing a Package
Section titled “Before Writing a Package”- Does a package already exist?
commercetools-{adyen,mollie,stripe,paynl,buckaroo,invoice}. If so this is a wiring job — skip to Wire It Up. - Does it need commercetools access at all? If the provider is an existing
service you only need to reach,
Remote()is the whole integration. - Pick the closest baseline to copy. This matters more than starting clean:
commercetools-mollie— redirect flow, webhook byinterfaceId, mappers. The best general starting point.commercetools-adyen— drop-in components, HMAC-verified notifications, publishes notification messages.commercetools-stripe— signed push events, redirect route.commercetools-paynl— signature-verifying Fastify plugin, order mappers.commercetools-invoice— no external PSP at all; the minimum viable adapter.
Package Layout
Section titled “Package Layout”packages/commercetools-<psp>/├── src/│ ├── adapter.ts # the PaymentAdapter — the only required file│ ├── index.ts # barrel: explicit named exports│ ├── lib/client.ts # PSP SDK client + <Psp>Config type│ ├── core/│ │ ├── create.ts # createTransaction implementation│ │ └── methods.ts # getPaymentMethods implementation│ ├── mappers/state-mapper.ts # PSP status → commercetools state│ ├── routes/│ │ ├── webhook.ts # async PSP updates│ │ └── redirect.ts # shopper returns from the PSP│ └── testing/│ ├── index.ts # exported test helpers (./testing subpath)│ └── mocks.ts # msw server seeded with ctMock handlers├── package.json tsconfig.json tsdown.config.ts└── vitest.config.ts vitest.setup.ts biome.jsonc README.mdCopy package.json, tsconfig.json, tsdown.config.ts, vitest.* and
biome.jsonc verbatim from the baseline package and change only the name and
the PSP SDK dependency. Two entries, always:
"exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" }, "./testing": { "import": "./src/testing/index.ts", "default": "./src/testing/index.ts" }},"imports": { "#src/*": "./src/*" }publishConfig.exports mirrors these against dist/, and tsdown.config.ts
lists both as entry. All three must agree — tsc and test do not catch a
stale tsdown entry, only pnpm --filter @evolve-framework/commercetools-<psp> build does.
Implement the Adapter
Section titled “Implement the Adapter”Extend BasePaymentAdapter. It resolves the cart/order and the payment before
calling you, so the hooks receive real objects, not ids.
export type PspPaymentAdapterOptions = { clientFactory: ClientFactory; config: PspConfig;};
export class PspPaymentAdapter extends BasePaymentAdapter { readonly identifier = "psp"; // route prefix, method namespace, ct paymentInterface public client!: PspClient; public readonly config: PspConfig;
constructor(options: PspPaymentAdapterOptions) { super(options.clientFactory); this.config = options.config; }
// Runs once, after this.ctApi exists. Build SDK clients here — never in the // constructor: adapters are constructed at composition time, before init(). protected override setup(): void { this.client = createClient(this.config); }
protected readonly getPaymentMethodsFor = async ( source: Cart | Order, device: "WEB" | "IOS" | "ANDROID", ): Promise<PaymentMethod[]> => { /* … */ };
protected readonly createTransactionFor = async ( req: TransactionRequest, target: Cart | Order, payment: Payment, ): Promise<TransactionResponse> => { /* … */ };
override getRoutes(): (app: FastifyInstance) => void { return (app) => { app.post("/webhook", webhookOpts, createWebhookHandler(this)); app.get("/redirect", createRedirectHandler(this)); }; }}identifier is load-bearing in three places: routes are mounted under
/<identifier>, checkout namespaces method ids as <identifier>-<methodId>, and
it becomes paymentMethodInfo.paymentInterface on the commercetools payment.
Registering two adapters with the same identifier throws.
The base class hands you commercetools operations — use these instead of writing
SDK calls: getCartOrOrder, getPaymentById, getTargetByPaymentId,
addPaymentTransaction, updatePayment, updateTarget. Raw this.ctApi is
available for provider-specific queries (e.g. lookup by interfaceId).
createTransactionFor
Section titled “createTransactionFor”- Re-check the method against
getPaymentMethods. The request is shopper-controlled; never start a transaction for a method the cart isn’t allowed to use. ThrowPaymentError("Payment method not allowed"). - Create the payment at the PSP.
- Record it with
addPaymentTransaction, storing the PSP’s id asinterfaceId— that field is the only join between a webhook and the commercetools payment. Open atstate: "Pending". - Return
{ status: "OK", payload: { id, redirectUrl, method: "GET" } }.
A zero-amount transaction already throws in the base class; keep the same guard if you reimplement.
The Webhook — where integrations actually break
Section titled “The Webhook — where integrations actually break”The PSP calls this, not the shopper. It is the only path that moves money-state into commercetools, and its correctness is the whole integration:
const payment = await adapter.getPaymentByInterfaceId(req.body.id); // join keyconst pspPayment = await adapter.client.payments.get(payment.interfaceId!);
const state = toTransactionState(pspPayment.status);if (!state) { /* unmapped status → log, 200, do nothing */ }
const updated = await adapter.updatePayment(payment, { state, interfaceCode: pspPayment.status, interfaceText: pspPayment.status,});const target = await adapter.getTargetByPaymentId(updated.id);if (target) { await adapter.updateTarget(target.targetId, target.targetType, state);}Rules, each of which a real integration has broken:
- Never trust the webhook body for status. Re-fetch from the PSP with the id in the payload. Bodies are spoofable; a fetch is not.
- Verify the signature where the PSP offers one (Adyen HMAC, Stripe
signature, Pay.nl exchange). That needs the raw body — set
config: { rawBody: true, encoding: false }on the route, or apreValidationplugin (seecommercetools-paynl). - Answer in the PSP’s dialect. Mollie wants
text/plainTRUE/FALSE; others want 200 with a specific body. A generic Fastify error page reads as a failure and triggers redelivery storms. Set anerrorHandleron the route. - Unknown status is not an error. Map it to “do nothing”, log, return success — PSPs send statuses you did not model.
- Be idempotent. Every PSP redelivers. Deriving state from a re-fetch instead of appending blindly gets you most of the way.
Map statuses in mappers/state-mapper.ts, one switch returning a
commercetools TransactionState (Initial / Pending / Success / Failure /
Refunded) or undefined for unmapped. Keep it pure and unit-test it — it is
the cheapest test in the package and catches PSP vocabulary drift.
The Redirect Route
Section titled “The Redirect Route”Where the shopper lands after paying. Build URLs with the framework helpers so
the storefront gets a consistent contract:
getRedirectServiceUrl(serverUrl, data) when creating the transaction, then
createSuccessUrl(successUrl, orderId) / createFailureUrl(failureUrl, reason, targetId, targetType) when redirecting back. Treat the return as untrusted —
it is a browser redirect. The webhook is the source of truth; the redirect only
navigates.
Notifications (optional)
Section titled “Notifications (optional)”Providers that emit domain events take an injected publisher:
messagePublisher: MessagePublisher from @evolve-framework/core/messaging
(see commercetools-adyen). Required option, injected at the composition root —
never resolve a queue target from a string inside the package. Use
NoopPublisher to opt out explicitly.
Wire It Up
Section titled “Wire It Up”Co-hosted with checkout (the common case — examples/commercetools-contentful):
const payments = new PaymentRegistry();if (config.PSP_API_KEY) { // gate optional providers on config payments.register(new PspPaymentAdapter({ clientFactory, config: { apiKey: config.PSP_API_KEY, serverUrl: `${config.SERVER_URL}/psp` }, }));}
new CartModule({ clientFactory, payments });new CheckoutModule({ clientFactory, payments });new OrderModule({ clientFactory, payments });new PaymentModule({ clientFactory, payments }); // mounts webhooks under /<identifier>serverUrl must be the externally reachable base URL including the provider
prefix — the PSP calls it. Localhost here means webhooks that never arrive.
Standalone provider service (examples/payment-commercetools-mollie):
const app = await createApplication({ name: "payment-commercetools-psp", module: new PaymentProviderModule({ name: "commercetools-psp", adapter: new PspPaymentAdapter({ clientFactory, config: { /* … */ } }), internalRoutes: true, // /payment-methods + /create for remote checkout externalRoutes: true, // adapter.getRoutes() — webhook/redirect secret: config.PAYMENT_SECRET, // guards internal routes only }), http: { address: { host, port } },});Checkout then registers Remote("psp", "https://…/psp", { secret }) — same
secret both sides. internalRoutes: false when the adapter is also co-hosted;
externalRoutes: false when another deployment already owns the webhooks.
Testing
Section titled “Testing”Use @labdigital/commercetools-mock via @evolve-framework/commercetools/testing
— it mocks the full commercetools API, so tests exercise the real SDK. Do not
hand-mock commercetools calls, not even behind a dataloader.
src/testing/mocks.ts and vitest.setup.ts are boilerplate — copy them:
// mocks.tsexport const mswServer = setupServer(...(ctMock.getHandlers() as unknown as RequestHandler[]));
// vitest.setup.tsbeforeAll(() => mswServer.listen({ onUnhandledRequest: "error" }));afterEach(async () => { mswServer.resetHandlers(); await ctMock.clear(); });afterAll(() => mswServer.close());onUnhandledRequest: "error" is deliberate: an un-stubbed PSP call fails the
test instead of hitting the network. Add PSP handlers per test with
mswServer.use(...).
Cover, in rough priority order:
routes/healthcheck.test.ts— boots the adapter through a realcreateApplication+PaymentProviderModuleand injectsGET /healthcheck. Every provider package has one; it is the cheapest proof the adapter constructs, initializes and mounts. Copy it first.- Webhook tests — seed a commercetools payment in
ctMock, POST a webhook body, assert the transaction state changed. Include an unmapped status and a bad signature. - State mapper — pure unit test of every status you map.
- Methods / create — allow-list rejection, zero amount,
interfaceIdstored.
Coverage threshold is 60% (lines/functions/branches/statements).
Ship It
Section titled “Ship It”pnpm format:fixpnpm --filter @evolve-framework/commercetools-<psp> build # only this catches stale tsdown entriespnpm check # format + tsc + lint + knippnpm changeset status- Barrels list every symbol explicitly — no
export *. It keeps the public surface visible and lets knip find dead exports. - No self-imports through your own package name. Inside the package use
#src/…; reserve@evolve-framework/<pkg>/<subpath>for crossing into another package. - knip must pass. A symbol used only inside its own file must not be exported.
- Write a changeset describing the provider and any config a consumer must set. No PR numbers, ticket keys or requirement codes — consumers read these as release notes.
- Add an
examples/payment-commercetools-<psp>/app with.env.exampleif the provider can run standalone; it doubles as the manual smoke test.
Checklist
Section titled “Checklist”- Checked whether a provider package already exists, or
Remote()suffices. - Copied the closest baseline package (mollie unless a better fit).
-
identifierset; SDK client built insetup(), not the constructor. -
createTransactionForre-checks the method allow-list and stores the PSP id asinterfaceIdviaaddPaymentTransaction. - Webhook re-fetches from the PSP, verifies the signature over the raw body, answers in the PSP’s expected format, is idempotent, tolerates unknown statuses.
- Status mapper is a pure, unit-tested
switch. -
exports+publishConfig.exports+tsdown.config.tsagree;buildrun. - Healthcheck + webhook tests pass against
ctMock; no hand-mocked ct calls. - Registered in a
PaymentRegistry(co-hosted) or behindPaymentProviderModule+Remote()(standalone), gated on config. -
SERVER_URL/serverUrlis externally reachable and includes the prefix. -
pnpm checkgreen, changeset written.
References
Section titled “References”evolve-framework/packages/commercetools/src/payments/—adapter.ts,registry.ts,publisher.ts,remote-adapter.ts,schema.ts.evolve-framework/packages/commercetools/src/modules/payment-provider.ts— hosting a provider as a service.evolve-framework/packages/commercetools/src/lib/payment/checkout.ts— what checkout does around your adapter (method namespacing, payment creation).evolve-framework/packages/commercetools-mollie/— reference implementation.evolve-framework/examples/payment-commercetools-mollie/— standalone service.evolve-framework/examples/commercetools-contentful/src/module.ts— co-hosted.evolve-framework/AGENTS.md— repo conventions, testing, changesets.

