Skip to content

New: AI agent integration via Model Context Protocol (MCP).Learn more

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.

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

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:

src/adapter.ts
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.

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:

src/module.ts
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.

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:

src/server.ts
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:

src/commercetools.ts
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.

Add your provider’s endpoint to the checkout service configuration so it knows where to route payment requests:

Terminal window
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.

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

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