# 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 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** ```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** ```html
``` 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_KEY` comes from the dashboard, Developers, Secret keys. - `ECOMPAY_WEBHOOK_SECRET` is the `secret` you get when you add your webhook address (dashboard, Developers, Webhook, or `POST /v1/webhook_endpoints`). It is shown once. - Both live in your server's environment (a `.env` file 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: ```text 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 , 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=,v1=, 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 of your thank-you page. After payment the customer is sent there from the embedded form and the hosted page, with `?session_id=...&reference=...` added. If you leave it out, the thank-you page set in the dashboard (Settings, After payment) is used; if neither is set, the customer stays on the payment page. | | `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. | ```bash 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 ```json { "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": {}, "payment_link": null, "client_secret": "cs_test_8HAbtgvZuqCktLdzMouJJ2EE_secret_...", "url": "https://api.ecompay.co.uk/c/cs_test_8HAbtgvZuqCktLdzMouJJ2EE?s=..." } ``` - `client_secret` and `url` are returned only to your server, by create, retrieve and list. They are never in webhooks. Give the browser the `client_secret` only. - `url` is 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, `payment` holds `card_brand`, `card_last4`, `three_d_secure` (`frictionless`, `challenge` or null), `authorization_code` and `paid_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) ```html
``` - 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 `complete` event is for the customer's experience. The order is paid when **your server** sees `paid`, through the webhook or a retrieve. ### Sending customers to your thank-you page After payment the form shows "Paid" and then, about a second and a half later, takes the whole page to your thank-you page if one is set: 1. the checkout's `success_url`, if your server sent one, otherwise 2. the thank-you page set in the dashboard under Settings, After payment (separate for test and live). `?session_id=cs_...&reference=...` is added, so your thank-you page can fetch the session on your server and show the order. If neither is set, the customer stays on the payment form, and you can do your own thing in the `complete` handler. To always handle it yourself, pass `redirect: false`: ```js EcomPay.checkout({ clientSecret, redirect: false }).on('complete', (s) => { showMyOwnThanks(s.id); }).mount('#pay'); ``` - 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, after payment, the same thank-you redirect as the embedded form. Treat the redirect as a hint only; confirm on your server. ## Payment links A payment link is a short address that opens a secure ecomPay checkout. Send it by email, WhatsApp or text, put it on an invoice, or show its QR code. No website or code is needed: merchants can make them in the dashboard under Payment links. The API does the same. - **One-off** (`kind: "single"`, the default): one payment, like an invoice. Everyone who opens it gets the same checkout, so it cannot be paid twice. Once paid it shows "already paid". - **Reusable** (`kind: "reusable"`): the same price for many customers, like a deposit or a class. Each visitor gets their own checkout. ### Create a payment link `POST /v1/payment_links` | Field | Type | Required | Notes | | --- | --- | --- | --- | | `amount` | integer | yes | Minor units. | | `currency` | string | yes | 3 letter ISO code. | | `description` | string | yes | What it is for, shown to the customer. Up to 255 characters. | | `kind` | string | no | `single` (default) or `reusable`. | | `reference` | string | no | Your invoice or order id. Copied onto every checkout from the link. | | `customer` | object | no | `email`, `name`. One-off links only. The payment page will not ask for them again. | | `success_url` | string | no | Thank-you page after payment (falls back to the dashboard setting). | | `expires_at` | string | no | ISO date and time, 10 minutes to 1 year ahead. The link stops working after it. | | `metadata` | object | no | Up to 19 string values, copied onto each checkout with `payment_link` added. | ```bash curl https://api.ecompay.co.uk/v1/payment_links \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"amount": 12500, "currency": "GBP", "description": "Invoice 1042", "reference": "INV-1042", "customer": {"email": "client@example.com"}}' ``` ```json { "id": "pl_...", "object": "payment_link", "url": "https://api.ecompay.co.uk/l/k7qx2a9mrt", "code": "k7qx2a9mrt", "kind": "single", "status": "active", "livemode": false, "amount": 12500, "currency": "GBP", "description": "Invoice 1042", "reference": "INV-1042", "customer": { "email": "client@example.com" }, "success_url": null, "expires_at": null, "paid_session": null, "metadata": {}, "created_at": "2026-10-15T12:00:00.000Z", "payments_count": 0, "amount_collected": 0 } ``` `status` is `active`, `paid` (one-off links), `expired` or `deactivated`. `amount_collected` is paid minus refunded. A QR code for any link is at `https://api.ecompay.co.uk/l/{code}/qr.svg`. ### Other calls - `GET /v1/payment_links/{id}`: one link with its totals. - `GET /v1/payment_links?status=active&limit=20`: newest first. `status` is `active`, `paid`, `expired` or `deactivated`. - `POST /v1/payment_links/{id}/deactivate`: closes the link. A checkout still waiting on it closes too; one already with the bank finishes. ### Knowing a link was paid Each payment through a link is an ordinary checkout session with `payment_link` set to the link id and your `reference` copied over, so the usual `session.completed` webhook tells you. One-off links also send `payment_link.paid` with `{ id, code, session, amount, currency, reference }`. A one-off link can only ever be paid once: everyone who opens it shares one checkout, a closed or expired link stops taking payment straight away, even for a customer already on the page, and in the rare case two payments land at the same moment the second is refunded automatically (`payment_link.duplicate_payment`). In the dashboard, links can be emailed to the customer from ecomPay, with replies going to you. Test links can only be emailed to your own login email. ## Webhooks Add your webhook address in the dashboard under Developers, or with the API: ```bash 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 ```json { "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=`. | 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. | | `payment_link.paid` | A one-off payment link was paid. `data` is `{ id, code, session, amount, currency, reference }`. | | `payment_link.duplicate_payment` | A second payment reached a one-off link that was already paid (two people paying at the same moment). It is refunded automatically and does not send `session.completed`. 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** ```js 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** ```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** ```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 - 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. | ```json { "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: ` 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: ```json { "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) ```ts // 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 }); } ``` ```tsx // 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(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 <>