Skip to content

Add a payment provider

Each payment provider is one package with one adapter. The adapters run on commercetools: they read and write the cart, order, and payment there, and talk to the provider for the rest. The other commerce engines have no payment provider packages yet.

Provider Package Adapter Identifier
Adyen @evolve-framework/commercetools-adyen AdyenPaymentAdapter adyen
Stripe @evolve-framework/commercetools-stripe StripePaymentAdapter stripe
Mollie @evolve-framework/commercetools-mollie MolliePaymentAdapter mollie
Pay.nl @evolve-framework/commercetools-paynl PaynlPaymentAdapter paynl
Buckaroo @evolve-framework/commercetools-buckaroo BuckarooPaymentAdapter buckaroo
Pay on account @evolve-framework/commercetools-pay-on-account PayOnAccountPaymentAdapter invoice

The identifier is the provider’s name in your service. It becomes the paymentInterface on every commercetools payment the provider starts, and the provider on each payment method the storefront lists.

Pay on account has no external provider. It offers the invoice method only to a business unit whose canPayOnAccount custom field is true, and records the payment as credit granted instead of charging anything.

The commerce modules that take payments read their providers from one PaymentRegistry. You register each provider in it, in one of two ways:

  • In-process. Register the provider’s adapter. It runs inside your commerce service, and PaymentModule mounts its webhook and redirect routes under /<identifier>.
  • Remote. Run the adapter in a service of its own with PaymentProviderModule, and register Remote(...) in your commerce service. Checkout calls the provider service over HTTP, and the webhook routes live on the provider service.

Both are a PaymentAdapter, so checkout behaves the same either way.

  • Payment states. The framework moves payments through State resources of type PaymentState with fixed keys: PaymentInitial, PaymentPending, PaymentSuccess, PaymentFailure, PaymentRefunded, PaymentCancelled, and PaymentCreditGranted. Provision all of them. PAYMENT_STATE_KEYS lists them.
  • A payment custom type. PaymentModule takes its key and checks at startup that it exists.
  • Pay on account only. A Boolean canPayOnAccount field on your business unit custom type. The adapter refuses to start when the PaymentCreditGranted state is missing.

Install the provider package in your commerce service:

pnpm add @evolve-framework/commercetools-mollie

Register the adapter, and give the registry to every module that takes payments. InvoiceModule takes it too, when invoices can be paid online.

import {
CartModule,
CheckoutModule,
type ClientFactory,
OrderModule,
PaymentModule,
} from "@evolve-framework/commercetools";
import { PaymentRegistry } from "@evolve-framework/commercetools/payments";
import { MolliePaymentAdapter } from "@evolve-framework/commercetools-mollie";
export const createModules = (clientFactory: ClientFactory) => {
const payments = new PaymentRegistry().register(
new MolliePaymentAdapter({
clientFactory,
config: {
apiKey: process.env.MOLLIE_API_KEY!,
serverUrl: `${process.env.SERVER_URL}/mollie`,
},
}),
);
const customTypes = {
order: "my-order",
"line-item": "my-line-item",
payment: "my-payment",
};
return [
new CartModule({ clientFactory, payments, customTypes }),
new CheckoutModule({ clientFactory, payments }),
new OrderModule({ clientFactory, payments }),
new PaymentModule({ clientFactory, payments, customTypes }),
];
};

serverUrl is the public URL the adapter’s own routes are reached at. In-process they sit under /<identifier>, so the URL ends in /mollie. Pass routePrefix: "/payments" to PaymentModule to move them to /payments/<identifier>/…, and extend serverUrl to match.

The variable names in these examples are yours to choose. The adapters take plain values, not environment variables.

import { AdyenPaymentAdapter } from "@evolve-framework/commercetools-adyen";
import { NoopPublisher } from "@evolve-framework/core/messaging";
new AdyenPaymentAdapter({
clientFactory,
config: {
apiKey: process.env.ADYEN_API_KEY!,
hmacKey: process.env.ADYEN_HMAC_KEY!,
merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT!,
environment: "TEST",
serverUrl: `${process.env.SERVER_URL}/adyen`,
},
messagePublisher: new NoopPublisher(),
});

The webhook verifies each notification’s HMAC signature, publishes it through messagePublisher, and answers 202. A consumer applies it to commercetools by calling processNotification(adapter, notification). With a NoopPublisher, notifications are verified but never applied, so pass a publisher from a cloud adapter in a real environment.

The provider service hosts the adapter with PaymentProviderModule. It has no GraphQL schema and is not part of the supergraph. It serves no healthcheck of its own, so compose a HealthModule with graphql: false beside it.

