Mooov Docs

Mooov Connect protocol

Audience: integrators (platforms) who want to embed Mooov payments in their product and offer payments to many tenants under a single credential, like Stripe Connect.

This document is the integrator-facing surface of Mooov Connect. If you're a merchant integrating Mooov 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 Mooov's side and rotate per-merchant.

2. Endpoints, at a glance

Production  https://api.mooov.money
Sandbox     https://sandbox.api.mooov.money    (Mooov-Test-Mode)
Authorize   https://connect.mooov.money/authorize

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_<8-hex>
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. Mooov verifies a merchant_grants row exists for (your platform_id, that merchant_id) and that the requested action is in the granted scopes. No grant, no operation; the API returns 403 with GRANT_NOT_FOUND.

Drop-in signer (Python)

import hmac, hashlib, json, time, uuid, urllib.request

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(path: str, payload: dict) -> tuple[int, bytes]:
    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(
        "https://api.mooov.money" + path,
        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":    MERCH,
            "Idempotency-Key":   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(path: string, payload: unknown) {
  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("https://api.mooov.money" + path, {
    method: "POST",
    headers: {
      "Content-Type":      "application/json",
      "X-Mooov-Key-Id":    KEY_ID,
      "X-Mooov-Timestamp": ts,
      "X-Mooov-Signature": sig,
      "Mooov-Merchant":    MERCH,
      "Idempotency-Key":   randomUUID(),
    },
    body,
  });
}

Why HMAC and not Bearer? A bearer token is exposed in every TLS session log, every reverse-proxy debug dump, and every accidental curl -v. An HMAC signature is single-use, request-scoped, and useless to replay outside the ±5 min skew window. We never accept the secret in plaintext on the wire.

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
    

    redirect_uri must exact-match one entry in the allowlist you registered with Mooov. Path prefixes and query-string matches are rejected on purpose so a misconfigured tenant can't be phished.

  2. The tenant signs into Mooov (or creates an account), sees a consent screen showing your display name and the scopes you asked for, and clicks Connect.

  3. Mooov 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...",
      "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.

4. Act on a tenant's behalf

Create a payment

POST /v1/payment_intents supports two integration shapes via the flow field. Pick whichever fits the surface you're building.

flow: "redirect" — Hosted Stripe Checkout (recommended)

Use this for any surface where the cardholder is on a web page you control (dues forms, event guest-checkout, donations, subscription sign-up, generic Checkout-Session creators). The gateway opens a Stripe Checkout Session on the tenant's connected account and returns the hosted URL; you redirect the browser there; Stripe handles every payment method on the cardholder's side (card, Apple Pay, Google Pay, Klarna, iDEAL, SEPA, BACS, …) along with SCA / 3DS / dispute fingerprinting / dynamic localisation; the cardholder returns to your success_url on completion or cancel_url on abandon.

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"
}

Response:

{
  "payment_id": "pay_…",
  "state":      "processing",
  "provider": {
    "provider":     "stripe",
    "provider_ref": "cs_test_…",
    "status":       "pending",
    "hosted_url":   "https://checkout.stripe.com/c/pay/cs_test_…"
  }
}

window.location.assign(response.provider.hosted_url) and you're done. When the cardholder completes payment, our gateway ingests Stripe's payment_intent.succeeded webhook and fans out payment.succeeded (with the same payment_id) to your registered webhook endpoint within a couple of seconds. The payment is immediately refundable via POST /v1/payment_intents/{id}/refund.

PCI scope: SAQ A — you never touch a PAN; we never touch a PAN.

flow: "" (default) / "server" — Server-to-server confirm

