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
- 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 (Stripe-Connect-style) so each tenant connects with one click. The connector minted on your behalf appears in our merchant database as a real, addressable merchant.
- A signed webhook stream that fans out events for every connected tenant to a single URL on your side. No per-tenant webhook routing.
- An operator-grade revocation surface on our side. If your key
leaks we revoke the right grants from
ops.mooov.money/platformsand your customers' funds stay safe.
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)
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:configureredirect_urimust 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.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.
Mooov 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...", "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.
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 returns400 server_flow_requires_payment_methodand Stripe never gets called. Stripe's own/v1/payment_intentsrejectsconfirm=truewith 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, useflow: "redirect"orflow: "embedded"instead — Stripe Checkout collects the card for you and you don't passpayment_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_intentswill accept a confirm=true with nopayment_methodand 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
- 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 Mooov 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 touch the merchant's connected PSP account. Revoking the Mooov grant breaks the integrator → Mooov authorisation chain but does not delete the merchant's Stripe Connect account row in our database. If the merchant later re-grants you via the consent flow in §3, charging resumes against the same Stripe account. This is intentional — disconnecting a payment integration shouldn't force the merchant to redo full Stripe KYC.
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:
- 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. - The Stripe provider card is highlighted with a brand-coloured ring.
return_urlis validated client-side (must behttps://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 insessionStorageso it survives the round-trip through Stripe's hosted onboarding.- 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. - Mooov pulls fresh status from Stripe (
account.updatedwebhook plus an explicit sync), and once at least one connection upgrades tocharges_enabled=truewe auto-bounce the merchant back to yourreturn_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/savedcron driving dues, the migration is: (1) create a Stripe Subscription on the connected account withbilling_cycle_anchor= your cron's next charge time andcancel_atset to the final cycle, (2) Mooov projects thecustomer.subscription.createdand you get asubscription.activatedevent, (3) every cycle now produces the dual-emit pair so your existingpayment.succeededhandler keeps working unchanged and the subscription lane is purely additive. Stop minting/v1/charges/savedfor that schedule once the firstsubscription.invoice_paidlands.
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:
- Email
ops@mooov.moneywith: your product name, redirect URI allowlist (one per environment), webhook URL per environment, and your security contact. - Mooov provisions your platform integration and mints a sandbox platform key. You receive the secret via a 1Password share (24h TTL).
- 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.