Medipay merchant API
Read your payments, pull revenue summaries, create pay links, and receive signed webhooks - or skip the code entirely and connect an AI assistant over MCP. Keys are minted by workspace admins in the portal under Account → Developers.
REST API
Payments, revenue summary, create + send pay links. This page.
Webhooks
Signed events pushed to your system: paid, refunded, voided. This page.
Terminal integration
Drive card machines from your software: jobs, outcomes, refunds.
Clinic staff setting up a card machine (not writing code)? See the card machine setup guide. One link covers all of this: portal.medipayuk.co.uk/docs.
Authentication
Every request carries a bearer key. Keys are shown once at mint time, stored hashed, scoped to a single workspace, and revocable instantly. Two scopes: read (payments, summary) and write (create pay links). Rate limit: 60 requests per minute per key - over-limit responses are 429 with a Retry-After header.
curl https://portal.medipayuk.co.uk/api/v1/summary \
-H "Authorization: Bearer mp_live_your_key_here"Endpoints
GET /api/v1/payments
Your payments, newest first. Query params: status (active | paid | refunded | cancelled | expired), from / to (ISO dates on created_at), limit (max 100), offset.
curl "https://portal.medipayuk.co.uk/api/v1/payments?status=paid&limit=2" \
-H "Authorization: Bearer mp_live_..."
{
"ok": true,
"count": 2,
"payments": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"reference": "Hygiene appointment",
"amount_pence": 6500,
"status": "paid",
"paid_at": "2026-07-15T14:32:11Z",
"card_brand": "visa",
"card_last4": "4242",
"channel": "payment_link",
"taken_by": "j.okafor@riversidedental.co.uk"
}
]
}GET /api/v1/summary
Revenue summary over a period (defaults to the last 30 days). Query params: from, to.
{
"ok": true,
"payments": 29,
"collected_pence": 530000,
"refunded_pence": 12500,
"net_pence": 517500,
"by_channel": {
"payment_link": { "count": 21, "amount_pence": 402000 },
"virtual_terminal": { "count": 8, "amount_pence": 128000 }
}
}POST /api/v1/paylinkswrite scope
Create a pay link. Body: amount_pence (int, required), reference (required), description, expiry_days (1–90, default 7), payer_email, payer_name, payer_phone (all optional - with an email set, the platform sends the payer their receipt on payment). Optional return_url (absolute https): where to send the patient after a successful payment, e.g. back to your own site. We append medipay_status, medipay_payment_id and medipay_reference as query parameters. Treat the redirect as a convenience and confirm the payment from the payment.paid webhook, never from those parameters. Practices on Dentally can also pass dentally_patient_id and dentally_invoice_id(numeric ids, optional): when the workspace has Dentally connected in Settings, the paid payment is recorded in that patient's Dentally ledger and allocated to the invoice automatically. Pass send: true (optionally with channels: ["email"] or ["sms"]; default both) and the platform messages the patient their payment link the moment it is created, using your practice templates and branding - so a booking system can charge an overrun or add-on at sign-off with a single call and no messaging of its own. Requires payer_email and/or payer_phone; the response reports what was sent in sent. Send the patient to url: opening it from your own system (new tab, popup or embedded browser) replaces a Lightbox-style overlay - the card fields are hosted by the gateway on our page, so no card data ever touches your system.
curl -X POST https://portal.medipayuk.co.uk/api/v1/paylinks \
-H "Authorization: Bearer mp_live_..." \
-H "Content-Type: application/json" \
-d '{"amount_pence": 6500, "reference": "Hygiene appointment",
"payer_email": "patient@example.com"}'
{
"ok": true,
"id": "3f2b8c1d-...",
"url": "https://portal.medipayuk.co.uk/pay/3f2b8c1d-...",
"expires_at": "2026-07-23T10:00:00Z"
}GET /api/v1/paylinks/{id}
One payment's current state, by the id the create call returned. The polling half of the contract: webhooks push payment.paidthe moment it happens, and this answers "what is it now" on demand. Statuses: active, paid, expired, cancelled, declined, refunded.
curl https://portal.medipayuk.co.uk/api/v1/paylinks/3f2b8c1d-... \
-H "Authorization: Bearer mp_live_..."
{
"ok": true,
"payment": {
"id": "3f2b8c1d-...",
"reference": "Hygiene appointment",
"amount_pence": 6500,
"status": "paid",
"paid_at": "2026-08-12T09:14:03Z",
"card_brand": "visa",
"card_last4": "4242",
"url": "https://portal.medipayuk.co.uk/pay/3f2b8c1d-..."
}
}GET /api/v1/terminals
The workspace's card machines - use it to offer a terminal picker before creating a terminal job.
POST /api/v1/terminal-jobswrite scope
Send an amount to a physical card machine at the practice - built for initiating in-person payments from your own system (a CRM payment page, for example). Body: terminal_id (required), amount_pence (int, required), reference (required), description, payer_email. The practice's Medipay connector must be online.
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": "…", "amount_pence": 12000, "reference": "INV-1234"}'
{ "ok": true, "job": { "id": "…", "status": "queued", "payment_id": "…", "terminal": "Front desk" } }Then poll GET /api/v1/terminal-jobs/{id} every 1–2 seconds while the patient is at the terminal. approved means paid (with auth_code, card_brand, card_last4); declined, cancelled and error are terminal; anything else is still in progress. A payment.paid webhook also fires on approval. For the full card-present spec - request/response flows, status lifecycle and the webhook payload with signature verification - see the terminal integration guide.
Stored cards (charge later)
For booking flows where an extra amount may be due later - overruns, add-ons - a patient's card can be stored at Elavon (never on Medipay or your system) and charged merchant-initiated at sign-off. The patient enters their card once on a secure hosted capture page; your system holds only an id. Consent is mandatory: you record how the patient agreed their card can be stored and charged, and it is kept verbatim.
1. Create - POST /api/v1/stored-cards (write scope) with payer_name, consent_note (both required) and optional payer_email. Returns id and capture_url - put the URL in front of the patient. The capture page is Medipay-branded with the same embedded secure fields as checkout; the card number goes directly to the acquirer and never touches your systems or ours.
2. Poll - GET /api/v1/stored-cards/{id} until status is active (it flips when the patient completes the capture; the response then carries card_brand, card_last4 and expiry).
3. Charge at sign-off - POST /api/v1/stored-cards/{id}/charge with amount_pence and reference. Creates a real paid payment with receipt, webhook and reporting like any other; the response carries payment_id. Declines return the issuer's reason and create nothing. Revoke with DELETE /api/v1/stored-cards/{id} when the patient withdraws consent.
curl -X POST https://portal.medipayuk.co.uk/api/v1/stored-cards -H "Authorization: Bearer mp_live_..." -H "Content-Type: application/json" -d '{"payer_name": "Jane Doe", "payer_email": "jane@example.com",
"consent_note": "Agreed at booking (T&Cs v3, checkbox): card may be charged for additional usage."}'
{ "ok": true, "id": "b71c...", "status": "pending",
"capture_url": "https://portal.medipayuk.co.uk/store/b71c..." }Availability: requires payment method capture to be enabled on the clinic's Elavon account - if the create call reports it is not, contact Medipay and we will arrange it with Elavon.
Webhooks
Webhooks push payment outcomes to your system the moment they happen, so your booking or practice software never needs to poll. Configure endpoints under Settings → Developers → Webhooks: each endpoint has its own signing secret and its own selection of events, and a Send test button delivers a webhook.test event so you can prove the plumbing before going live.
Events: payment.paid, payment.failed, payment.refunded, payment.voided, gateway.notification. Every delivery is a JSON POST with this envelope:
{
"event": "payment.paid",
"created_at": "2026-08-20T14:03:22.511Z",
"data": {
"payment_id": "0d9c1e7a-...", // stable id - use with event for idempotency
"reference": "INV-2045",
"amount_pence": 33000,
"channel": "payment_link", // payment.paid: how it was taken
"card_brand": "Visa", // payment.paid: card details when known
"card_last4": "4242"
}
}
// payment.failed carries response_code and response_text (the decline
// reason) alongside the same core fields; payment.refunded carries
// refund_amount_pence; payment.voided carries the core fields. Fields may be null.gateway.notificationis different: it relays events from the clinic's own Elavon Payment Gateway account, including sales your system took directly on that account rather than through Medipay. Medipay reads the account's notifications about every two minutes and delivers each one once, oldest first, with the transaction read back when the event is about one. Events about payments Medipay took itself are not relayed; those arrive as payment.*.
{
"event": "gateway.notification",
"created_at": "2026-09-18T12:43:10.020Z",
"data": {
"notification_id": "9k2...", // EPG's id - use with event for idempotency
"event_type": "saleCaptured", // saleAuthorized, saleCaptured, saleRefunded, expired...
"resource_type": "transaction", // transaction, paymentSession, order
"resource": "https://api.eu.convergepay.com/transactions/2dx2...",
"resource_id": "2dx2...",
"custom_reference": null, // as EPG carries it on the notification
"occurred_at": "2026-09-18T12:41:46.514Z",
"gateway": "epg",
"transaction": { // only when resource_type is transaction
"id": "2dx2...", "state": "captured",
"amount": "1.00", "currency": "GBP",
"card_brand": "Visa", "card_last4": "0074",
"auth_code": "089145", "processor_reference": "BT8EI628I0T",
"order_id": "38th...", "payment_session_id": "v2xw...",
"custom_reference": null,
"order_custom_reference": "a0IP300000AEcKbMAL" // your reference from the order
}
}
}Every delivery is signed: the X-Medipay-Signature header is sha256=<HMAC-SHA256 of the raw body with your endpoint secret>. Verify before trusting:
// Node
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}# Python
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header, expected)Delivery semantics, honestly: two immediate attempts one second apart with a five second timeout each; any non-2xx response or timeout counts as failed. Failed deliveries stay visible in the delivery log under Developers → Webhooks with a one-click redeliver, so treat webhooks as at-least-once and make your handler idempotent on event + data.payment_id. Respond with a 2xx quickly and do any heavy work asynchronously - slow handlers time out and show as failures. Webhooks complement polling, not replace it: the GET /api/v1/payments endpoint remains the source of truth for reconciliation.
AI assistants (MCP)
Medipay is an MCP server - connect Claude or another MCP client and ask questions like "how much did we take this week?" The endpoint is /api/mcp, authenticated with the same API keys. Tools: list_payments, revenue_summary, create_pay_link, list_products.
# Claude Code
claude mcp add --transport http medipay \
https://portal.medipayuk.co.uk/api/mcp \
--header "Authorization: Bearer mp_live_..."
# claude.ai → Settings → Connectors → Add custom connector
# URL: https://portal.medipayuk.co.uk/api/mcp
# Header: Authorization: Bearer mp_live_...ChatGPT: expose the same three REST endpoints as Actions using the Postman collection above as the schema source.