Use this when you already have a tokenised payment method on hand (a pm_xxx you collected previously via a Setup Intent, or the cardholder's confirmation_token / ctoken_xxx from a Payment Element you host inline). The gateway confirms the PaymentIntent synchronously and returns the terminal state.

You MUST send payment_method. Without it the gateway returns 400 server_flow_requires_payment_method and Stripe never gets called. Stripe's own /v1/payment_intents rejects confirm=true with no payment method attached, so there is no Mooov path that can confirm a card you haven't already tokenised. If your cardholder is on a browser surface and you don't want to host the Payment Element yourself, use flow: "redirect" or flow: "embedded" instead — Stripe Checkout collects the card for you and you don't pass payment_method.

POST /v1/payment_intents HTTP/1.1
Host: api.mooov.money
Content-Type:      application/json
X-Mooov-Key-Id:    mk_platform_<8-hex>
X-Mooov-Timestamp: 2026-05-21T08:39:00Z
X-Mooov-Signature: 1a2b3c4d…  (lowercase hex HMAC-SHA256)
Mooov-Merchant:    merch_lodge_001
Idempotency-Key:   c5b0…

{
  "payment_id":     "pay_…",
  "amount":         4200,
  "currency":       "GBP",
  "payment_method": "pm_…"        // pm_xxx or ctoken_xxx; required
}

You may also drive a saved-PM charge by passing customer_ref + setup_future_usage: "off_session" instead of payment_method; the gateway will resolve the saved PaymentMethod on the connected account's Stripe Customer record. Either path satisfies the "must have a PM" precondition.

If the tenant has not yet finished PSP onboarding you get the typed 422 documented in §4.2; otherwise the response is the standard PaymentIntent envelope.

Why this stricter than Stripe? Stripe's own /v1/payment_intents will accept a confirm=true with no payment_method and return a soft error after the call. Mooov fails the request synchronously before touching Stripe so a mis-shaped integrator never produces a half-created PaymentIntent row that has to be reconciled out of band.

Inspect a payment's history

GET /v1/payment_intents/pay_xxx/timeline HTTP/1.1
Host: api.mooov.money
X-Mooov-Key-Id:    mk_platform_<8-hex>
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.

Every /v1/* endpoint follows the same shape: same HMAC auth headers, same Mooov-Merchant header, same idempotency rules. 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) pair immediately. Every subsequent Mooov-Merchant: <that-merchant-id> request from your platform key returns 403 GRANT_NOT_FOUND.

Unlike every other /v1/* route, this endpoint does not take 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 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" }   ← body optional
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.

reason is optional; we cap it at 200 chars 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>"
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" \
  -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 your signer is misconfigured — fix that against the smoke before pointing it at real grants.

5.2 Reconnect deep-link (Stripe account recovery)

If a tenant's underlying PSP connection (today: Stripe Connect) breaks — most commonly because Stripe revoked OAuth, the connected account hit a hard requirement past_due, or the lodge admin clicked Disconnect inside their Stripe dashboard — every charge you send returns a typed payment.failed webhook with failure_code: "account_invalid" (or a related Stripe code) and the merchant's merchant_provider_accounts.charges_enabled flips to false.

Reconnecting via the Mooov OAuth consent flow in §3 does not fix this — the Mooov grant is still active, the problem is one layer down. The fix is to send the lodge admin to the Mooov merchant portal's Stripe-reconnect surface:

https://mooov3.mooov.money/connections
  ?from=<your-platform-slug>
  &action=stripe_reconnect
  &return_url=<your https URL — urlencoded>

Example (LodgePay):

https://mooov3.mooov.money/connections
  ?from=lodgepay
  &action=stripe_reconnect
  &return_url=https%3A%2F%2Fwww.lodgepayments.co.uk%2Fadmin%2Fintegrations

What happens on the Mooov side:

  1. The page shows a branded banner ("<Your platform> sent you here to repair your Stripe connection") so the lodge admin knows where they came from and where they're going back to.
  2. The Stripe provider card is highlighted with a brand-coloured ring.
  3. return_url is validated client-side (must be https:// and resolve to an origin we explicitly recognise — today: lodgepayments.co.uk, www.lodgepayments.co.uk, *.vercel.app; new integrators are added on request) and stashed in sessionStorage so it survives the round-trip through Stripe's hosted onboarding.
  4. The lodge admin clicks Continue setup, completes whatever Stripe needs (requirements, re-authorisation, bank account re-verification, etc.), and lands back on /connections?from=psp_return.
  5. Mooov pulls fresh status from Stripe (account.updated webhook plus an explicit sync), and once at least one connection upgrades to charges_enabled=true we auto-bounce the merchant back to your return_url. If nothing upgrades (Stripe still rejecting) the merchant stays on the Mooov page with the actual error visible so they can try again.

The return_url allowlist exists specifically to prevent mooov3.mooov.money/connections?from=...&return_url=https://evil.example.com being used as an open-redirect from a logged-in merchant session. Want a new origin added? Email ops@mooov.money with the production + preview origins you need; we add them inline with your integrator config and ship a tiny merchant-app change.

Recommended integrator flow when account_invalid fires

// In your payment.failed webhook handler:
if (event.data.failure_code === "account_invalid") {
  await markMerchantAsNeedsReconnect(event.data.merchant_id);
  // In the merchant's UI, replace the "Process payment" CTA with:
  const url = new URL("https://mooov3.mooov.money/connections");
  url.searchParams.set("from", "lodgepay");
  url.searchParams.set("action", "stripe_reconnect");
  url.searchParams.set("return_url", `${YOUR_ORIGIN}/admin/integrations?reconnect=ok`);
  showReconnectCTA({ href: url.toString() });
}

When the merchant returns to your origin via return_url, treat that as provisional confirmation only — wait for the account.updated payload or the next successful charge before flipping your tenant flag back to "connected". The deep-link only bounces back when our side saw charges_enabled=true, but a belt-and-braces re-check on your side is the right contract.

6. Webhooks

You register one endpoint per environment. Mooov delivers a signed POST for every event on every tenant that's granted you the webhooks:configure scope.

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:
    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
    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]));
  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

Mooov retries non-2xx responses with exponential backoff (1s, 5s, 30s, 5m, 30m, 2h, 12h). 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": { /* event-specific payload */ }
}

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

