Checkout.com is an enterprise acquirer and gateway for cards, wallets, and local payment methods. This guide uses the official checkout-sdk-node (5.x) on the server, Flow (Checkout.com's embedded Web Components) in the browser on top of a payment session created by your API, and core verifyWebhookSignature() for the Cko-Signature header. The Hosted Payments Page is covered as the redirect alternative.
What you should know up front
Payment sessions are server-side. Your API calls POST /payment-sessions with the amount, currency, and reference from the stored order, and returns the session to the browser. Flow renders the payment methods and runs the payment, including 3-D Secure and other required actions. See never trust client amounts.
Amounts are minor units. GBP 10.00 is amount: 1000. Checkout.com has its own rules for a few currencies, so check its currency and amount guidance before you add a market.
Every account has its own base URL. Requests go to a merchant-specific subdomain: https://{prefix}.api.sandbox.checkout.com in sandbox and https://{prefix}.api.checkout.com in production, where the prefix is the first 8 characters of your client id (without cli_), shown under Dashboard -> Developers -> Overview. The SDK's subdomain option is required.
Idempotency is a header, on some endpoints. Cko-Idempotency-Key is documented for /payments and the payment actions (captures, refunds, voids, authorizations, cancellations), with keys kept for 72 hours by default. /payment-sessions is not on that list.
Webhooks are HMAC-SHA256 hex over the raw body. The Cko-Signature header is keyed with the signature key you generate on the webhook configuration. You can additionally have Checkout.com send a fixed key in the Authorization header.
Acknowledge within 10 seconds. Checkout.com delivers at least once and does not guarantee ordering.
1. Provision
Create a sandbox account and note the account prefix under Developers -> Overview.
Create API keys: a secret key (sk_sbox_...) for the server and a public key (pk_sbox_...) for Flow. Copy the processing channel id (pc_...) your payments should use.
Under the webhook configuration (Developers -> Webhooks or Workflows), add a webhook to https://your-app.example.com/webhooks/checkout-com, select the payment events you need (payment_approved, payment_captured, payment_declined, payment_capture_declined, payment_refunded), choose Generate key for the signature key, and optionally set an authorization header key.
Repeat in production with production keys; sandbox and production keys and signature keys are separate.
2. Install
bash
pnpm add checkout-sdk-node
bash
npm install checkout-sdk-node
bash
yarn add checkout-sdk-node
bash
bun add checkout-sdk-node
For the browser, Flow loads from https://checkout-web-components.checkout.com/index.js or the @checkout.com/checkout-web-components package. Neither is a runtime dependency of @daloyjs/core.
3. Environment variables
.envdotenv
CKO_SECRET_KEY=sk_sbox_replace_me # server onlyCKO_PUBLIC_KEY=pk_sbox_replace_me # public, used by FlowCKO_SUBDOMAIN=replace_me # 8-character account prefixCKO_PROCESSING_CHANNEL_ID=pc_replace_meCKO_WEBHOOK_SIGNATURE_KEY=replace_me # Cko-Signature HMAC keyCKO_WEBHOOK_AUTH_KEY=replace_me # optional Authorization header valueAPP_URL=https://your-app.example.com
4. Plugin
src/plugins/checkout-com.tsts
import { Checkout } from "checkout-sdk-node";import { timingSafeEqual, verifyWebhookSignature } from "@daloyjs/core";import type { App } from "@daloyjs/core";// sk_sbox_ keys select sandbox automatically; sk_ keys select production.const cko = new Checkout(process.env.CKO_SECRET_KEY!, { pk: process.env.CKO_PUBLIC_KEY!, subdomain: process.env.CKO_SUBDOMAIN!, // required: the SDK throws without it});export interface CkoPaymentSession { id: string; payment_session_secret: string; payment_session_token: string;}export interface CkoPayment { id: string; status: string; // for example "Captured" amount: number; balances?: { total_captured?: number; total_refunded?: number }; currency: string; reference?: string; metadata?: Record<string, string>;}export interface CheckoutComClient { createPaymentSession(input: { orderId: string; amountMinor: number; currency: string; billingCountry: string; email?: string; }): Promise<CkoPaymentSession>; getPayment(paymentId: string): Promise<CkoPayment>; refund(input: { paymentId: string; amountMinor: number; reference: string; idempotencyKey: string }): Promise<{ action_id: string }>; verifyWebhook(raw: Uint8Array, headers: Headers): Promise<boolean>;}// Exported so job handlers (which run outside a request) can use it too.export const checkoutComClient: CheckoutComClient = { async createPaymentSession({ orderId, amountMinor, currency, billingCountry, email }) { const base = process.env.APP_URL!; return (await cko.paymentSessions.request({ amount: amountMinor, // minor units, from the stored order currency, reference: orderId, processing_channel_id: process.env.CKO_PROCESSING_CHANNEL_ID!, billing: { address: { country: billingCountry } }, customer: email ? { email } : undefined, metadata: { orderId }, success_url: `${base}/checkout/return?order=${encodeURIComponent(orderId)}`, failure_url: `${base}/checkout/failed?order=${encodeURIComponent(orderId)}`, })) as CkoPaymentSession; }, async getPayment(paymentId) { return (await cko.payments.get(paymentId)) as CkoPayment; }, async refund({ paymentId, amountMinor, reference, idempotencyKey }) { return (await cko.payments.refund(paymentId, { amount: amountMinor, reference }, idempotencyKey)) as { action_id: string }; }, async verifyWebhook(raw, headers) { // Optional second factor: the fixed key set on the webhook configuration. const expectedAuth = process.env.CKO_WEBHOOK_AUTH_KEY; const authOk = expectedAuth ? timingSafeEqual(headers.get("authorization") ?? "", expectedAuth) : true; const sigOk = await verifyWebhookSignature({ payload: raw, signature: headers.get("cko-signature") ?? "", // hex secret: process.env.CKO_WEBHOOK_SIGNATURE_KEY!, algorithm: "sha256", }); return authOk && sigOk; },};// Call with the root app, not through app.register(): a decoration made// inside a registered plugin is visible only to that plugin's own routes.export function checkoutComPlugin(app: App) { app.decorate("checkoutCom", checkoutComClient);}declare module "@daloyjs/core" { interface AppState { checkoutCom: CheckoutComClient; }}
Both webhook checks run before either result is used, so the timing does not reveal which one failed. The SDK's cko.payments.* action methods accept an idempotency key as their last argument and send it as Cko-Idempotency-Key; cko.paymentSessions.request() does not take one, which matches the API's idempotency list.
5. Create a payment session for Flow
Flow on a server-created payment session
BrowserDaloyJS routeCheckout.com
01requestBrowserDaloyJS routePOST /checkout/checkout-com/session{ orderId } only
02requestDaloyJS routeCheckout.comPOST /payment-sessionsamount in minor units + currency + reference from the order
04asyncBrowserCheckout.comFlow pays, runs 3DS if requiredredirect or onPaymentCompleted is a UX hint
The server prices the session from the stored order and sets the order id as the payment reference. Flow handles payment-method selection and authentication; only a verified webhook plus a refetch marks the order paid.
The route relies on the shared app wiring (session, scoped csrf() and rateLimit()) and the overview's orders module:
src/routes/checkout-checkout-com.tsts
import { z } from "zod";import { HttpError, idempotency } from "@daloyjs/core";import { checkoutComPlugin } from "../plugins/checkout-com.ts";import { orders } from "../orders.ts";import { db } from "../db.ts";checkoutComPlugin(app);app.post( "/checkout/checkout-com/session", { operationId: "createCheckoutComPaymentSession", // Safe client retries of this POST (scoped per signed-in user). hooks: idempotency({ scope: (ctx) => ctx.state.session.get<string>("userId") }), request: { body: z.object({ orderId: z.string().min(1).max(80) }).strict() }, responses: { 201: { description: "payment session", body: z.object({ id: z.string(), payment_session_secret: z.string(), payment_session_token: z.string() }), }, }, }, async ({ body, state }) => { const userId = state.session.get<string>("userId"); const order = await orders.forCheckout(body.orderId, userId); const customer = await db.users.billingProfile(userId!); // country (and email) from your records try { const session = await state.checkoutCom.createPaymentSession({ orderId: order.id, amountMinor: order.amountMinor, currency: order.currency, billingCountry: customer.country, email: customer.email, }); return { status: 201, body: { id: session.id, payment_session_secret: session.payment_session_secret, payment_session_token: session.payment_session_token }, }; } catch (e) { const err = e as { name?: string; http_code?: number; body?: { request_id?: string; error_codes?: string[] } }; // Keep Checkout.com's request id and error codes in your logs, not in the response. app.log.warn( { event: "cko.session_failed", name: err.name, status: err.http_code, requestId: err.body?.request_id, codes: err.body?.error_codes }, "Checkout.com rejected the payment session", ); throw new HttpError(502, { type: "https://your-app.example.com/problems/payment-provider", title: "Payment provider error", detail: "Checkout could not be started. Please try again.", }); } },);
Because /payment-sessions has no documented idempotency key, a retry that slips past the idempotency() hook can create a second session. An unpaid session charges nothing, and orders.markPaid() only transitions pending -> paid once; still, reconcile captured payments against orders so a shopper who pays two sessions in two tabs gets the duplicate refunded.
Mount Flow with the session object and your public key:
Flow handles 3-D Secure and other additional actions when the shopper selects Pay. The cko-payment-id on the success redirect and the onPaymentCompleted callback are UX signals only.
02noteDaloyJS routeDaloyJS routeverifyWebhookSignature over the raw bodyHMAC-SHA256 hex, signature key
03responseDaloyJS routeCheckout.com401 when verification fails{ error: 'invalid signature' }
04asyncDaloyJS routeJob workerenqueue keyed by event id, then 200worker refetches the payment and calls orders.markPaid()
Verify the exact bytes, collapse at-least-once redeliveries through the job idempotency key on the event id, and let the worker read the payment's current state so out-of-order events are harmless.
src/webhooks/checkout-com.tsts
import { z } from "zod";import { readBodyLimited, safeJsonParse, jobIdempotencyKey } from "@daloyjs/core";app.post( "/webhooks/checkout-com", { operationId: "checkoutComWebhook", // No request.body schema: the raw bytes stay readable for the HMAC check. responses: { 200: { description: "ack", body: z.object({ ok: z.literal(true) }) }, 401: { description: "bad signature", body: z.object({ error: z.string() }) }, }, }, async ({ request, state }) => { const raw = await readBodyLimited(request, 1_048_576); if (!(await state.checkoutCom.verifyWebhook(raw, request.headers))) { return { status: 401, body: { error: "invalid signature" } }; } const event = safeJsonParse(new TextDecoder().decode(raw)) as { id?: string; type?: string; data?: { id?: string } }; if (!event.id) return { status: 401, body: { error: "missing event id" } }; if (event.type?.startsWith("payment_") && event.data?.id) { await app.jobs!.enqueue({ name: "checkout_com.payment", payload: { eventId: event.id, type: event.type, paymentId: event.data.id }, idempotencyKey: jobIdempotencyKey({ name: "checkout_com.payment", key: event.id }), }); } // Ack every verified event, including types you do not handle. return { status: 200, body: { ok: true as const } }; },);
The worker refetches the payment with GET /payments/{id} and compares the amount and currency with the stored order:
src/jobs/checkout-com.tsts
import type { JobHandler } from "@daloyjs/core";import { checkoutComClient } from "../plugins/checkout-com.ts";import { orders } from "../orders.ts";export const handleCheckoutComPayment: JobHandler<{ eventId: string; paymentId: string }> = async ({ job }) => { const payment = await checkoutComClient.getPayment(job.payload.paymentId); // authoritative state const orderId = payment.metadata?.orderId ?? payment.reference; // balances survive later state changes (a partial refund moves status past "Captured"). const captured = payment.balances?.total_captured ?? 0; if (captured === 0 || !orderId) return; await orders.markPaid(orderId, { amountMinor: captured, currency: payment.currency, providerRef: payment.id, });};// Register it in your single app.useJobs() call (see the payments overview).
The worker keys off balances.total_captured rather than status, so a refund that lands before the job runs does not hide the capture. If your processing channel authorizes without capturing, nothing is marked paid until your own capture call settles.
7. Refunds
Expose refunds only through a protected refund route (auth, role, ownership, amount cap, audit log). Checkout.com strongly recommends V4 UUIDs as idempotency keys, so create one per logical refund, store it with the refund record, and reuse it on every retry of that refund:
Refunds are asynchronous: the API answers with an action_id, and the outcome arrives as a payment_refunded (or payment_refund_declined) webhook. Update your ledger from the verified event.
Hosted Payments Page
If you would rather redirect than embed, create a Hosted Payments Page session with the same server-side amount and send the browser to the returned _links.redirect.href. Add a method like this to the plugin (where cko is in scope); the webhook route and worker above do not change:
Private Link merchants use a pl- prefixed subdomain, which the SDK also accepts. A few services (balances, files, forward, transfers) use fixed hostnames instead of your prefix. This guide does not cover choosing a processing region; the prefix and processing channel configured on your account determine where requests go, so confirm data-residency questions with Checkout.com.
Runtimes
checkout-sdk-node 5.x declares Node >=18 and runs on Node hosts (long-lived Node, Vercel Node.js functions, AWS Lambda, Azure Functions). It imports axios and Node's stream module, so Deno, Cloudflare Workers, and Vercel Edge are not verified here; on those runtimes call the REST API directly with resilientFetch (Authorization: Bearer sk_... against your prefixed base URL, Cko-Idempotency-Key on payment actions). The webhook verification uses only core WebCrypto helpers and runs on every DaloyJS adapter. See the runtime table.
Errors
The SDK throws errors whose name identifies the failure (AuthenticationError 401, ActionNotAllowed 403, NotFoundError 404, ValidationError 422 with error_codes, TooManyRequestsError 429, BadGateway 502). Log the name, status, request_id, and error codes, and return a generic problem+json 502. A concurrent request with the same idempotency key gets 409; wait before retrying rather than generating a new key.
Modernisation notes
Flow over Frames for new builds. Flow wraps payment-method selection, wallets, and authentication in one component on top of a server-created payment session.
Always pass subdomain. Initializing without it now throws; the legacy shared domain is deprecated.
Use the order id as reference. It appears in the Dashboard and reports, and gives the worker a second way to find the order.
Never fulfil from the redirect. The cko-payment-id on the success URL tells you which payment to look up, not that it succeeded.