Connecting any payment gateway to Commerce Layer with external payments.
Learn how to connect any payment provider to Commerce Layer using external payment gateways. This tutorial walks through an async Mollie integration with TypeScript, webhooks, place and authorize payment flows.
One of Commerce Layer's most powerful yet underrated capabilities is its external payment gateway — a first-class integration point that lets you connect any payment provider to Commerce Layer without writing a custom integration from scratch. If a provider has an API, you can bridge it in.
In this article we'll explore what the external gateway is, why it matters for complex commerce architectures, and walk through a working proof of concept that integrates Mollie as a third-party gateway using two different order-placement strategies:
- Place
- Authorize
Why external gateways matter
Commerce Layer ships with first-party connectors for Stripe, Braintree, PayPal, Klarna, Adyen, and several others. But the real world is messy: your enterprise client in Brazil uses a local acquirer you've never heard of; your MENA merchant requires a gateway that only speaks Arabic-locale APIs; your fintech customer needs a white-labelled payment solution that wraps their own banking infrastructure.
The external payment gateway is the answer to all of these. Rather than waiting for a native connector, you expose a small HTTP endpoint that speaks Commerce Layer's webhook protocol. Commerce Layer will call that endpoint at the right moment in the order lifecycle — and your code decides what to do with the request, talking to any third-party API it needs to.
The result is a clean, provider-agnostic payment layer where the Commerce Layer order state machine stays authoritative, and the payment complexity lives entirely in your gateway microservice.
How it works
The contract is deliberately minimal:
- You register one or more URLs in the Commerce Layer dashboard (one per lifecycle action — authorize, capture, void, refund, token).
- Commerce Layer signs every outbound request with an HMAC-SHA256 header.
- Your endpoint verifies the signature, does whatever your provider requires, and replies.
For synchronous gateways, a 200 OK with success: true is enough to advance the order state machine. For providers that settle asynchronously — hosted payment pages, bank redirects, delayed captures — you return 202 Accepted with a unique action_id. Commerce Layer parks the transaction in a pending state and waits. When your gateway later POSTs to Commerce Layer's webhook_endpoint_url with that same action_id, the authorization is completed and the order lifecycle continues.
Prerequisites
- Familiarity with REST APIs, webhooks, and HMAC-based request signing.
- Confidence reading and writing TypeScript in a Node.js server context.
- A Commerce Layer account with: a configured market with at least one SKU, inventory entry, and shipping method, a set of sales channel API credentials scoped to that market, a set of integration API credentials (used by the gateway server).
- A Mollie account with a test API key.
- ngrok or cloudflare to expose your local gateway to the internet.
The proof of concept
The companion repository is a minimal TypeScript monorepo with two packages:
Package | Role |
|---|---|
| Vite + React SPA — builds the order and shows the async status. |
| Hono HTTP server — the gateway bridge between Commerce Layer and Mollie. |
The demo keeps both packages small so you can follow every step of the flow.
Setting up the external gateway in Commerce Layer
Before writing any code, you need to register your gateway in the Commerce Layer dashboard.
Go to Settings → Payment gateways → Add payment gateway → External and fill in:
- Name — anything descriptive (e.g.
Mollie Gateway) - Authorize URL — the public URL where Commerce Layer will POST when an order is placed (e.g.
https://your-tunnel.ngrok.io/authorize)
After saving you'll receive two values you'll need to copy:
- Shared secret — used to sign and verify webhook payloads (stored as
CL_GATEWAY_SHARED_SECRET). - Webhook endpoint URL — the URL your gateway calls back to complete an async authorization (stored as
CL_WEBHOOK_ENDPOINT_URL).
Then attach this gateway to your market under Markets → Your market → Payment methods.
Two strategies for the same result
The demo exposes both ways the frontend can trigger the authorization step.
Strategy 1 — Place
The classic checkout flow. A single PATCH on the order with _place: true both finalizes the order and triggers the authorization. Commerce Layer sends the POST /authorize request to your gateway synchronously as part of that transition.
await cl.orders.update({ id: order.id, _place: true })
Strategy 2 — Authorize
The authorization-first flow. The frontend triggers authorization explicitly with _authorize: true. If Auto place is enabled on the payment method, a successful authorization automatically places the order — useful when you want to separate the "confirm payment" step from the "submit order" step, or when a multi-step checkout UI needs finer control.
await cl.orders.update({ id: order.id, _authorize: true })
Inside the gateway: the authorize endpoint
This is the core of the async contract. The /authorize route receives a signed request from Commerce Layer, extracts the Mollie payment ID that the frontend stored in payment_source_token, and responds with 202 Accepted + an action_id to signal that authorization is in progress.
authorizeRoute.post('/', async (c) => {
const rawBody = await c.req.text()
const signature = c.req.header('X-CommerceLayer-Signature')
if (!verifySignature(rawBody, signature)) {
return c.json({ error: { code: 'INVALID_SIGNATURE' } }, 401)
}
const body = JSON.parse(rawBody)
const paymentSourceToken = body?.data?.attributes?.payment_source_token
// Use the Mollie payment ID as the action_id — we'll send it back
// to CL's webhook_endpoint_url once Mollie confirms the payment.
return c.json(
{
success: true,
data: {
transaction_token: `cl-txn-${Date.now()}`,
action_id: paymentSourceToken,
},
},
202, // 202 = async flow started
)
})
Inside the gateway: closing the loop
When the customer completes payment on the Mollie-hosted page, Mollie fires a POST to our /mollie-webhook endpoint with a form-encoded body containing a single field: the ID of the payment. The handler parses that ID, instantiates a Mollie client, and fetches the current payment status:
mollieWebhookRoute.post('/', async (c) => {
const body = await c.req.parseBody()
const paymentId = body['id'] as string | undefined
if (!paymentId) return c.text('Missing id', 400)
const mollie = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY })
const payment = await mollie.payments.get(paymentId)
if (payment.status === 'paid') {
await notifyCL(paymentId)
}
// Mollie expects a 200 OK — always return it regardless of our processing
return c.text('OK')
})
The paid guard is important: Mollie webhooks are status-agnostic — they fire for every status change (open, pending, failed, expired, etc.), not only for successful payments. We only want to notify Commerce Layer when the payment has actually been confirmed.
Once we know the payment is paid, we call notifyCL. This is where we close the async loop that was opened by the 202 Accepted response in the authorize endpoint. We build a payload with success: true and the action_id set to the Mollie payment ID (which is the same value we stored as action_id when we created the payment), sign it with the shared secret, and POST it to Commerce Layer's webhook_endpoint_url:
async function notifyCL(actionId: string) {
const payload = {
success: true,
data: {
action_id: actionId,
message: 'Payment authorized via Mollie',
},
}
const rawBody = JSON.stringify(payload)
const signature = createHmac('sha256', process.env.CL_GATEWAY_SHARED_SECRET)
.update(rawBody)
.digest('base64')
await fetch(process.env.CL_WEBHOOK_ENDPOINT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CommerceLayer-Signature': signature,
},
body: rawBody,
})
}
Commerce Layer receives the callback, matches the action_id to the pending authorization, and transitions the payment — and the order — to authorized. The frontend polls the order status and shows the confirmation screen once the transition is detected.
Resilience in production
The demo's notifyCL function is intentionally minimal. In a production gateway you'll want to harden this path:
- Retry with backoff
Commerce Layer'swebhook_endpoint_urlmay be temporarily unavailable or return a transient error. Wrap the call in a retry loop (exponential backoff, 3–5 attempts) before giving up. - Persist the action ID
Store the pendingaction_idin a durable queue or database so that a process restart between the provider webhook and the CL callback doesn't leave the authorization permanently pending. - Handle provider webhook deduplication
Mollie (and most providers) may deliver the same webhook event more than once. Your handler should be idempotent — check whether theaction_idhas already been forwarded before calling Commerce Layer again. - Alert on terminal failure
If the CL callback ultimately fails after all retries, the order will remain in an indeterminate state. Surface this to your operations team immediately rather than silently discarding the error.
Commerce Layer's own circuit breaker will open after repeated failures from your gateway, temporarily suspending calls to your endpoint — so a well-behaved gateway that retries and self-heals is important for keeping the circuit closed. See the docs for thresholds and recovery behaviour:
Beyond authorization: the full gateway contract
This demo focuses on the authorize step because it's the entry point of every payment flow, and it's where the async pattern is most interesting. But the external gateway protocol covers the entire payment lifecycle. For each action you want to support, you register a dedicated URL in the gateway settings and implement the corresponding endpoint:
Action | URL field | When it's called |
|---|---|---|
Authorize |
| When an order is placed or |
Capture |
| When an authorization is captured (e.g. at fulfilment). |
Void |
| When an authorization is cancelled before capture. |
Refund |
| When a captured payment is refunded, fully or partially. |
Token |
| When a customer saves a payment source for future use. |
Each endpoint receives a signed JSON:API payload with the relevant transaction resource and a set of pre-included relationships (order, line_items, billing_address, customer, and more) so your gateway has all the context it needs without extra roundtrips to the Commerce Layer API.
The response contract is consistent across all actions: return a transaction_token, the amount_cents involved, and optionally metadata and messages (which Commerce Layer will attach as notifications to the order). Return an HTTP 4xx on failure.
All actions except token_url also support the async 202 pattern described above — meaning you can defer capture, void, and refund confirmations just as you can defer authorization.
The demo repository implements the authorize endpoint and the async webhook callback. Extending it to cover capture, void, and refund follows exactly the same structure: a new route file, the same HMAC verification helper, and a POST to whichever third-party API your provider exposes.
The full protocol reference is available in the Commerce Layer docs.
Running the demo locally
You can run the demo on your local machine by following these simple steps:
# Clone and install
git clone https://github.com/commercelayer/examples.git
cd examples/solutions/external-payment-gateway
pnpm install
# Copy and fill in environment variables
cp packages/mollie-gateway/.env.example packages/mollie-gateway/.env
cp packages/app/.env.example packages/app/.env
# Start a tunnel so Commerce Layer can reach your local gateway
ngrok http 3001
# → copy the https URL into GATEWAY_PUBLIC_URL and the authorize URL in the CL dashboard
# Start both packages
pnpm dev
Open http://localhost:5173, choose either the _place or _authorize flow, and watch the async payment lifecycle unfold in real time.
Conclusion
The external payment gateway is what makes Commerce Layer viable for enterprise deals that standard connectors can't cover. A local acquirer in Southeast Asia with a bespoke API, a regulated PSP in the Middle East that requires custom authentication flows, a unified commerce platform that needs to route payments through an in-house settlement layer — all of these map cleanly onto the same protocol. You write a gateway microservice that speaks to your provider; Commerce Layer owns the order state machine.
The Mollie demo is a concrete, runnable starting point. The async 202 pattern it demonstrates applies unchanged to any provider with a hosted payment page or delayed settlement. Swap the Mollie SDK calls for your provider's client library and the scaffold remains the same.