Mooov fans out the following lifecycle events when a connected account has at least one Stripe Subscription. v1 is pass-through: the integrator creates the subscription directly via Stripe (against the connected account, using the platform's Stripe-Account header and standard idempotency), and Mooov projects every customer.subscription.* / invoice.* webhook into a stable subscription_id namespaced under the merchant. The five event types below are the integrator-facing names; rename or removal is a breaking Connect change covered by §10.

type When it fires (Stripe trigger)
subscription.activated First customer.subscription.created on the connected account. The projection upserts a row and emits this event once per subscription, idempotent on stripe_event_id.
subscription.updated Any customer.subscription.updated that is NOT a status transition to canceled / incomplete_expired (we collapse those into subscription.canceled below).
subscription.canceled Either customer.subscription.deleted or a customer.subscription.updated whose new status is canceled / incomplete_expired. The two cases dedupe on event.id.
subscription.invoice_paid invoice.paid for a subscription cycle. Dual-emitted alongside payment.succeeded for the same cycle — see the dual-emit note below.
subscription.invoice_failed invoice.payment_failed for a subscription cycle. Dual-emitted alongside payment.failed. After Stripe's Smart Retry chain gives up the same envelope fires again.

Dual-emit contract. Every successful subscription cycle produces two webhook deliveries with two distinct event.ids: one subscription.invoice_paid (subscription lane, carries the projection's subscription_id and the Stripe invoice id) and one payment.succeeded (payment lane, carries a per-cycle payment_id the integrator can refund via POST /v1/payment_intents/{id}/refund). The same pair fires on failure as subscription.invoice_failed + payment.failed. Integrators who already process payment.* events need no code change for the payment lane; the subscription lane is the new surface and is opt-in via your webhook destination's event-type allowlist in the operator console.

Per-cycle event payload (subscription.invoice_paid / subscription.invoice_failed). The data envelope carries the Stripe invoice id, the resolved subscription_id, period markers, and a verbatim copy of the subscription's metadata bag (the bag you set on the Stripe Subscription itself, NOT the per-invoice metadata). LP-side cycle correlation reads data.subscription_metadata.lp_schedule_id and data.subscription_metadata.cycle_number; populate those on your side when you create the Subscription.

{
  "id": "evt_stripe_subscription_subscription.invoice_paid_<stripe_event_id>",
  "type": "subscription.invoice_paid",
  "created": "2026-06-01T00:00:01.000Z",
  "merchant": { "id": "merch_lodge_001", "entity_id": "mooov3" },
  "data": {
    "subscription_id":         "sub_merch_lodge_001_abc123",
    "stripe_subscription_id":  "sub_abc123",
    "stripe_customer_id":      "cus_xyz789",
    "customer_ref":            "mbr_22222222-2222-4222-8222-222222222222",
    "subscription_status":     "active",
    "amount":                  1250,
    "currency":                "GBP",
    "interval":                "month",
    "interval_count":          1,
    "period_start":            "2026-06-01T00:00:00Z",
    "period_end":              "2026-07-01T00:00:00Z",
    "source_event_id":         "evt_<stripe_event_id>",
    "subscription_metadata": {
      "lp_schedule_id":    "sched_lp_test_1",
      "lp_member_dues_id": "mdues_lp_test_1"
    }
  }
}

Fields are omitted when empty rather than emitted as JSON null, so a schema validator that rejects unexpected nulls (Ajv with strict: true, Pydantic v2 strict mode) still passes. Specifically customer_ref, stripe_customer_id, period_start, period_end, cancel_at, canceled_at, and subscription_metadata are dropped entirely when not present on the projection. Always check if "customer_ref" in data before reading.

Payload precision. created and every period timestamp follow the same RFC3339-UTC-with-three-fractional-digits contract as the payment events above (see §6.3 Event shape).

Cancellation events. subscription.canceled is dual-sourced: Stripe's customer.subscription.deleted fires when an integrator calls DELETE /v1/subscriptions/<id> immediately, and customer.subscription.updated with status="canceled" fires when the cancellation was scheduled via cancel_at. Both flow through the same projection path so the integrator only ever sees subscription.canceled. The original-sub-row carries canceled_at in data so you can branch on "scheduled" vs "immediate" on your side if you need to.

Migrating from saved-charge cron to subscription pass-through. If you have an existing POST /v1/charges/saved cron driving dues, the migration is: (1) create a Stripe Subscription on the connected account with billing_cycle_anchor = your cron's next charge time and cancel_at set to the final cycle, (2) Mooov projects the customer.subscription.created and you get a subscription.activated event, (3) every cycle now produces the dual-emit pair so your existing payment.succeeded handler keeps working unchanged and the subscription lane is purely additive. Stop minting /v1/charges/saved for that schedule once the first subscription.invoice_paid lands.

7. Errors you'll hit

Status error.code What it means
401 PLATFORM_KEY_INVALID Wrong key id or secret. Check X-Mooov-Key-Id matches the bearer.
401 PLATFORM_SUSPENDED Mooov has suspended your integrator. Check ops.mooov.money/platforms/<your slug> for the reason.
403 GRANT_NOT_FOUND No active merchant_grants row for (your platform_id, Mooov-Merchant). Tenant may have revoked.
403 SCOPE_NOT_GRANTED The action you tried isn't in the grant's scopes. Re-prompt the tenant with the wider scope set.
400 MOOOV_MERCHANT_REQUIRED You forgot the Mooov-Merchant header on an on-behalf-of call.
409 IDEMPOTENCY_KEY_REUSED Same Idempotency-Key with a different request body. Pick a new key.
422 ROUTING_NO_ELIGIBLE_PROVIDER The merchant's routing config rejected every PSP for this request. Operator has to update it.
404 GRANT_NOT_FOUND On POST /v1/grants/{id}/revoke (§5.1) only: grant doesn't exist OR belongs to another integrator.
422 merchant_not_charge_capable The tenant exists and your grant is valid, but they haven't finished PSP onboarding. The error body includes a signed setup_url you can hand back so they can finish. See the error reference.

When a grant disappears mid-flight — the lodge admin clicked Revoke in their dashboard, a Mooov operator did, or you did via §5.1 — every subsequent on-behalf-of call for that merchant returns 403 GRANT_NOT_FOUND. The correct behaviour on your side is to mark the tenant as disconnected and surface a "reconnect Mooov" CTA in your UI.

When the underlying PSP breaks (Stripe revokes OAuth on the connected account, hard requirements past_due, etc.), grants stay active but charges return payment.failed with failure_code: "account_invalid" (or the corresponding Stripe code). The fix is the reconnect deep-link in §5.2, not the OAuth re-grant flow in §3 — the Mooov authorisation is fine, it's the layer below that needs repair.

8. Sandbox

For local dev and CI, use the sandbox base URL and ask Mooov for a Mooov-Test-Mode platform key:

Base URL    https://sandbox.api.mooov.money
Authorize   https://sandbox.connect.mooov.money/authorize

Sandbox merchants seed automatically when you exchange the auth code, so a fresh CI run can spin up merch_test_* tenants without manual seeding on Mooov's side. Sandbox card numbers + simulated webhook deliveries are documented on the sandbox page.

9. Getting credentials

Mooov mints integrator credentials via an offline ceremony — we don't support self-serve platform registration in v1. To get started:

  1. Email ops@mooov.money with: your product name, redirect URI allowlist (one per environment), webhook URL per environment, and your security contact.
  2. Mooov provisions your platform integration and mints a sandbox platform key. You receive the secret via a 1Password share (24h TTL).
  3. You build against sandbox. When you're ready to go live, repeat the ceremony for prod. Sandbox and prod keys are different secrets and they never share grants.

10. Versioning

Mooov'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 Mooov) revoke it. Rotate it whenever your runbook says to.