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.
- API base:
https://api.ecompay.co.uk - Dashboard:
https://dashboard.ecompay.co.uk/ - Docs:
https://docs.ecompay.co.uk/ - Machine-readable:
https://api.ecompay.co.uk/openapi.json,https://api.ecompay.co.uk/llms.txt,https://api.ecompay.co.uk/llms-full.txt
How it works
- Your server calls
POST /v1/checkout/sessionswith your secret key and the amount. You get back a sessionidand aclient_secret. - Your page loads
ecompay.js, passes theclient_secrettoEcomPay.checkout()and mounts the card form. The customer pays. Any bank check (3D Secure) happens inside the form. - 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 seenstatus: "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.
ECOMPAY_SECRET_KEYcomes from the dashboard, Developers, Secret keys.ECOMPAY_WEBHOOK_SECRETis thesecretyou get when you add your webhook address (dashboard, Developers, Webhook, orPOST /v1/webhook_endpoints). It is shown once.- Both live in your server's environment (a
.envfile or your host's settings), never in the page.
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=..."
}client_secretandurlare returned only to your server, by create, retrieve and list. They are never in webhooks. Give the browser theclient_secretonly.urlis a ready-made hosted checkout page. Send the customer there if you do not want to embed the form (an email link, a chatbot, a site with no JavaScript).- Once paid,
paymentholdscard_brand,card_last4,three_d_secure(frictionless,challengeor null),authorization_codeandpaid_at.
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>- The form is an iframe served by ecomPay. It sizes itself and switches to a stacked layout on narrow screens. Give it at least 260px of width.
- It collects the card number, expiry, security code and the name on the card. It asks for an email and a billing address only if the session has none.
- Bank checks (3D Secure) run inside the form. If the bank wants the customer to confirm, a window opens over your page and closes itself afterwards.
- A declined card shows a plain message and lets the customer try another. You do not need to handle it.
- The
completeevent is for the customer's experience (go to the thank-you page). The order is paid when your server seespaid, through the webhook or a retrieve. - Styling: the form uses ecomPay's ticket design. In the dashboard, Settings, you can set your business name as shown on the form. A dark theme and an accent colour are available on request.
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'])) < 300Use 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
- The dashboard (Developers, Webhook) has a "Send test event" button that sends a
test.ping, and shows recent deliveries with their HTTP status. POST /v1/events/{id}/resend(with your secret key) sends any event again, which is the easy way to check that your handler ignores duplicates. The answer is{ "resent": 1 }.
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
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
- Add your live account details in the dashboard, Account details, Live. They are checked before they are saved.
- Create a live key under Developers, Live, and put it in your server's environment in place of the test key.
- Add your live webhook address (each mode has its own address and secret).
- Make one small real payment and refund it from the dashboard.
- Keep the test key for staging. Test and live never mix.
Security notes
- Card numbers never reach your server, so your PCI scope stays small. Keep it that way: never build your own card fields.
- Keep secret keys and webhook secrets in environment variables. Rotate them from the dashboard if they leak.
- Your checkout page must be served over https in live mode.
- Mark orders paid only from the webhook or a server-side retrieve. Browser events and the
success_urlredirect are for the customer's experience, not for fulfilment. - All requests are rate limited to 100 per second per key.
Support
support@ecompay.co.uk. Include the request_id or session id.