ecomPayDeveloper docs

ecomPay card payments: developer guide

ecomPay lets any website take card payments with one JSON API and one script tag. Your server creates a checkout session, your page shows the ecomPay card form, and ecomPay tells your server when the customer has paid. Card details are typed into a form served by ecomPay and encrypted in the customer's browser, so they never pass through your site.

How it works

  1. Your server calls POST /v1/checkout/sessions with your secret key and the amount. You get back a session id and a client_secret.
  2. Your page loads ecompay.js, passes the client_secret to EcomPay.checkout() and mounts the card form. The customer pays. Any bank check (3D Secure) happens inside the form.
  3. Your server learns the result from a signed webhook, or by fetching the session with GET /v1/checkout/sessions/{id}. Fulfil the order only when the server has seen status: "paid".

Amounts are whole numbers in the smallest unit of the currency: 4999 is £49.99.

Keys and modes

Every account has a test side and a live side.

Key Where to get it What it does
sk_test_... Dashboard, Developers Test payments with test cards. No real money moves.
sk_live_... Dashboard, Developers, after live account details are added Real payments.

Send the key from your server as Authorization: Bearer sk_test_.... Never put a secret key in browser code, a public repository or a mobile app. Secrets created in the dashboard are shown once; if one leaks, delete it there and create another.

A session created with a test key is a test session (livemode: false, id starts cs_test_). A session created with a live key is live (cs_live_). Test keys cannot see live sessions and the other way round.

Quick start

A complete working example: an Express server and one HTML page.

server.js

import express from 'express';
const app = express();
app.use(express.json());
app.use(express.static('public'));

const ECOMPAY = 'https://api.ecompay.co.uk';
const KEY = process.env.ECOMPAY_SECRET_KEY; // sk_test_... from your dashboard

// 1. The page asks for a checkout session.
app.post('/api/checkout', async (req, res) => {
  const r = await fetch(`${ECOMPAY}/v1/checkout/sessions`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      amount: 4999,                 // £49.99
      currency: 'GBP',
      reference: 'order_1001',      // your order id
      description: 'Blue running shoes, size 9',
      customer: { email: req.body.email },
    }),
  });
  const session = await r.json();
  if (!r.ok) return res.status(400).json(session);
  // Only the client_secret goes to the browser.
  res.json({ clientSecret: session.client_secret, id: session.id });
});

