Movere Connect protocol
Audience: integrators (platforms) who want to embed Movere payments in their product and offer payments to many tenants under a single credential.
This document is the integrator-facing surface of Movere Connect. If you're a merchant integrating Movere directly for your own business, you don't need this page — use a per-merchant API key instead (see the merchant quickstart).
1. What you get
- One platform API key for your whole product. You hold one credential and act on behalf of any tenant who has connected to you.
- A hosted authorization flow so each tenant approves access to their merchant account in the selected environment.
- A signed webhook stream for subscribed Live payment events from connected tenants at one platform-wide URL.
- An operator-grade revocation surface on our side. If your key
leaks we revoke the right grants from
ops.mooov.money/platformsand block further calls authorized by those grants.
You don't get raw card data, raw bank account numbers, or PSP-level credentials. Those live on Movere's side and rotate per-merchant.
2. Endpoints, at a glance
API https://api.mooov.money (shared platform credential; explicit environment)
Authorize https://connect.mooov.money/authorize
Platform keys span Test and Live. For on-behalf-of API calls, send
?environment=test or ?environment=live; omission defaults to Live.
The matching merchant grant determines the request environment. For
authorization, use mode=test or mode=live in the hosted flow.
https://sandbox.api.mooov.money is a temporary compatibility alias
only; it does not select the request environment.
Every API call is authenticated with an HMAC-SHA256 signature over the canonical request. The platform key secret is the HMAC key — it is never sent on the wire as a bearer token.
X-Mooov-Key-Id: mk_platform_<id>
X-Mooov-Timestamp: <RFC3339 UTC, e.g. 2026-05-21T08:39:00Z>
X-Mooov-Signature: <lowercase hex HMAC-SHA256 over the canonical request>
Mooov-Merchant: merch_<id> (required for on-behalf-of calls)
Idempotency-Key: <your uuid> (required on writes)
Content-Type: application/json (on POST / PUT / PATCH)
The signed string is exactly four lines (no trailing newline):
<METHOD>\n
<PATH>\n
<X-Mooov-Timestamp>\n
<lowercase hex SHA-256 of the raw request body, or hex SHA-256("") on GET>
PATH is the request path without the host or query string, e.g.
/v1/payment_intents. We tolerate ±5 minutes of clock skew on
X-Mooov-Timestamp; outside that we 401.
Mooov-Merchant is the tenant whose books you're touching. Movere
verifies a merchant_grants row exists for (your platform_id, that merchant_id, requested environment) and that the requested action
is in the effective key/grant scopes. No grant, no operation; the API returns 403 with
MERCHANT_GRANT_REQUIRED.
Drop-in signer (Python)
import hmac, hashlib, json, time, uuid, urllib.request, urllib.parse
from typing import Optional
KEY_ID = "mk_platform_<your key id>"
SECRET = b"<your platform key secret (the hex string, as bytes)>"
MERCH = "merch_<tenant merchant id>"
def signed_post(target: str, payload: dict, merchant: str = MERCH,
idempotency_key: Optional[str] = None) -> tuple[int, bytes]:
url = "https://api.mooov.money" + target
path = urllib.parse.urlsplit(url).path # query is sent, but not signed
body = json.dumps(payload, separators=(",", ":")).encode()
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
sig = hmac.new(
SECRET,
f"POST\n{path}\n{ts}\n{hashlib.sha256(body).hexdigest()}".encode(),
hashlib.sha256,
).hexdigest()
req = urllib.request.Request(
url,
data=body, method="POST",
headers={
"Content-Type": "application/json",
"X-Mooov-Key-Id": KEY_ID,
"X-Mooov-Timestamp": ts,
"X-Mooov-Signature": sig,
**({"Mooov-Merchant": merchant} if merchant else {}),
"Idempotency-Key": idempotency_key or str(uuid.uuid4()),
},
)
resp = urllib.request.urlopen(req, timeout=15)
return resp.status, resp.read()
Drop-in signer (Node / TypeScript)
import { createHmac, createHash, randomUUID } from "node:crypto";
const KEY_ID = "mk_platform_<your key id>";
const SECRET = "<your platform key secret>"; // the hex string itself
const MERCH = "merch_<tenant merchant id>";
export async function signedPost(
target: string, payload: unknown, merchant = MERCH, idempotencyKey = randomUUID(),
) {
const url = new URL(target, "https://api.mooov.money");
const path = url.pathname; // query is sent, but not signed
const body = JSON.stringify(payload);
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
const bodyHash = createHash("sha256").update(body).digest("hex");
const sig = createHmac("sha256", SECRET)
.update(`POST\n${path}\n${ts}\n${bodyHash}`)
.digest("hex");
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Mooov-Key-Id": KEY_ID,
"X-Mooov-Timestamp": ts,
"X-Mooov-Signature": sig,
...(merchant ? { "Mooov-Merchant": merchant } : {}),
"Idempotency-Key": idempotencyKey,
},
body,
});
}
Keep the same payment_id, request body, and idempotencyKey (Python:
idempotency_key) when retrying a payment write. Generate the key once
per operation and persist it. The helpers accept a target such as
/v1/payment_intents?environment=test while signing only its path.
Pass an empty merchant argument for token exchange or grant revocation.
HMAC keeps the secret out of request headers. The signature authenticates the method, path, timestamp, and body; it is not a single-use token. Payment-write idempotency is separate from the five-minute clock window.
3. Bring a new tenant online (the consent flow)
You generate a signed
statetoken and send the tenant to:https://connect.mooov.money/authorize ?client_id=<your platform slug> &redirect_uri=<your callback URL> &state=<your signed CSRF token> &scope=payments:write+payments:read+customers:write+webhooks:configure &mode=testThis example requests a Test grant. Use
mode=livefor Live; omission defaults to Live. URL-encode each parameter value.redirect_urimust exact-match one entry in the allowlist you registered with Movere. Prefix matching is not supported; the complete URL, including any query string, must match the registered entry.The tenant signs into Movere (or creates an account), sees a consent screen showing your display name and the scopes you asked for, and clicks Connect.
Movere mints a
merchant_grantsrow and redirects to:<redirect_uri>?code=<auth_code>&state=<your state>auth_codeis single-use and expires in 10 minutes.You verify
stateagainst your CSRF token, then exchange the code at the gateway. Use the same HMAC signing scheme from §2 —POST /v1/public/connect/tokenwith the JSON body below.The
grant_typefield is required and must be the literal string"authorization_code"(matches RFC 6749). The gateway returns400 invalid_request "grant_type must be 'authorization_code'"if it's missing or any other value.// body: { "grant_type": "authorization_code", "code": "<auth_code from step 3>", "redirect_uri": "<same redirect_uri you used in step 1>" }(Use
signedPost("/v1/public/connect/token", body, "")from the helpers in §2 — same HMAC headers, no Bearer.)The response is the durable identifier you store on your side:
{ "merchant_id": "merch_lodge_001", "entity_id": "mooov3", "grant_id": "grant_01HXX...", "environment": "test", "granted_scopes": ["payments:write", "payments:read", "customers:write", "webhooks:configure"], "granted_at": "2026-05-18T12:00:00Z", "platform_id": "platform_lodgepay", "token_type": "merchant_id" }Keep
grant_idtoo — it's the path parameter for the disconnect endpoint in §5.1.
That merchant_id is what you put in Mooov-Merchant on every
subsequent call you make on this tenant's behalf. Store environment
alongside merchant_id and grant_id; use it on subsequent API calls.
4. Act on a tenant's behalf
Create a payment
Use POST /v1/payment_intents to create a payment for a connected tenant.
The flow field selects how the customer completes payment. Availability
depends on the merchant's enabled provider connection, payment method and
request environment. A flow name does not select or recommend a provider.
The examples below use ?environment=test; use ?environment=live for Live calls.
flow: "redirect" — Hosted checkout
Use this for browser checkout when you want the enabled payment provider
to collect payment details on its hosted page. The gateway returns a
provider.hosted_url; redirect the customer to that exact URL. Available
payment methods, required customer authentication and the return behaviour
depend on the provider and the merchant's configuration. Supply both
success_url and cancel_url as absolute HTTPS URLs.
Body:
{
"payment_id": "<your stable id, e.g. pay_<uuid>>",
"amount": 4200,
"currency": "GBP",
"flow": "redirect",
"success_url": "https://www.lodgepayments.co.uk/dues/success?payment_id=<id>",
"cancel_url": "https://www.lodgepayments.co.uk/dues/cancel",
"description": "Lodge of Devon · Dues 2026",
"customer_email": "guest@example.com"
}
Illustrative response (provider values vary; use the values returned by the API):
{
"payment_id": "pay_…",
"state": "processing",
"provider": {
"provider": "<selected provider>",
"provider_ref": "<provider payment reference>",
"status": "pending",
"hosted_url": "<hosted checkout URL returned by the provider>"
}
}
window.location.assign(response.provider.hosted_url) and you're
ready for the customer to pay. Confirm completion using the payment
timeline, not the browser return URL. For Live payments, gateway events
can fan out as payment.succeeded to your subscribed platform endpoint.
Ordinary Test payment events are not currently fanned out to platforms.
Refunds use POST /v1/payment_intents/{id}/refund?environment=test
(or environment=live for a Live payment).
Hosted Checkout keeps raw card entry on the provider's page. Confirm your applicable PCI validation requirements with your compliance contact.
flow: "" (default) / "server" — Server-to-server confirmation
This flow requires a supported tokenised payment method or an eligible
saved payment method. Tokens and saved-method capabilities are specific
to the enabled provider; they are not interchangeable between providers.
Without a payment method or eligible saved-method request, the gateway
returns 400 server_flow_requires_payment_method before provider execution.
Additional customer action or processing may still be required, so do not
assume every response is terminal.
See the provider-specific integration notes for the supported token formats, a signed request example and the current Live/Test saved-method restrictions. For browser collection, hosted checkout avoids passing raw card details through your integration. Embedded-flow behaviour also depends on the provider; consult the API reference before choosing that flow.
Inspect a payment's history
GET /v1/payment_intents/pay_xxx/timeline?environment=test HTTP/1.1
Host: api.mooov.money
X-Mooov-Key-Id: mk_platform_<id>
X-Mooov-Timestamp: 2026-05-21T08:39:00Z
X-Mooov-Signature: <HMAC over `GET\n/v1/payment_intents/pay_xxx/timeline\n<ts>\n<sha256("")>`>
Mooov-Merchant: merch_lodge_001
Returns every provider attempt and published event for the payment,
in order — useful for support tooling and reconciliation. Requires
the payments:read scope.
On-behalf-of payment endpoints use these HMAC headers and the
Mooov-Merchant header. Token exchange and grant revocation authenticate
separately and do not require the merchant header. The full
endpoint reference (capture, refund, void, timeline, saved-method
charges, customer lookups) is at
docs.mooov.money/api-reference.
5. Disconnect a tenant
5.1 Server-side revoke (POST /v1/grants/{id}/revoke)
When a tenant clicks Disconnect in your admin UI, hit this
endpoint from your backend. We flip the grant to revoked,
stamp the reason, and stop accepting on-behalf-of calls for that
(platform, merchant, environment) grant immediately. Every subsequent
Mooov-Merchant: <that-merchant-id> request from your platform key
in that environment returns 403 MERCHANT_GRANT_REQUIRED.
Like token exchange, this endpoint does not require a
Mooov-Merchant header — the grant_id in the URL path is the
unique key. The grant row carries the merchant binding, and we
deliberately allow already-revoked grants through for idempotency
(which the on-behalf-of chokepoint can't do, since it requires an
active grant to authenticate).
POST /v1/grants/{grant_id}/revoke?environment=test HTTP/1.1
Host: api.mooov.money
X-Mooov-Key-Id: mk_platform_<your key id>
X-Mooov-Timestamp: 2026-05-22T13:00:00Z
X-Mooov-Signature: <HMAC over `POST\n/v1/grants/{id}/revoke\n<ts>\n<sha256(body)>`>
Content-Type: application/json
{ "reason": "merchant_clicked_disconnect_in_lp_admin" }
| Status | Body | When |
|---|---|---|
| 204 | (empty) | Grant flipped active → revoked. Also on every subsequent retry — see "idempotent" note below. |
| 401 | {"error":"invalid_client","error_description":"..."} |
Missing/expired/skewed auth headers, unknown platform key, signature mismatch, or non-platform key. |
| 404 | {"error":"GRANT_NOT_FOUND","message":"no grant with that id..."} |
Grant id doesn't exist OR it belongs to a different integrator. We deliberately don't distinguish. |
Use the grant's environment in the query. Omission defaults to Live;
a different environment returns 404. The JSON body is optional.
reason is optional; we cap it at 200 bytes and store it on the
grant row so an operator looking at audit logs can trace why a
specific lodge was disconnected. Omit it and we stamp the sentinel
string integrator_requested.
What this endpoint does and doesn't do
- Idempotent. A retry against an already-revoked grant returns
204without touching the row, so the originalrevoke_reasonandrevoked_atsurvive (operator audit clarity). Safe to call from any "fire-and-forget" job. - Fires
grant.revokedif you've subscribed. Every successful revoke — whether you call this endpoint or a Movere operator disconnects the tenant — emits onegrant.revokedevent to your webhook destination, providedgrant.revokedis in your destination's enabled event types. Thedatapayload carriesgrant_id,merchant_id,revoked_at, and the recordedreason. Retries of an already-revoked grant never re-emit. If your webhook consumer and the system calling this endpoint are the same process you can ignore the echo; if they're separate (the common setup), the event is how your consumer learns the tenant disconnected. - Does NOT disconnect the merchant's payment provider. Revoking a Movere grant removes this platform's permission to act for the merchant. It does not remove the underlying provider connection or replace the provider's own onboarding and eligibility requirements.
Drop-in smoke test (curl)
KEY_ID="mk_platform_<your key id>"
SECRET="<your platform key secret>"
ENVIRONMENT="test"
GRANT="grant_smoke_test_does_not_exist" # safe: must 404
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
BODY='{"reason":"smoke"}'
HASH=$(printf "%s" "$BODY" | shasum -a 256 | awk '{print $1}')
SIG=$(printf "POST\n/v1/grants/${GRANT}/revoke\n${TS}\n${HASH}" \
| openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -i -X POST "https://api.mooov.money/v1/grants/${GRANT}/revoke?environment=${ENVIRONMENT}" \
-H "X-Mooov-Key-Id: ${KEY_ID}" \
-H "X-Mooov-Timestamp: ${TS}" \
-H "X-Mooov-Signature: ${SIG}" \
-H "Content-Type: application/json" \
-d "$BODY"
Expected against a junk grant id: HTTP/2 404 with
{"error":"GRANT_NOT_FOUND",...}. Anything else (401, 400) means
authentication or request validation needs investigation before using
a real grant. This example signs only the path, not its query string.
5.2 Recover an underlying provider connection
A valid Movere grant does not guarantee that a merchant's provider connection is ready to process payments. When provider authorisation or onboarding needs attention, handle the API error as well as subscribed Live payment events. Completing Movere's consent flow again does not repair an underlying provider connection.
For merchant_not_charge_capable, follow the returned setup_url as
explained in the onboarding error reference.
Other recovery actions depend on the connection and error. Use the
provider-specific recovery notes
where applicable; do not apply one provider's recovery action to another.
6. Webhooks
You register one platform-wide endpoint through your Movere contact.
Ordinary payment fanout currently delivers Live events only. It
requires an active merchant grant, an active destination, and the event
type enabled on that destination. webhooks:configure is reserved; it
does not gate delivery. Grant-revocation events use a separate delivery
path to notify the platform after its grant is revoked.
Connect envelopes currently contain no environment or livemode
field. Do not rely on a Test payment producing a platform webhook.
Merchant-owned destinations have a separate envelope with livemode.
POST /your/webhook/endpoint
X-Mooov-Signature: t=1747614000,v1=<hex>
X-Mooov-Delivery: 42 (numeric delivery id, dedupe key)
User-Agent: Mooov-Webhooks/1
Content-Type: application/json
Verify the signature
The signed input is <t>.<raw request body> (literal dot between the
timestamp and the body). Compute HMAC-SHA256 with your webhook
signing secret as the key, hex-encode, and compare in constant time:
import hmac, hashlib, time
def verify(body: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance_s:
return False
if len(v1) != 64 or any(c not in "0123456789abcdef" for c in v1):
return False
except (ValueError, KeyError, TypeError):
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(body: Buffer, header: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
if (!/^\d+$/.test(parts.t ?? "") || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const mac = createHmac("sha256", secret);
mac.update(`${parts.t}.`);
mac.update(body);
const expected = Buffer.from(mac.digest("hex"));
const got = Buffer.from(parts.v1);
return expected.length === got.length && timingSafeEqual(expected, got);
}
Idempotency on your side
Movere retries transport failures and non-2xx responses up to eight
attempts total. Retry delays are 60s, 120s, 240s, 480s, 960s, 1920s,
and 3600s, plus retry-loop scheduling time. After the last failure the
delivery is dead-lettered. Use X-Mooov-Delivery as the
dedupe key. Distinct deliveries for the same business event share
the same event.id field in the body, so it's also safe to dedupe
on event.id if that's easier for you.
Event shape
{
"id": "evt_01HXX...",
"type": "payment.succeeded",
"created": "2026-05-18T12:34:56.000Z",
"merchant": { "id": "merch_lodge_001", "entity_id": "mooov3" },
"data": { "payment_id": "pay_example", "state": "captured" }
}
created is always RFC3339 UTC with exactly three fractional-second
digits and a Z suffix. We never emit nanoseconds, never omit the
fractional part, never use a numeric offset. Integrators can rely on
this for strict ISO parsers, Postgres timestamp(3) columns, and JSON
Schema format: date-time validators.
The exact data shape is documented per event in the
webhook event catalog.
Subscription events
For eligible Live platform integrations, create a hosted subscription with
POST /v1/subscription_checkouts. Check the current provider eligibility,
request fields and Test restriction in the API reference.
Subscription support is not implied for every payment provider.
The supported integration emits subscription.activated,
subscription.updated, subscription.canceled,
subscription.invoice_paid and subscription.invoice_failed.
A billing cycle can also emit a separate payment.succeeded or
payment.failed event. These have distinct event IDs: correlate and
deduplicate the payment and invoice lanes to avoid processing a cycle twice.
The subscription implementation reference retains the exact provider-specific fields, event triggers, timing and omitted-field behaviour needed by existing integrations. These are implementation details, not a recommendation to use a particular provider.
7. Errors you'll hit
Platform-auth errors have a flat error string, for example
{"error":"MERCHANT_GRANT_REQUIRED","message":"..."}. Endpoint errors
may have their own additional fields; they do not all use error.code.
| Status | error |
What it means |
|---|---|---|
| 401 | PLATFORM_KEY_UNKNOWN / PLATFORM_KEY_REVOKED |
Key not recognized or revoked. |
| 401 | SIGNATURE_MISMATCH |
Wrong secret or canonical request. |
| 401 | MISSING_SIGNATURE / TIMESTAMP_INVALID / TIMESTAMP_SKEW |
Missing authentication headers or invalid/stale timestamp. |
| 401 | PLATFORM_SUSPENDED |
Contact Movere operations about the integration. |
| 403 | MERCHANT_GRANT_REQUIRED |
No active grant for this platform, merchant, and requested environment. |
| 403 | SCOPE_DENIED |
The key and grant scopes have an empty intersection. |
| 403 | forbidden |
The effective scopes do not satisfy the endpoint's scope check. |
| 400 | MOOOV_MERCHANT_REQUIRED |
Missing merchant header on an on-behalf-of call. |
| 400 | INVALID_ENVIRONMENT |
The environment query must contain exactly one test or live value. |
| 404 | GRANT_NOT_FOUND |
Revoke only: grant absent, belongs to another platform, or does not match the requested environment. |
| 422 | merchant_not_charge_capable |
Complete PSP onboarding using the returned setup_url; see the error reference. |
For payment conflicts and routing failures, consult the API reference; preserve the full response when reporting an issue rather than assuming all errors share the auth envelope.
When a grant disappears mid-flight — the lodge admin clicked
Revoke in their dashboard, a Movere operator did, or you did
via §5.1 — every subsequent on-behalf-of call for that merchant
in that environment returns 403 MERCHANT_GRANT_REQUIRED. The correct behaviour on your side
is to mark the tenant as disconnected and surface a "reconnect
Movere" CTA in your UI.
When the underlying PSP connection needs attention, the Movere grant may remain active while payment requests fail. Check the actual API or Live event error and follow the relevant provider recovery procedure in §5.2. Re-granting consent in §3 does not repair a provider connection.
8. Test mode
Use your existing platform credential with the canonical API host:
Base URL https://api.mooov.money
Authorize https://connect.mooov.money/authorize
Create a Test grant with mode=test in the consent flow. Send
?environment=test on every Test on-behalf-of request and Test grant
revoke. Use mode=live and ?environment=live for Live. Both selectors
default to Live when omitted. A merchant ID prefix and the sandbox API
alias do not select environment.
Token exchange returns the merchant and grant already attached during consent; it does not automatically seed a new Test merchant. Arrange a Test merchant and provider setup with your Movere contact. Ordinary Test payment events are not currently delivered to platform webhooks; verify Test payments through their timeline and exercise your receiver with local signed fixtures. See the sandbox guide for provider-specific Test limitations.
9. Getting credentials
Platform registration is operator-led. Send ops@movere.money your
product name, security contact, exact redirect URI allowlist, and one
platform-wide webhook URL. Movere provisions a platform key and shares
its secret once, plus the destination's webhook signing secret.
The platform key spans Test and Live; grants and payment data remain separate. Coordinate Live readiness with your Movere contact, then obtain Live tenant consent and explicitly select Live on API requests. You do not need a second platform credential merely to select Live.
10. Versioning
Movere's /v1/* API 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.
Your platform key never expires unless you (or Movere) revoke it. Rotate it whenever your runbook says to.