Skip to content

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.

  • 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.
┌──────────────────── 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.

  1. Does a package already exist? commercetools-{adyen,mollie,stripe,paynl,buckaroo,invoice}. If so this is a wiring job — skip to Wire It Up.
  2. Does it need commercetools access at all? If the provider is an existing service you only need to reach, Remote() is the whole integration.
  3. Pick the closest baseline to copy. This matters more than starting clean:
    • commercetools-mollie — redirect flow, webhook by interfaceId, 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.
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.md

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

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

  1. 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. Throw PaymentError("Payment method not allowed").
  2. Create the payment at the PSP.
  3. Record it with addPaymentTransaction, storing the PSP’s id as interfaceId — that field is the only join between a webhook and the commercetools payment. Open at state: "Pending".
  4. 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 key
const 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 a preValidation plugin (see commercetools-paynl).
  • Answer in the PSP’s dialect. Mollie wants text/plain TRUE/FALSE; others want 200 with a specific body. A generic Fastify error page reads as a failure and triggers redelivery storms. Set an errorHandler on 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.

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.

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.

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.

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.ts
export const mswServer = setupServer(...(ctMock.getHandlers() as unknown as RequestHandler[]));
// vitest.setup.ts
beforeAll(() => 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 real createApplication + PaymentProviderModule and injects GET /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, interfaceId stored.

Coverage threshold is 60% (lines/functions/branches/statements).

pnpm format:fix
pnpm --filter @evolve-framework/commercetools-<psp> build # only this catches stale tsdown entries
pnpm check # format + tsc + lint + knip
pnpm 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.example if the provider can run standalone; it doubles as the manual smoke test.
  • Checked whether a provider package already exists, or Remote() suffices.
  • Copied the closest baseline package (mollie unless a better fit).
  • identifier set; SDK client built in setup(), not the constructor.
  • createTransactionFor re-checks the method allow-list and stores the PSP id as interfaceId via addPaymentTransaction.
  • 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.ts agree; build run.
  • Healthcheck + webhook tests pass against ctMock; no hand-mocked ct calls.
  • Registered in a PaymentRegistry (co-hosted) or behind PaymentProviderModule + Remote() (standalone), gated on config.
  • SERVER_URL/serverUrl is externally reachable and includes the prefix.
  • pnpm check green, changeset written.
  • 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.