// 3. ecomPay tells us the result. Verify the signature, then mark the order paid.
import crypto from 'node:crypto';
function verifyWebhook(rawBody, header, secret) {
  const p = Object.fromEntries(String(header || '').split(',').map((x) => x.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${p.t}.${rawBody}`).digest('hex');
  const same = expected.length === String(p.v1).length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(String(p.v1)));
  return same && Math.abs(Date.now() / 1000 - Number(p.t)) < 300;
}
const seen = new Set(); // use your database for this in production
app.post('/webhooks/ecompay', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyWebhook(req.body, req.get('Ecompay-Signature'), process.env.ECOMPAY_WEBHOOK_SECRET)) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  if (seen.has(event.id)) return res.sendStatus(200);   // the same event can arrive twice
  seen.add(event.id);
  if (event.type === 'session.completed') {
    markOrderPaid(event.data.reference, event.data.id); // your code
  }
  res.sendStatus(200);
});

// 4. The thank-you page asks the server, never the browser, whether it is really paid.
app.get('/api/order-status', async (req, res) => {
  const r = await fetch(`${ECOMPAY}/v1/checkout/sessions/${req.query.session_id}`, { headers: { Authorization: `Bearer ${KEY}` } });
  const s = await r.json();
  res.json({ paid: r.ok && s.status === 'paid' });
});

app.listen(3000);

public/index.html

<form id="buy">
  <input id="email" type="email" placeholder="Email for your receipt" required>
  <button>Buy for £49.99</button>
</form>
<div id="pay"></div>

<script src="https://api.ecompay.co.uk/v1/ecompay.js"></script>
<script>
  document.getElementById('buy').addEventListener('submit', async (e) => {
    e.preventDefault();
    const r = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: document.getElementById('email').value }),
    });
    const { clientSecret, id } = await r.json();
    EcomPay.checkout({ clientSecret })
      .on('complete', () => { location.href = '/thanks?session_id=' + id; })
      .mount('#pay');
  });
</script>

Run it with ECOMPAY_SECRET_KEY=sk_test_... ECOMPAY_WEBHOOK_SECRET=whsec_... node server.js, open the page and pay with test card 4012 0000 0006 0085, any future expiry, any 3 digits.

The thank-you page calls /api/order-status?session_id=... and shows "paid" only if the server says so. The webhook is the backstop for customers who close the tab before the thank-you page loads.

Local development

Webhook addresses must be public https:// URLs, so ecomPay cannot reach localhost. While developing, either confirm payments with the retrieve call on the thank-you page (enough for most sites), or expose your local server with a tunnel such as ngrok or cloudflared and register that address.

Prompt for AI coding assistants

Building with Claude, Cursor, Copilot or similar? Paste this, then your test key:

Add ecomPay card payments to this site.

Read the integration guide at https://api.ecompay.co.uk/llms-full.txt and follow it exactly.

Summary of what to build:
1. A server route that creates a checkout session with POST https://api.ecompay.co.uk/v1/checkout/sessions, using the secret key from the environment variable ECOMPAY_SECRET_KEY (never in browser code). Send amount in minor units (pence), currency, our order reference and the customer email. Return only the client_secret and session id to the browser.
2. On the checkout page, load <script src="https://api.ecompay.co.uk/v1/ecompay.js"></script>, call EcomPay.checkout({ clientSecret }).on('complete', ...).mount('#pay'), and on complete send the customer to a thank-you page.
3. A webhook route that verifies the Ecompay-Signature header (t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with ECOMPAY_WEBHOOK_SECRET>, constant-time compare, reject if t is older than 5 minutes), then marks the order paid on session.completed. Make it idempotent: the same event can arrive twice, so skip event ids you have seen. The webhook address is registered in the ecomPay dashboard (Developers, Webhook) or with POST https://api.ecompay.co.uk/v1/webhook_endpoints; the secret returned there is ECOMPAY_WEBHOOK_SECRET.
4. On the thank-you page, confirm on the server with GET https://api.ecompay.co.uk/v1/checkout/sessions/{id} and only show "paid" when status is "paid". Never mark an order paid from browser code alone.

Use test card 4012 0000 0006 0085 (any future expiry, any CVC) to try it. Webhook addresses must be public https, so while developing locally rely on step 4 or use a tunnel. Tell me where to put ECOMPAY_SECRET_KEY and ECOMPAY_WEBHOOK_SECRET.

Checkout sessions

A checkout session is one attempt to collect one amount from one customer. It stays open for 30 minutes by default; the customer can retry with another card inside that window.

Create a session

POST /v1/checkout/sessions

Field Type Required Notes
amount integer yes Minor units. 4999 is £49.99. 1 to 99,999,999.
currency string yes 3 letter ISO code, for example GBP. Must be enabled on your account.
reference string no, but strongly recommended Your order id, up to 255 characters. It comes back in every webhook and read, so your code can find the order. Searchable in the dashboard and by GET ?reference=.
description string no Shown to the customer on the form, up to 255 characters.
customer object no email, name (up to 90), phone (up to 32). Give the email if you have it, so the form does not ask again.
billing_address object no line1, line2, city, postal_code, state, country (2 letter code). If you leave it out the form asks the customer for it.
shipping_address object no Same shape as billing.
success_url string no Full https URL. Used by the hosted page after payment, with ?session_id= added.
cancel_url string no Full https URL. "Cancel and go back" link on the hosted page.
expires_in integer no Seconds, 300 to 86,400. Default 1,800.
metadata object no Up to 20 string values (keys up to 40 characters, values up to 500). Returned on every read and in webhooks.
curl https://api.ecompay.co.uk/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"amount": 4999, "currency": "GBP", "reference": "order_1001", "customer": {"email": "sam@example.com"}}'

The session object

{
  "id": "cs_test_8HAbtgvZuqCktLdzMouJJ2EE",
  "object": "checkout.session",
  "status": "open",
  "livemode": false,
  "amount": 4999,
  "amount_refunded": 0,
  "currency": "GBP",
  "reference": "order_1001",
  "description": null,
  "customer": { "email": "sam@example.com" },
  "billing_address": null,
  "shipping_address": null,
  "success_url": null,
  "cancel_url": null,
  "expires_at": "2026-10-15T12:30:00.000Z",
  "attempts": 0,
  "failure_code": null,
  "failure_message": null,
  "payment": null,
  "paid_at": null,
  "created_at": "2026-10-15T12:00:00.000Z",
  "metadata": {},
  "client_secret": "cs_test_8HAbtgvZuqCktLdzMouJJ2EE_secret_...",
  "url": "https://api.ecompay.co.uk/c/cs_test_8HAbtgvZuqCktLdzMouJJ2EE?s=..."
}

Statuses

Status Meaning
open Waiting for the customer. A declined card leaves the session open for another try (up to 5 cards).
processing A card attempt is with the bank. Usually seconds.
paid Money taken. Fulfil the order.
failed Five cards declined, or the bank check never finished. Create a new session to try again.
expired Nobody paid before expires_at.
refunded Fully refunded. Partial refunds keep paid and raise amount_refunded.
disputed The cardholder's bank has opened a chargeback.

paid, failed, expired, refunded and disputed are final apart from the refund and dispute moves shown.

Retrieve a session

GET /v1/checkout/sessions/{id}

If the session is processing, ecomPay checks with the bank before answering, so this is safe to call from a thank-you page. The answer is the session object.

List sessions

GET /v1/checkout/sessions?reference=order_1001&limit=20

Newest first, up to 100. Without reference it returns your latest sessions. Answer: { "object": "list", "data": [ ...sessions ] }.

The card form (ecompay.js)

<div id="pay"></div>
<script src="https://api.ecompay.co.uk/v1/ecompay.js"></script>
<script>
  const checkout = EcomPay.checkout({ clientSecret: CLIENT_SECRET });
  checkout.on('ready', (s) => {});                 // form is showing; s has id, status, amount, currency
  checkout.on('complete', (s) => {});              // the customer has paid (confirm on your server)
  checkout.on('failed', (s) => {});                // session failed after the last allowed attempt
  checkout.on('error', (e) => {});                 // e.code, for example invalid_client_secret
  checkout.mount('#pay');                          // a CSS selector or an element
  // checkout.unmount();
</script>

Hosted page instead of embedding

Every session has a url. Open it and the customer gets the same form on an ecomPay page, with "Cancel and go back" (cancel_url) and a redirect to success_url?session_id=... after payment. Treat the redirect as a hint only; confirm on your server.

Webhooks

Add your webhook address in the dashboard under Developers, or with the API:

curl https://api.ecompay.co.uk/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yoursite.com/webhooks/ecompay"}'

The answer includes secret (whsec_...), shown once. Each mode (test, live) has one address. Setting a new address replaces the old one and makes a new secret.

Event

{
  "id": "evt_...",
  "type": "session.completed",
  "created": "2026-10-15T12:01:05.000Z",
  "livemode": false,
  "data": { ...the session object, without client_secret or url }
}

created is an ISO timestamp. The t in the signature header is unix seconds; use that for the age check, not created.

Headers: Content-Type: application/json, Ecompay-Event-Id, Ecompay-Signature: t=1760530865,v1=<hex>.

Type When
session.completed Paid. Sent once per session.
session.failed The last allowed card attempt failed.
session.expired Not paid in time.
session.refunded A refund succeeded. data.refund holds the refund object. After a partial refund data.status is still paid with amount_refunded raised; after a full refund it is refunded.
session.disputed A chargeback was opened.
session.dispute_closed The chargeback closed in your favour.
session.duplicate_refunded A second approval reached an already paid session and was refunded automatically. Nothing to do.
test.ping Sent by the "Send test event" button in the dashboard.

Verifying the signature

The signature is HMAC-SHA256 of t + "." + raw body with your webhook secret, hex encoded. Compare it to v1 using a constant-time comparison and reject timestamps older than five minutes.

Node

import crypto from 'node:crypto';
export function verify(rawBody, header, secret) {
  const p = Object.fromEntries(header.split(',').map((x) => x.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${p.t}.${rawBody}`).digest('hex');
  const ok = expected.length === p.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
  return ok && Math.abs(Date.now() / 1000 - Number(p.t)) < 300;
}

PHP

function ecompay_verify(string $rawBody, string $header, string $secret): bool {
  parse_str(str_replace(',', '&', $header), $p);
  $expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
  return hash_equals($expected, $p['v1']) && abs(time() - (int) $p['t']) < 300;
}

Python

import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    p = dict(x.split('=') for x in header.split(','))
    expected = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, p['v1']) and abs(time.time() - int(p['t'])) < 300

