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.
How a provider fits in
Section titled “How a provider fits in”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
PaymentModulemounts its webhook and redirect routes under/<identifier>. - Remote. Run the adapter in a service of its own with
PaymentProviderModule, and registerRemote(...)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.
Prepare commercetools
Section titled “Prepare commercetools”- Payment states. The framework moves payments through
Stateresources of typePaymentStatewith fixed keys:PaymentInitial,PaymentPending,PaymentSuccess,PaymentFailure,PaymentRefunded,PaymentCancelled, andPaymentCreditGranted. Provision all of them.PAYMENT_STATE_KEYSlists them. - A payment custom type.
PaymentModuletakes its key and checks at startup that it exists. - Pay on account only. A Boolean
canPayOnAccountfield on your business unit custom type. The adapter refuses to start when thePaymentCreditGrantedstate is missing.
Add a provider in-process
Section titled “Add a provider in-process”Install the provider package in your commerce service:
pnpm add @evolve-framework/commercetools-mollieRegister 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.
Configure each provider
Section titled “Configure each provider”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.
import { StripePaymentAdapter } from "@evolve-framework/commercetools-stripe";
new StripePaymentAdapter({ clientFactory, config: { apiKey: process.env.STRIPE_API_KEY!, account: process.env.STRIPE_ACCOUNT!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, serverUrl: `${process.env.SERVER_URL}/stripe`, },});webhookSecret is the signing secret (whsec_…) of the webhook endpoint you
register in Stripe.
import { MolliePaymentAdapter } from "@evolve-framework/commercetools-mollie";
new MolliePaymentAdapter({ clientFactory, config: { apiKey: process.env.MOLLIE_API_KEY!, serverUrl: `${process.env.SERVER_URL}/mollie`, },});Mollie webhooks carry only a payment id, so the handler fetches the status from the Mollie API instead of trusting the request.
import { cache } from "@evolve-framework/core/cache";import { PaynlPaymentAdapter } from "@evolve-framework/commercetools-paynl";
await cache.configure({ url: process.env.REDIS_URL });
new PaynlPaymentAdapter({ clientFactory, config: { credentials: { atCode: process.env.PAYNL_AT_CODE!, apiToken: process.env.PAYNL_API_TOKEN!, serviceId: process.env.PAYNL_SERVICE_ID!, serviceSecret: process.env.PAYNL_SERVICE_SECRET!, }, environment: process.env.ENVIRONMENT ?? "development", serverUrl: `${process.env.SERVER_URL}/paynl`, },});The adapter caches the payment methods Pay.nl offers through
@evolve-framework/core/cache. paymentLogos optionally replaces a method’s
logo, keyed by Pay.nl tag.
import { BuckarooPaymentAdapter } from "@evolve-framework/commercetools-buckaroo";
new BuckarooPaymentAdapter({ clientFactory, config: { credentials: { secretKey: process.env.BUCKAROO_SECRET_KEY!, websiteKey: process.env.BUCKAROO_WEBSITE_KEY!, }, mode: "TEST", currency: "EUR", serverUrl: `${process.env.SERVER_URL}/buckaroo`, },});import { PayOnAccountPaymentAdapter } from "@evolve-framework/commercetools-pay-on-account";
new PayOnAccountPaymentAdapter({ clientFactory });No credentials and no routes: nothing outside your project calls back.
Run a provider as its own service
Section titled “Run a provider as its own service”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, }),);PaymentProviderModulepublishesPOST /payment-methods,POST /create, andPOST /validate-apple-pay-sessionwhen the adapter supports Apple Pay. Turn them off withinternalRoutes: false.- It mounts the adapter’s webhook and redirect routes. Turn them off with
externalRoutes: false. secretguards only the internal routes: callers must sendauthorization: Bearer <secret>. The webhook stays open to the provider. Leavesecretunset only when the network already protects the internal routes.- A remote call times out after 5 seconds by default. Set
timeoutMsto 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 passinFlightWindowMstoRemote(...)if you rely on it.
See PaymentProviderModule
and RemotePaymentAdapter
for every option.
Callbacks and test mode
Section titled “Callbacks and test mode”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.
Checkout in the storefront
Section titled “Checkout in the storefront”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.
- List the methods.
useAvailablePaymentMethodsasks every registered provider for the methods it offers on this cart. - Choose one.
useSetPaymentMethodstores the choice on the cart, and the checkout’spaymentSectionis complete once the cart has one. Choosing a method charges nothing. - 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".