import {
ClientFactory,
type CommercetoolsLoaderContext,
PaymentProviderModule,
} from "@evolve-framework/commercetools";
import { MolliePaymentAdapter } from "@evolve-framework/commercetools-mollie";
import {
CompositeModule,
createApplication,
HealthModule,
} from "@evolve-framework/core";
const clientFactory = ClientFactory.create({
projectKey: process.env.CTP_PROJECT_KEY!,
apiUrl: process.env.CTP_API_URL!,
authUrl: process.env.CTP_AUTH_URL!,
clientId: process.env.CTP_CLIENT_ID!,
clientSecret: process.env.CTP_CLIENT_SECRET!,
scopes: process.env.CTP_SCOPES?.split(",") ?? [],
});
const app = await createApplication({
name: "payment-mollie",
module: new CompositeModule<CommercetoolsLoaderContext>([
new PaymentProviderModule({
adapter: new MolliePaymentAdapter({
clientFactory,
config: {
apiKey: process.env.MOLLIE_API_KEY!,
serverUrl: process.env.SERVER_URL!,
},
}),
secret: process.env.PAYMENT_SERVICE_SECRET,
}),
new HealthModule({ name: "payment-mollie", graphql: false }),
]),
http: {
address: {
host: process.env.HTTP_HOST ?? "localhost",
port: Number(process.env.HTTP_PORT ?? 4026),
},
},
});
await app.start();

Here the routes sit at the service root, so serverUrl is the service’s own public URL.

In your commerce service, register a remote adapter under the provider’s identifier:

import { PaymentRegistry, Remote } from "@evolve-framework/commercetools/payments";
const payments = new PaymentRegistry().register(
Remote("mollie", process.env.PAYMENT_MOLLIE_URL!, {
secret: process.env.PAYMENT_SERVICE_SECRET,
}),
);
  • PaymentProviderModule publishes POST /payment-methods, POST /create, and POST /validate-apple-pay-session when the adapter supports Apple Pay. Turn them off with internalRoutes: false.
  • It mounts the adapter’s webhook and redirect routes. Turn them off with externalRoutes: false.
  • secret guards only the internal routes: callers must send authorization: Bearer <secret>. The webhook stays open to the provider. Leave secret unset only when the network already protects the internal routes.
  • A remote call times out after 5 seconds by default. Set timeoutMs to change it.
  • The Adyen adapter limits how long an unfinished redirect counts as in flight (redirectWindowMs, one hour by default). A remote adapter cannot ask, so pass inFlightWindowMs to Remote(...) if you rely on it.

See PaymentProviderModule and RemotePaymentAdapter for every option.

The provider returns the shopper through a redirect route and confirms the payment through a webhook. The redirect is best effort, because a shopper can close the tab. The webhook is the authoritative status update. Paths are relative to serverUrl.

Provider Routes What you configure at the provider Test mode
Adyen GET /redirect, POST /push /push as the webhook for standard notifications environment: "TEST" (the default) or "LIVE"
Stripe GET /redirect, POST /push /push as a webhook endpoint, and its signing secret No mode option
Mollie POST /webhook, POST /payment-status Nothing: each payment sends its webhook URL A test_… or live_… API key
Pay.nl GET /redirect, POST /webhook Nothing: each payment sends its exchange URL Any environment other than "production" uses the sandbox
Buckaroo POST /redirect, POST /push /push as the push URL in your website settings mode: "TEST" or "LIVE"
Pay on account None Nothing Not applicable

serverUrl has to be reachable from the internet for the webhooks to arrive. Mollie does not deliver webhooks to localhost.

The storefront checkout comes from @evolve-storefront/feature-cart, and paying for an order from @evolve-storefront/feature-order. The storefront hard-codes no provider: it lists whatever the registry offers.

  1. List the methods. useAvailablePaymentMethods asks every registered provider for the methods it offers on this cart.
  2. Choose one. useSetPaymentMethod stores the choice on the cart, and the checkout’s paymentSection is complete once the cart has one. Choosing a method charges nothing.
  3. Pay. Pick one of two flows when you build the storefront:
Flow Calls Backend
Order first usePlaceOrder, then usePayOrder CheckoutModule default, orderCreationStrategy: "before-payment"
Payment first usePayCart(returnUrls) orderCreationStrategy: "after-payment" on CheckoutModule

Both flows send the shopper to the provider with three return URLs: successUrl, cancelUrl, and failureUrl. For order first, set them as returnUrls on the OrderProvider of feature-order. OrderPayNow renders the pay control on an unpaid order.

Payment first cannot be combined with quotes or approvals, and CheckoutModule refuses to start when it is. Quotes are on by default, so pass quotes: false with orderCreationStrategy: "after-payment".