Use the raw request body, not a re-encoded version of the parsed JSON.

Delivery

Reply with any 2xx within 15 seconds. Anything else is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, then given up. Make your handler idempotent: use the event id to skip events you have already processed. Addresses must be https:// and public.

Testing your webhook

Refunds

POST /v1/checkout/sessions/{id}/refunds

Field Type Required Notes
amount integer no Minor units. Leave out for everything not yet refunded.
reason string no Up to 255 characters, shown in the dashboard.
{ "id": "re_...", "object": "refund", "session": "cs_test_...", "amount": 1500, "status": "succeeded", "reason": null, "failure_message": null, "created_at": "..." }

status is succeeded, pending (the bank has not confirmed yet; a session.refunded webhook follows) or failed (failure_message says why; nothing was taken). Refunds go back to the card that paid. The dashboard can do the same from a payment's page.

Idempotency

Send Idempotency-Key: <any unique string up to 255 characters> on POST requests. The same key with the same body returns the stored reply for 24 hours, with Idempotent-Replayed: true. The same key with a different body gets idempotency_conflict.

Use it on refunds, so a retried request cannot refund twice. For session creation use it only to guard a retry of the same attempt, with a fresh random key (a UUID) each time your code decides to create a session. Do not key it on your order id: a replayed create returns the earlier session as it is now, which may already be paid, expired or refunded.

