Adding a payment provider
Each payment provider in Evolve is a framework package that communicates with its PSP and updates commercetools Payment objects. The package is mounted by a thin platform service, and the checkout service orchestrates the flow and reacts to payment state changes through events. This guide walks through creating a new provider, using a fictional PSP called “Acme” as the example.
1. Author the framework package
Section titled “1. Author the framework package”Payment providers live in the framework repository as
@evolve-framework/commercetools-<provider> packages. Use
evolve-framework/packages/commercetools-stripe as the reference
implementation:
Directorypackages/commercetools-acme/
Directorysrc/
- index.ts Public exports (module, adapter, client)
- module.ts AcmePaymentModule
- adapter.ts AcmePaymentAdapter
Directorylib/
- client.ts PSP SDK client setup
Directorycore/ PSP-specific transaction and methods logic
- …
Directoryroutes/
- healthcheck.ts
- push.ts Webhook handler
- redirect.ts Customer redirect back from the PSP
- package.json
Implement the payment adapter
Section titled “Implement the payment adapter”The payment contract lives in @evolve-framework/commercetools/payments. It
provides the abstract PaymentAdapter class with two methods to implement
(createTransaction and getPaymentMethods) and ready-made commercetools
helpers (addPaymentTransaction, updatePayment, getTargetByPaymentId,
updateTarget) that handle Payment updates and state transitions for you:
import type { ByProjectKeyRequestBuilder, Cart, Order, Payment,} from "@commercetools/platform-sdk";import { PaymentAdapter, type TransactionRequest, type TransactionResponse,} from "@evolve-framework/commercetools/payments";import type { AcmeClient } from "#src/lib/client.ts";
export class AcmePaymentAdapter extends PaymentAdapter { private readonly acmeApi: AcmeClient;
constructor(ctApi: ByProjectKeyRequestBuilder, acmeApi: AcmeClient) { super(ctApi); this.acmeApi = acmeApi; }
public readonly createTransaction: PaymentAdapter["createTransaction"] = async ( data: TransactionRequest, target: Cart | Order, payment: Payment, ): Promise<TransactionResponse> => { // Call your PSP SDK to start a transaction const result = await this.acmeApi.createTransaction({ amount: data.amount.centAmount, currency: data.amount.currency, returnUrl: data.successUrl, });
// Record an initial Transaction on the commercetools Payment await this.addPaymentTransaction(payment, { transactionId: result.transactionId, state: "Pending", transactionStatus: result.status, transactionCode: result.statusCode, });
return { status: "OK", payload: { redirectURL: result.redirectUrl }, }; };
public readonly getPaymentMethods: PaymentAdapter["getPaymentMethods"] = async (source, device) => { const methods = await this.acmeApi.listMethods(); return methods.map((method) => ({ id: method.id, name: { "en-GB": method.label }, })); };}addPaymentTransaction records the transaction, sets the status interface
code/text, and transitions the Payment state — you don’t write raw update
actions.
Wrap the adapter in a module
Section titled “Wrap the adapter in a module”The module extends AbstractModule from @evolve-framework/core. The PSP
client is built in init() (constructors must stay cheap and side-effect
free), and getHttpConfig() registers the routes:
import type { ClientFactory } from "@evolve-framework/commercetools";import { paymentServiceHandlers } from "@evolve-framework/commercetools/payments";import { AbstractModule, type HttpConfig } from "@evolve-framework/core";import { AcmePaymentAdapter } from "#src/adapter.ts";import { createAcmeClient, type AcmeClient, type AcmeConfig } from "#src/lib/client.ts";import { createHealthcheckHandler } from "#src/routes/healthcheck.ts";import { createPushHandler } from "#src/routes/push.ts";import { createRedirectHandler } from "#src/routes/redirect.ts";
export type AcmePaymentModuleOptions = { config: AcmeConfig; clientFactory: ClientFactory; internalRoutes?: boolean; externalRoutes?: boolean;};
export class AcmePaymentModule extends AbstractModule { private readonly moduleOptions: AcmePaymentModuleOptions; private acmeClient!: AcmeClient;
constructor(options: AcmePaymentModuleOptions) { super(); this.moduleOptions = options; }
override init(): void { this.acmeClient = createAcmeClient(this.moduleOptions.config); }
override getHttpConfig(): HttpConfig { const { config, clientFactory } = this.moduleOptions; const adapter = new AcmePaymentAdapter( clientFactory.getSystemRequestBuilder(), this.acmeClient, );
return { routes: (app): void => { app.get("/healthcheck", createHealthcheckHandler());
if (this.moduleOptions.internalRoutes ?? true) { app.register(paymentServiceHandlers, { paymentAdapter: adapter }); }
if (this.moduleOptions.externalRoutes ?? true) { app.post("/push", createPushHandler({ adapter, webhookSecret: config.webhookSecret })); app.get("/redirect", createRedirectHandler(adapter)); } }, }; }}The routes split into two groups:
| Route | Group | Purpose |
|---|---|---|
POST /payment-methods |
internal | Return available methods for the given cart or order |
POST /create |
internal | Start a transaction and return a redirect URL |
POST /push |
external | Receive webhook callbacks from the PSP |
GET /redirect |
external | Handle customer redirect back from the PSP |
The internal routes are the checkout-facing contract and come for free from
paymentServiceHandlers — they validate requests against the shared TypeBox
schemas and delegate to your adapter. Only the external, PSP-facing routes are
handwritten. The /push handler must verify webhook authenticity; if your PSP
requires raw body verification (e.g., Stripe), register fastify-raw-body
inside getHttpConfig() — see StripePaymentModule in
packages/commercetools-stripe/src/module.ts for the pattern.
Publish the package to your own registry, or keep it as a workspace package
inside your project’s monorepo — either way, pin it in the catalog:
section of pnpm-workspace.yaml like the other framework packages. If the
provider is broadly useful, consider submitting it upstream as an official
@evolve-framework/commercetools-<provider> package.
2. Create the thin platform service
Section titled “2. Create the thin platform service”The platform side is a small mount at
backend/services/payment-commercetools-<provider>/ with just four source
files plus run.ts and terraform/. The config extends
CommercetoolsConfig, and the server wires the module into a
DomainService:
import { AcmePaymentModule } from "@evolve-framework/commercetools-acme";import { createDomainService } from "@evolve-framework/core";import { clientFactory, initClientFactory } from "#src/commercetools.ts";import { config, loadConfig } from "#src/config.ts";
export const startServer = async (): Promise<void> => { await loadConfig(); await initClientFactory(); const service = await createDomainService({ name: "payment-commercetools-acme", module: new AcmePaymentModule({ config: { apiKey: config.ACME_API_KEY, webhookSecret: config.ACME_WEBHOOK_SECRET, serverUrl: config.SERVER_URL, }, clientFactory, internalRoutes: true, externalRoutes: true, }), http: { address: { host: config.HTTP_HOST, port: config.HTTP_PORT, }, }, }); await service.start();};The ClientFactory comes from @evolve-framework/commercetools:
import { ClientFactory, getClientOptions,} from "@evolve-framework/commercetools";import { config } from "#src/config.ts";
export let clientFactory!: ClientFactory;
export const initClientFactory = async (): Promise<void> => { const options = await getClientOptions(config); clientFactory = ClientFactory.create(options);};Add the framework package to the service’s package.json with catalog: as
the version and add the pinned version to the catalog: section of
pnpm-workspace.yaml. Compare with
backend/services/payment-commercetools-stripe/src/ for the complete set of
files.
The internalRoutes/externalRoutes flags exist so you can split the
deployment: one instance exposed only to checkout, another exposed publicly
for webhooks and redirects. Locally, run both in one process.
3. Register with the checkout service
Section titled “3. Register with the checkout service”Add your provider’s endpoint to the checkout service configuration so it knows where to route payment requests:
CHECKOUT_PAYMENT_SERVICE_ENDPOINTS='{"acme": "http://payment-acme:3000"}'The checkout service builds a PaymentProvider for each entry in this map.
availablePaymentMethods fans out POST /payment-methods across all
providers and prefixes each method id with the provider identifier
(acme-ideal); createPayment and checkoutComplete route POST /create to
the provider matching the selected method.
4. Handle payment state transitions
Section titled “4. Handle payment state transitions”Update the commercetools Payment state to one of the standard states:
| State | When |
|---|---|
PaymentInitial |
Payment created (set automatically by API extension) |
PaymentPending |
Transaction started, awaiting PSP result |
PaymentSuccess |
PSP confirms payment succeeded |
PaymentCancelled |
Customer cancelled at PSP |
PaymentFailure |
PSP reports a failure |
When the state changes, commercetools emits a
PaymentStatusStateTransition event. The checkout service subscribes to
this event and updates the order accordingly (e.g., marking it as paid when
all payments succeed).
5. Add Terraform
Section titled “5. Add Terraform”Define the service infrastructure and commercetools payment states in
Terraform. Follow the patterns in existing payment services such as
backend/services/payment-commercetools-stripe/terraform/.
Further reading
Section titled “Further reading”- Payment architecture for the full payment flow and state machine
- REST endpoints and webhooks for webhook verification patterns per provider
- Messaging and events for how payment state transitions trigger downstream processing

