Terminal payments from your system
Start an in-person payment on a physical card machine from your own application - for example a payment button on a Salesforce record - read the result, and receive a signed webhook when it is paid. This is the card-present counterpart to pay links; it is a Medipay integration and does not involve the gateway directly.
Base URL and versioning
All endpoints are under https://portal.medipayuk.co.uk/api/v1. Amounts are always integer pence (£1.00 = 100). Every response is JSON with an ok boolean; on failure an error string explains why.
Authentication
Every request carries a workspace API key as a bearer token. Keys are minted by a workspace admin in the portal under Account → Developers, scoped to that practice's data. Reads need the read scope; starting a payment needs write.
Authorization: Bearer mp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxNo IP allow-listing is required. Keys can be revoked and reissued at any time from the same screen.
How a terminal payment flows
- Your system calls
POST /terminal-jobswith the machine, amount and a reference. We create the job and return itsidimmediately (HTTP 201). - The practice's card machine wakes and prompts the patient to present their card. No card data touches your system or ours - the terminal and the acquirer handle it.
- You learn the outcome either way, or both:
- Poll
GET /terminal-jobs/{id}every 1-2 seconds while the patient is at the machine, or - Subscribe to the
payment.paidwebhook (see below) and let us call you on approval.
- Poll
You do not need a physical machine to build and test against this API. A job created while no terminal is connected returns a clear status (error, or a 503 at creation if the practice connector is offline), so request and response handling can be developed end to end before hardware is in place.
List the practice's machines
GET /api/v1/terminals returns the card machines in the workspace so you can offer a choice or pick one by name. Use a machine whose status is active.
curl https://portal.medipayuk.co.uk/api/v1/terminals \
-H "Authorization: Bearer mp_live_..."
{
"ok": true,
"terminals": [
{ "id": "94c3cf26-7ed6-40ea-934c-256602ea60d8",
"name": "Front Desk", "model": "PAX A920 Pro", "status": "active" }
]
}Initiate a payment
POST /api/v1/terminal-jobs write scope
Request body
| Field | Type | Notes |
|---|---|---|
terminal_id | string, required | A machine id from GET /terminals. |
amount_pence | integer, required | Between 100 and 1,000,000 (£1 to £10,000). |
reference | string, required | Your reference (invoice no., record id). Shown in the portal and returned on every status/webhook. |
description | string, optional | Free-text detail, up to 500 chars. |
payer_email | string, optional | If set, the patient is emailed a receipt on approval. |
Example
curl -X POST https://portal.medipayuk.co.uk/api/v1/terminal-jobs \
-H "Authorization: Bearer mp_live_..." \
-H "Content-Type: application/json" \
-d '{
"terminal_id": "94c3cf26-7ed6-40ea-934c-256602ea60d8",
"amount_pence": 15000,
"reference": "INV-2201",
"description": "Consultation - Dr Smith",
"payer_email": "patient@example.com"
}'
HTTP/1.1 201 Created
{
"ok": true,
"job": {
"id": "7c44cd04-d380-414a-b7b8-8f0360f68a96",
"status": "queued",
"payment_id": "a5e82ce0-19e3-4a12-ad2f-cc58e4119679",
"terminal": "Front Desk"
}
}Keep the returned job.id. payment_idis the payment's id in Medipay, the same value carried on the webhook.
Creation errors
400- missing/invalidterminal_id,referenceoramount_pence.404- the terminal is not in this workspace.409- the terminal exists but is notactive.409- the terminal is not paired with the practice connector (the practice pairs it from Terminals; nothing to retry until then).503- the practice's connector is offline, so nothing would collect the job. Surface this to the operator and retry.
Read the outcome
GET /api/v1/terminal-jobs/{id} read scope. Poll every 1-2 seconds until status is terminal.
Status lifecycle
| status | Meaning |
|---|---|
queued | Created, waiting for the practice connector to pick it up. In progress. |
sent | On the machine; the patient is being prompted. In progress. |
approved | Terminal. Paid. auth_code, card_brand, card_last4 are populated. |
declined | Terminal. The card was declined; error carries the reason. |
cancelled | Terminal. Cancelled at the machine or timed out. |
error | Terminal. The machine could not be reached or returned an error; error explains. |
Approved response
curl https://portal.medipayuk.co.uk/api/v1/terminal-jobs/7c44cd04-... \
-H "Authorization: Bearer mp_live_..."
{
"ok": true,
"job": {
"id": "7c44cd04-d380-414a-b7b8-8f0360f68a96",
"status": "approved",
"amount_pence": 15000,
"reference": "INV-2201",
"payment_id": "a5e82ce0-19e3-4a12-ad2f-cc58e4119679",
"auth_code": "A1B2C3",
"card_brand": "mastercard",
"card_last4": "1812",
"error": null,
"created_at": "2026-08-06T09:15:02.123Z",
"finished_at": "2026-08-06T09:15:19.784Z"
}
}A job with no result after ~4 minutes is closed as error. If no practice connector ever collected it, the message says so and the payment is cancelled - nothing reached the machine, send it again once the connector is running. If a connector collected it and went quiet, the message notes the outcome is unknown - check the machine before retrying, as the card may still have been charged. Either way the closing happens on our side every fifteen minutes too, so a job never stays in progress for ever.
Webhooks
Rather than polling to the end, subscribe an endpoint and we POST to it the moment a payment is paid, refunded or voided. Manage endpoints in the portal under Account → Developers → Webhooks; each has its own signing secret.
Events
payment.paid- fires on terminal approval (and on any other paid channel).payment.refunded,payment.voided- later lifecycle.webhook.test- sent by the "Send test" button so you can wire up before any real payment.
Delivery payload
POSTed as JSON. The data for a terminal payment carries the payment_id (matching job.payment_id), the reference, amount and card details.
POST https://your-endpoint.example.com/medipay/webhook
Content-Type: application/json
X-Medipay-Signature: sha256=<hex HMAC-SHA256 of the exact raw body>
User-Agent: Medipay-Webhooks/1.0
{
"event": "payment.paid",
"created_at": "2026-08-06T09:15:19.900Z",
"data": {
"payment_id": "a5e82ce0-19e3-4a12-ad2f-cc58e4119679",
"reference": "INV-2201",
"amount_pence": 15000,
"channel": "physical_terminal",
"card_brand": "mastercard",
"card_last4": "1812"
}
}Authenticating a delivery
Every request carries X-Medipay-Signature: sha256=<hex>, an HMAC-SHA256 of the exact raw request bodykeyed with that endpoint's secret. Recompute it over the bytes you received (before any JSON re-serialisation) and compare with a constant-time check. Reject anything that does not match.
// Node.js verification
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" +
createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header ?? "");
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}// Apex verification (Salesforce)
Blob mac = Crypto.generateMac(
'hmacSHA256',
Blob.valueOf(rawBody),
Blob.valueOf(endpointSecret));
String expected = 'sha256=' + EncodingUtil.convertToHex(mac);
Boolean valid = expected.equals(req.headers.get('X-Medipay-Signature'));Respond 2xxquickly to acknowledge. We attempt twice (1s apart, 5s timeout each); failed deliveries stay in the portal's delivery log with a manual redeliver, so a brief endpoint outage never loses an event. Deliveries are not guaranteed to be unique or ordered - treat payment_id as the idempotency key.
Reconciling with poll + webhook
Using both is fine and recommended: drive the on-screen experience from the poll (so the operator sees "approved" the instant it happens), and treat the webhook as the durable source of truth for your own records. Both report the same payment_id, so de-duplicate on it.
Getting a key
A workspace admin creates the key in the portal under Account → Developers with the read and write scopes, then shares it with your team through a secure channel. Questions on the integration: nikita@medipayuk.co.uk.