Errors

Every error is JSON with the same shape and an HTTP status that matches:

{
  "error": {
    "code": "parameter_invalid",
    "param": "amount",
    "message": "amount must be a whole number in minor units.",
    "fix": "Send 4999 for £49.99, not 49.99.",
    "doc_url": "https://docs.ecompay.co.uk/#error-parameter_invalid",
    "request_id": "req_..."
  }
}

message says what is wrong, fix says what to change. Quote request_id when contacting support.

Error codes

Code HTTP Message Fix
invalid_api_key 401 API key missing, wrong or revoked. Send your secret key as "Authorization: Bearer sk_test_..." from your server. Never put it in browser code.
live_mode_not_enabled 403 This account has test keys only. Use your sk_test_ key. Live keys start working once your merchant account is connected.
parameter_missing 400 A required field is missing. Add the field named in "param".
parameter_invalid 400 A field has the wrong format. Check the field named in "param" against the docs.
amount_too_large 400 Amount is above the limit for this key. Use a smaller amount, or a personal test key from /v1/sandbox/signup.
currency_not_enabled 400 This account does not process this currency. Use a currency your account processes, for example GBP.
session_not_found 404 No such checkout session for this key and mode. Check the session id, and that you are using the same mode (test or live) that created it.
idempotency_conflict 409 This Idempotency-Key was already used with a different request body. Use a new Idempotency-Key for a different request.
request_in_progress 409 A request with this Idempotency-Key is still being processed. Wait a second and retry with the same key.
session_not_refundable 409 This session cannot be refunded. Only paid sessions with an amount left to refund can be refunded.
refund_exceeds_amount 400 Refund is more than what is left to refund. Send an amount no larger than amount minus amount_refunded.
webhooks_not_available 403 Webhooks are not available on the public demo key. Get a personal test key from POST /v1/sandbox/signup, or confirm payment with GET /v1/checkout/sessions/{id}.
rate_limited 429 Too many requests. Retry after the number of seconds in the Retry-After header.
processor_unavailable 502 The card processor did not answer. Safe to retry with the same Idempotency-Key.
account_configuration 500 This merchant account needs setting up by ecomPay support. Contact ecomPay support and quote the request_id.
invalid_client_secret 401 Client secret missing or wrong. Pass the client_secret from your server-side session create call to EcomPay.checkout().
session_not_payable 409 This checkout can no longer take a payment. Create a new checkout session.
payment_in_progress 409 A payment is already in progress for this checkout. Wait for it to finish.
card_not_encrypted 400 Card details must be encrypted in the browser. Use the ecomPay card form. Card numbers are never accepted in plain text.

Why a card attempt failed

On an open or failed session, failure_code and failure_message describe the last attempt. The message is already written for customers.

failure_code Meaning
card_declined The bank said no.
invalid_card The number was not accepted.
expired_card The card has expired.
risk_declined Declined by risk checks.
authentication_failed The bank check (3D Secure) did not pass or was abandoned.
processing_error The request could not be processed. The customer can try again.
processor_unavailable The bank could not be reached. Try again shortly.
account_configuration Your account needs attention from ecomPay. Contact support.

Test cards

Use any future expiry and any 3 digit security code. These work in test mode only.

