movere Docs

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

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)

  1. You generate a signed state token 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=test
    

    This example requests a Test grant. Use mode=live for Live; omission defaults to Live. URL-encode each parameter value.

    redirect_uri must 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.

  2. 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.

  3. Movere mints a merchant_grants row and redirects to:

    <redirect_uri>?code=<auth_code>&state=<your state>
    

    auth_code is single-use and expires in 10 minutes.

  4. You verify state against your CSRF token, then exchange the code at the gateway. Use the same HMAC signing scheme from §2 — POST /v1/public/connect/token with the JSON body below.

    The grant_type field is required and must be the literal string "authorization_code" (matches RFC 6749). The gateway returns 400 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_id too — 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

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.