MedipayTerminal integration
Medipay docsAPI referenceTerminal integrationCard machine setup

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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

No IP allow-listing is required. Keys can be revoked and reissued at any time from the same screen.

How a terminal payment flows

  1. Your system calls POST /terminal-jobs with the machine, amount and a reference. We create the job and return its id immediately (HTTP 201).
  2. 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.
  3. 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.paid webhook (see below) and let us call you on approval.

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

FieldTypeNotes
terminal_idstring, requiredA machine id from GET /terminals.
amount_penceinteger, requiredBetween 100 and 1,000,000 (£1 to £10,000).
referencestring, requiredYour reference (invoice no., record id). Shown in the portal and returned on every status/webhook.
descriptionstring, optionalFree-text detail, up to 500 chars.
payer_emailstring, optionalIf 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

Read the outcome

GET /api/v1/terminal-jobs/{id} read scope. Poll every 1-2 seconds until status is terminal.

Status lifecycle

statusMeaning
queuedCreated, waiting for the practice connector to pick it up. In progress.
sentOn the machine; the patient is being prompted. In progress.
approvedTerminal. Paid. auth_code, card_brand, card_last4 are populated.
declinedTerminal. The card was declined; error carries the reason.
cancelledTerminal. Cancelled at the machine or timed out.
errorTerminal. 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

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.