Card Result
4012 0000 0006 0085 Visa, approved
5200 0000 0000 1021 Mastercard, approved after a background check
4066 3300 0000 0004 Visa, approved after a background check
4918 1900 0000 0002 Bank confirmation window, you choose the result
4111 1100 0000 0922 Declined by the bank

Test mode sessions are capped at £50 on the public demo key. Your own test key has no cap.

Trying it without an account

POST /v1/sandbox/signup with {"email": "you@example.com"} returns { "merchant_id", "secret_key": "sk_test_...", "livemode": false, "note" }, an instant test key for experiments. For anything you will keep, create an account at https://dashboard.ecompay.co.uk/ instead: it gives you keys, webhooks, a payments list and the path to going live.

More examples

Next.js (App Router)

// app/api/checkout/route.ts
export async function POST(req: Request) {
  const { email } = await req.json();
  const r = await fetch('https://api.ecompay.co.uk/v1/checkout/sessions', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.ECOMPAY_SECRET_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: 4999, currency: 'GBP', reference: 'order_1001', customer: { email } }),
  });
  const s = await r.json();
  if (!r.ok) return Response.json(s, { status: 400 });
  return Response.json({ clientSecret: s.client_secret, id: s.id });
}
// app/checkout/Pay.tsx
'use client';
import Script from 'next/script';
import { useEffect, useRef } from 'react';
declare global { interface Window { EcomPay: any } }

export default function Pay({ clientSecret, id }: { clientSecret: string; id: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const start = () => {
    if (!window.EcomPay || !ref.current || ref.current.childElementCount) return;
    window.EcomPay.checkout({ clientSecret })
      .on('complete', () => { location.href = `/thanks?session_id=${id}`; })
      .mount(ref.current);
  };
  useEffect(start, [clientSecret]);
  return <><Script src="https://api.ecompay.co.uk/v1/ecompay.js" onLoad={start} /><div ref={ref} /></>;
}
// app/api/webhooks/ecompay/route.ts
import crypto from 'node:crypto';
export async function POST(req: Request) {
  const raw = await req.text();
  const p = Object.fromEntries((req.headers.get('ecompay-signature') || '').split(',').map((x) => x.split('=')));
  const expected = crypto.createHmac('sha256', process.env.ECOMPAY_WEBHOOK_SECRET!).update(`${p.t}.${raw}`).digest('hex');
  const same = expected.length === String(p.v1).length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(String(p.v1)));
  if (!same || Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return new Response('bad signature', { status: 400 });
  const event = JSON.parse(raw);
  if (await alreadyHandled(event.id)) return new Response('ok');       // your database
  if (event.type === 'session.completed') await markOrderPaid(event.data.reference, event.data.id);
  return new Response('ok');
}

PHP

<?php
// create-session.php
$body = json_encode(['amount' => 4999, 'currency' => 'GBP', 'reference' => 'order_1001']);
$ch = curl_init('https://api.ecompay.co.uk/v1/checkout/sessions');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('ECOMPAY_SECRET_KEY'), 'Content-Type: application/json'],
  CURLOPT_POSTFIELDS => $body,
  CURLOPT_RETURNTRANSFER => true,
]);
$session = json_decode(curl_exec($ch), true);
header('Content-Type: application/json');
echo json_encode(['clientSecret' => $session['client_secret'], 'id' => $session['id']]);

Python (Flask)

import os, requests
from flask import Flask, request, jsonify
app = Flask(__name__)
ECOMPAY = 'https://api.ecompay.co.uk'

@app.post('/api/checkout')
def checkout():
    r = requests.post(f'{ECOMPAY}/v1/checkout/sessions',
        headers={'Authorization': f"Bearer {os.environ['ECOMPAY_SECRET_KEY']}"},
        json={'amount': 4999, 'currency': 'GBP', 'reference': 'order_1001', 'customer': {'email': request.json['email']}})
    s = r.json()
    if not r.ok:
        return jsonify(s), 400
    return jsonify(clientSecret=s['client_secret'], id=s['id'])

WordPress and WooCommerce

Use the ecomPay plugin for WooCommerce instead of this API. Ask ecomPay for the plugin.

Going live

  1. Add your live account details in the dashboard, Account details, Live. They are checked before they are saved.
  2. Create a live key under Developers, Live, and put it in your server's environment in place of the test key.
  3. Add your live webhook address (each mode has its own address and secret).
  4. Make one small real payment and refund it from the dashboard.
  5. Keep the test key for staging. Test and live never mix.

Security notes

Support

support@ecompay.co.uk. Include the request_id or session id.