API reference
Audience: anyone calling the Mooov gateway over HTTPS — platforms on Mooov Connect and merchants with a direct API key. For the consent flow, webhooks deep-dive, and credential ceremony see the Connect protocol; for direct merchant setup see the merchant quickstart.
Production https://api.mooov.money
Sandbox https://sandbox.api.mooov.money (see /sandbox/)
MCP-capable agents and editors can query these endpoint and webhook schemas directly; see Connect your AI agent.
1. Conventions
Authentication
Every /v1/* call carries the HMAC headers below. The signing scheme
is identical for merchant keys (mk_test_* / mk_live_*) and
platform keys (mk_platform_*):
X-Mooov-Key-Id: <your key id>
X-Mooov-Timestamp: <RFC3339 UTC>
X-Mooov-Signature: <lowercase hex HMAC-SHA256>
Idempotency-Key: <your uuid> (required on writes)
Mooov-Merchant: merch_<id> (platform keys only — on-behalf-of)
The signed string is exactly four lines (no trailing newline):
<METHOD>\n<PATH>\n<TIMESTAMP>\n<hex SHA-256 of the raw body, or of "" on GET>
We tolerate ±5 minutes of clock skew. Drop-in signers in Python and TypeScript are in §2 of the Connect protocol.
Scopes
Each key (and each Connect grant) carries a scope list. The wildcard
* satisfies any scope.
| Scope | Gates |
|---|---|
payments:write |
Create / capture / refund / void payments, saved-method charges |
payments:read |
Payment timeline |
customers:read |
Customer lookups |
Newly minted merchant keys default to
["payments:write", "payments:read"].
Idempotency
Every write requires an Idempotency-Key header. Replaying the same
key with the same body returns the original result; the same key with
a different body returns 409. A request still in flight returns
409 request already processing — retry with backoff.
Amounts
All amounts are integer minor units (4200 = £42.00). Currency
is an ISO 4217 code, e.g. "GBP".
Payment states
created → processing → authorized → captured → partially_refunded → refunded
↘ failed ↘ voided
2. Payment intents
POST /v1/payment_intents — create (and usually confirm) a payment
| Field | Type | Required | Notes |
|---|---|---|---|
payment_id |
string | Yes | Your stable id, e.g. pay_<uuid>. Used everywhere downstream. |
amount |
int | Yes | Minor units, > 0 |
currency |
string | Yes | ISO 4217 |
flow |
string | No | ""/"server" (default), "redirect", "embedded" |
payment_method |
string | For server flow | pm_* or ctoken_*. Required unless using customer_ref + off_session. |
success_url |
string | For redirect/embedded | Absolute https:// (localhost http:// allowed in sandbox) |
cancel_url |
string | For redirect/embedded | Same rules |
description |
string | No | Shown on hosted checkout / statements |
customer_email |
string | No | Pre-fills hosted checkout |
customer_name |
string | No | |
customer_ref |
string | No | Your durable customer handle (platform keys only) |
setup_future_usage |
string | No | "on_session" or "off_session"; requires customer_ref |
metadata |
object | No | String→string, max 50 keys; reserved mooov_* keys are dropped |
merchant_id |
string | No | Platform keys: alternative to the Mooov-Merchant header |
Response (all flows share this envelope):
{
"payment_id": "pay_…",
"state": "processing",
"provider": {
"provider": "stripe",
"provider_ref": "cs_…",
"status": "pending",
"hosted_url": "https://checkout.stripe.com/c/pay/cs_…",
"checkout_session_id": "",
"error": null
}
}
flow: "redirect"—provider.hosted_urlis the hosted checkout URL; redirect the browser there.flow: "embedded"—provider.hosted_urlis the Mooov-hosted checkout shell (https://pay.mooov.money/c/<payment_id>) andprovider.checkout_session_idcarries the underlying session for embedding.flow: "server"— synchronous confirm;statelands oncaptured,authorized, orfailedin the response, andprovider.erroris populated on decline:
"error": {
"category": "card_declined",
"code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"retryable": false,
"provider_code": "card_declined",
"provider_message": "…"
}
Both integration shapes are walked through end-to-end in §4 of the Connect protocol.
POST /v1/payment_intents/{payment_id}/capture
Captures a previously authorized payment. Empty body (the empty body is still hashed for the signature and idempotency).
Response: the same envelope as create, with state: "captured".
Re-capturing an already-captured payment returns 200 with the
current state (idempotent replay). A payment not in authorized
returns 409 payment not authorized for capture. If the provider
rejects the capture you still get a 200 — state stays
authorized and provider.status / provider.error carry the
failure.
POST /v1/payment_intents/{payment_id}/refund
| Field | Type | Required | Notes |
|---|---|---|---|
amount |
int | No | Minor units. Omit (or 0) = refund the full remaining amount. |
reason |
string | No | Stored on the refund for audit |
Partial refunds are first-class: refund less than the captured
amount and state becomes partially_refunded; keep refunding until
remaining_amount is 0 and it becomes refunded.
{
"payment_id": "pay_…",
"state": "partially_refunded",
"refunded_amount": 500,
"refunded_total": 500,
"remaining_amount": 1500,
"provider": { "status": "approved", … }
}
| Status | When |
|---|---|
| 409 | Payment not refundable in its current state / nothing left to refund |
| 422 | refund amount exceeds captured amount |
| 502 | Provider rejected the refund |
POST /v1/payment_intents/{payment_id}/void
Voids an authorized (not yet captured) payment.
| Field | Type | Required |
|---|---|---|
reason |
string | No |
Response: { "payment_id": …, "state": "voided", "provider": { … } }.
A payment that isn't authorized returns
409 payment not voidable in current state.
GET /v1/payment_intents/{payment_id}/timeline
Returns every provider attempt and published event for the payment,
oldest first. Requires payments:read.
{
"payment_id": "pay_…",
"timeline": [
{ "kind": "attempt", "operation": "charge",
"provider": "stripe", "status": "approved",
"occurred_at": "2026-06-10T12:00:00Z" },
{ "kind": "event", "operation": "payment.captured",
"status": "published",
"occurred_at": "2026-06-10T12:00:01Z" }
]
}
Entry fields: kind (attempt | event), operation, provider,
status, error_code, message, occurred_at. operation on
event entries uses internal event names (see the
webhook event catalog for the mapping to the names you
receive on Connect webhooks).
3. Saved payment methods
POST /v1/charges/saved — charge a saved method
Platform-key only (422 otherwise). Charges a payment method
previously saved against one of your customer_refs — the engine
behind off-session billing like dues and renewals.
| Field | Type | Required | Notes |
|---|---|---|---|
payment_id |
string | Yes | Your stable id for this charge |
amount |
int | Yes | Minor units, > 0 |
currency |
string | Yes | |
customer_ref |
string | Yes | The handle you set at save time |
payment_method_id |
string | Yes | The saved pm_* |
off_session |
bool | No | Default true |
auto_capture |
bool | No | Default true |
description |
string | No | |
customer_email |
string | No | |
customer_name |
string | No | |
metadata |
object | No | String→string |
merchant_id |
string | No | Alternative to Mooov-Merchant |
{
"payment_id": "pay_…",
"status": "succeeded",
"state": "captured",
"provider": "stripe",
"provider_ref": "pi_…",
"next_action": null,
"error": null
}
status is one of succeeded, requires_action, failed. On
requires_action (an off-session charge tripped 3DS), next_action
carries what your front-end needs to finish the challenge:
"next_action": {
"type": "use_stripe_sdk",
"client_secret": "pi_…_secret_…",
"connected_account_id": "acct_…",
"publishable_key": "pk_…"
}
On failed, error is the same typed provider-error object as
payment intents.
GET /v1/customers/by_ref?ref=<customer_ref>
Platform-key only. Requires customers:read. Resolves your durable
customer_ref to the underlying PSP customer:
{
"customer_ref": "mbr_8f3a",
"stripe_customer_id": "cus_…",
"merchant_id": "merch_…"
}
404 when no mapping exists for that ref on the addressed merchant.
4. Connect grant management
Documented in full in the Connect protocol:
| Endpoint | Purpose |
|---|---|
POST /v1/public/connect/token |
Exchange a consent-flow auth code for a merchant_id (§3) |
POST /v1/grants/{grant_id}/revoke |
Disconnect a tenant; idempotent; 204 on success (§5.1) |
5. Errors
Error bodies are JSON. Two shapes exist:
- Most
/v1/*endpoints:{ "error": "<message>" }, with typed errors like the422 merchant_not_charge_capablebody documented in the error reference. POST /v1/public/connect/token: OAuth-shaped —{ "error": "invalid_grant", "error_description": "…" }.
Cross-cutting statuses:
| Status | Meaning |
|---|---|
| 400 | Malformed body, missing Idempotency-Key, validation failure |
| 401 | Missing/invalid signature, stale timestamp, unknown key |
| 403 | Key or grant lacks the required scope; revoked grant (GRANT_NOT_FOUND) |
| 404 | Resource doesn't exist — also returned when your principal can't see it |
| 409 | Idempotency conflict or invalid state transition |
| 422 | Typed business errors (merchant_not_charge_capable, refund exceeds captured, …) |
| 5xx | Mooov-side or provider-side failure — safe to retry writes with the same Idempotency-Key |
The integrator-facing error code table is in §7 of the Connect protocol.
6. Versioning
/v1/* is stable: additive changes only. Breaking changes get a new
prefix (/v2/*) with a 12-month overlap and a public deprecation
calendar. Webhook event shapes follow the same contract.