MCP Server
Connect an AI assistant to your Moon account and issue cards through MCP tools.
The Moon MCP server lets an AI assistant issue and manage cards through your Moon account, without you writing an integration first.
It is a companion to the REST API, not a replacement: almost everything it does is available as a normal endpoint too. Use MCP when a human or an agent works conversationally; use REST for your product's own code paths.
There are two ways to connect. Paste an API key into the client, or let the client run the OAuth flow itself — in which case nobody handles a credential at all, and the person ticks exactly which permissions the application gets.
Endpoint
| Environment | MCP URL | Dashboard (sign-in, approvals, funds) |
|---|---|---|
| Production | https://api.themooncard.com/mcp | https://dashboard.paywithmoon.com |
The transport is streamable HTTP, POST only. Authenticate with a bearer token on every request:
Authorization: Bearer YOUR_API_KEYThe bearer is re-checked against the database on every request, so revoking a key, revoking an OAuth connection or banning a cardholder takes effect on the very next call rather than when a token happens to expire.
There are no sessions
The endpoint is stateless. Each request builds its own server, answers, and is torn down.
- No
Mcp-Session-Idis issued and none is accepted. Do not cache or replay one. initializestill works and still returns the server identity, instructions and protocol version; it simply carries no session.GETandDELETEon/mcpreturn405. Moon sends no server-initiated notifications, so there is no stream to hold open.
Because every request authenticates on its own, there is no session to expire, no reconnect to manage, and no limit on concurrent connections.
Connection types
The same endpoint serves three kinds of connection, and the bearer decides which. Nothing else does: there is no mode switch, no header and no per-client configuration.
| Bearer | Acts for | Toolset |
|---|---|---|
| Partner API key | Your organization | Organization — everything in Tools |
OAuth access token (moon_) | Your organization, narrowed | Organization, limited to the permissions the person ticked — see Connecting with OAuth |
| Cardholder token | One cardholder | Cardholder — that person's own cards, read and control |
Which one should I use?
| If you are… | Use |
|---|---|
| a server-to-server integration you deploy and operate yourself | a partner API key |
| an application a person connects with a button, or a developer's coding agent | OAuth — nobody pastes a credential anywhere |
| acting for one cardholder, over that cardholder's own cards | a cardholder token |
Cardholder connections
A cardholder token is minted through the ordinary cardholder login route; there is no MCP-specific endpoint.
POST /v2/api-gateway/cardholders/auth/loginwith{ email, organization_emails the cardholder a one-time code.id } - Call it again with
{ email, organization_to receiveid, code } { token, expiresAt }. - Hand that token to that cardholder's MCP client as the bearer.
The cardholder must have completed the code flow at least once. A token for a cardholder who is banned, whose authentication is disabled, or who no longer exists is refused with 401. See Authentication.
A cardholder connection registers a smaller toolset — these eleven tools:
| Tool | Scope |
|---|---|
get_ | Cardholder-level instructions, principal type and cardholder id |
list_ | The cardholder's own cards. Takes page / limit only |
get_ | One of the cardholder's cards |
get_ | Transactions on one of the cardholder's cards |
reveal_ | PAN/CVV, behind the same opt-in header. Refuses on an ask-first card (purchase_) |
freeze_ / unfreeze_ | Freeze and unfreeze the cardholder's own cards |
get_ | This cardholder's verification status. Takes no arguments |
create_ | A hosted verification link for this cardholder. No arguments |
list_ | Purchases waiting on this person's decision. No arguments |
decide_ | Records the decision the person gave. See Ask-first cards |
tools/list can also show a few tools that belong to features in limited release — for saved checkout details and for requests to turn ask-first off. On an account where those features are not enabled they do nothing: they answer with an empty list or a coded refusal. They are documented here when they become generally available.
It never appears in tools/list, and calling it by name is refused before any Moon code runs.
Three consequences are deliberate:
- A card belonging to another cardholder answers
card_, never a permission error. The refusal has to be indistinguishable from an id that does not exist, or a caller could enumerate other people's cards by watching which answer it gets.not_ found - The verification tools take no id. The cardholder is the connection.
- A cardholder connection cannot obtain the credential of an ask-first card.
request_andpurchase get_are organization-tier — a person does not ask themselves for permission — andpayment_ credential reveal_refuses on such a card. The purchase has to be requested by the organization's agent and approved by the person.card_ credentials
Connecting a client
Most clients take a remote MCP server as a URL plus headers:
JSON
{
"mcpServers": {
"moon": {
"type": "http",
"url": "https://api.themooncard.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}Restart the client after editing the file.
For a client that speaks OAuth, the configuration is the URL and nothing else — see Connecting with OAuth.
Instructions and the account line
The server ships a short instructions brief that clients pass to the model on initialize, and a get_ tool that returns the instructions for this connection plus the environment, the connection type, whether card details can be released (pan_, profile_) and — on an OAuth connection — which permissions it holds and which it is missing (granted_, missing_). Have your agent call get_ first — in particular, it is how the agent confirms it is pointed at Sandbox before doing anything irreversible.
The instructions are composed for each connection: a section is sent only when the connection is offered one of its tools. get_ returns the brief by default, every section with mode: "full", or one section with section: "<id>"; the answer lists the sections this connection has.
On an organization connection (an API key or OAuth, never a cardholder token) the brief — both the one initialize sends and the one get_ returns — opens with one line about the account:
ACCOUNT NOW: 2 cards ask first, newest <card id>; Moon Credit 1250.50 USD; approvals go to a•••@example.com.It reports how many cards ask first and the newest one's id (so request_ needs no list_ first), the available prepaid balance, and where approvals go, masked. Anything missing carries the action that fixes it — for example no card asks first (the person picks one at …/agents/connect) or Moon Credit 0.00 USD (they add funds at …/settings/moon-credit). A fact that cannot be read in time reads unknown; the request still succeeds. The line never names a card number.
Connecting with OAuth
Moon runs its own OAuth 2.1 authorization server, so an application can connect to /mcp without anybody pasting an API key into it. The person clicks Connect in their client, signs in to Moon at dashboard.paywithmoon.com, ticks the permissions they are willing to give, and the client receives a token scoped to exactly that.
OAuth is additive. API keys are untouched and keep working exactly as before, with no narrowing and no expiry.
What the person sees
- In their client they add Moon as a connector and the client sends them to Moon.
- If they are not signed in, they sign in as usual and land back on the consent screen with nothing lost.
- The consent screen names the application, the host its access will be delivered to, and — for an application that registered itself and has never been approved before — says so plainly. A connection that delivers to a program on the person's own computer says that too.
- The screen opens on two presets: Shop with my approval (recommended, pre-selected) and Look only — see Presets. Under them it says where the agent's purchase approvals will go ("Approvals go to: you (a•••@example.com)"). The individual permissions sit behind Advanced: choose permissions one by one, pre-ticked to match the chosen preset, each a checkbox with a plain-language description. The person unticks anything they are not willing to grant.
- Approve sends them back to their client, already connected. Deny sends them back with
access_, so the client learns the answer instead of hanging.denied - Asking for more later. When a connected application comes back asking for permissions it does not hold yet (after a tool answered
insufficient_), the screen reads "Let application do more?", lists what is already allowed, and asks about the new permissions only. There are no presets on it. Approving adds them to the same connection — still one entry in Connected apps — and denying leaves the connection exactly as it was.scope - Afterwards, Settings → Connected apps at dashboard.paywithmoon.com/settings/connected-apps lists every connected application — name, permissions, who approved it, when, last used — and disconnects one immediately.
The permissions
These are the words on the consent screen, verbatim. By default is where the box sits for a client that names no scope of its own.
| Permission | By default | On the consent screen |
|---|---|---|
cards:read | Requested, ticked | See your cards, cardholders, transactions, balances, spending controls and webhooks. |
cards:write | Requested, ticked | Create and manage cards, cardholders, spending controls, purchase approvals and webhooks. |
profile:read | Requested, ticked | See which personal details are saved for checkout, masked (for example ***.***.***-01). Never the full values. |
profile:write | Requested, ticked | Add, change and delete the personal details saved for checkout. Anything you enter through the application passes through it. |
cards:pay | Requested, ticked | Receive the card number, expiry date and security code of each purchase you approve, so the assistant can pay for it. Anything received this way is visible to the connected application and to anyone who can read its transcript. |
profile:reveal | Requested, ticked | Receive the personal details you approve for a purchase, such as your name, national ID or shipping address. Anything received this way is visible to the connected application and to anyone who can read its transcript. |
autonomy:approve | Requested, unticked | Record your answer when an agent asks to buy on its own or to turn off ask-first. Only when you tell it your decision, and never for a request this same application made. |
cards:reveal | Not requested, unticked | Reveal full card numbers, expiry dates and security codes. Anything revealed this way is visible to the connected application and to anyone who can read its transcript. |
All eleven appear in the discovery documents' scopes_. autonomy:approve belongs to a feature in limited release (agents buying on their own); on an account where it is not enabled, holding it changes nothing a connection can do.
cards:write is also the permission under which a connection can record the person's own answer to a purchase approval on the account's own cards — see The person's side — and the one request_ and wait_ sit behind.
The default request is the shopping bundle. A client that sends no scope is offered cards:read cards:write profile:read profile:write cards:pay profile:reveal funds:write autonomy:approve — the same list the 401 challenge names, so a client that asks for exactly what it is told gets it too. Asking is not granting: funds:write and autonomy:approve start unticked, and an approval that leaves them alone leaves them out. cards:reveal is never in the default. A client that sends an explicit scope is offered exactly that.
Presets
| Preset | Ticks |
|---|---|
| Shop with my approval (recommended, pre-selected) | cards:read, cards:write, profile:read, profile:write, cards:pay, profile:reveal |
| Look only | cards:read |
Shop with my approval is enough to pay. The agent asks before each purchase and, for every purchase the person approves, pays it itself by receiving that purchase's card details with get_ and, when the approval names them, the personal details with get_. The consent screen says so in one sentence under the preset. A preset may grant more than the client asked for — the token response's scope says what was granted, and get_ reports it as granted_.
A preset never adds cards:reveal, autonomy:approve or funds:write. Those are ticked by hand under Advanced, or not at all.
cards:pay and cards:reveal are two permissions for the two credential tools. Both return a live PAN and CVV. get_ sits behind cards:pay: it releases the number of one purchase the person approved, a limited number of times, and the authorization gate declines anything outside that approval — so the approval is the real control, and the default preset grants it. reveal_ hands over the number of any card that does not ask first, with no approval at all, so it stays behind cards:reveal: unticked, with a warning, and in no preset. On an OAuth connection these permissions replace the x-moon-allow-pan header: a hosted client builds the request itself and cannot be told to send a custom header, so the gate is the person's tick rather than the client's configuration. The header keeps its meaning for API-key connections, for both tools.
profile:reveal is bound to the approval the same way. It covers one tool, get_: one release per approved purchase, only to the connection that requested it, only the details that approval names. The consent screen adds one line under it: "Released only for a purchase you approved, only the details that approval names, only to the assistant that asked." On an OAuth connection it replaces the x-moon-allow-profile header an API key sends.
profile:read and profile:write never yield a full value. profile:read is a masked read, and profile:write cannot read back what it wrote. The only permission that releases full personal details is profile:reveal, and only the details a person approved for one purchase.
/oauth/authorize answers invalid_ for any other client that asks for either, so a person is never shown a checkbox for them, and the consent decision intersects again before a connection is written.
autonomy:approve is off unless it is ticked, like cards:reveal.
offline_ is advertised in the authorization-server metadata, has no behaviour of its own and is not a permission over your data. A refresh token is issued without it.
What the agent sees when a tool is missing
The list shows it; the call is refused. On an OAuth connection every organization tool the connection could be granted is listed in tools/list. One whose permission the connection lacks is listed compactly — its name, the first sentence of its description followed by a line naming the permission it needs (for example Needs the permission "Manage cards" (cards:write), which this connection lacks: calling it answers insufficient_scope.), and an empty input schema. It never runs. Calling it answers the coded refusal as an ordinary tool error:
JSON
{
"error": {
"code": "insufficient_scope",
"message": "list_connected_apps needs the permission \"Manage cards\" (cards:write), which this connection was not granted.",
"next_step": "Ask the client to reconnect with the permission \"Manage cards\" (cards:write); it is a one-click re-approval. If the client cannot, send the person to https://dashboard.paywithmoon.com/settings/connected-apps.",
"details": { "tool": "list_connected_apps", "missing_scopes": ["cards:write"] },
"retryable": false
}
}What the agent does with it: ask the client to reconnect with the named permission. A client that starts a new authorization request carrying the wider scope gets the incremental consent screen — only the new permissions, and approving widens the same connection. If the client cannot do that, the person reconnects at Settings → Connected apps (/settings/connected-apps) and approves the missing permission. A token refresh never widens a connection. No header turns a permission on.
After a widened consent, list the tools again. The server is stateless: the next tools/list returns the real entries, parameters and all. A client that cached the compact entry holds an empty input schema for that tool until it re-lists.
get_ reports the same thing up front: granted_, missing_ and a scope_ saying where to reconnect.
Cardholder and API-key connections never see gated entries.
A missing permission is not always a missing capability. On the default preset an agent pays through an approval — request, wait, then get_ — and never needs cards:reveal or funds:write to buy something. An approval-only connection (one without cards:pay) asks the person to reconnect and tick "Card details for approved purchases". Reconnecting is the answer only for a tool the task genuinely needs.
Managing connections
Three ways to disconnect an application, all immediate — the next call from it is a 401:
| Where | How |
|---|---|
| Moon app | Settings → Connected apps, Revoke |
| MCP | list_, then revoke_ — both behind cards:write |
| Protocol | POST /oauth/revoke (RFC 7009). Revoking a refresh token revokes its connection and that connection's live tokens; other installs of the same application are unaffected. The endpoint always answers 200, whatever it was handed |
A disconnected application cannot come back on its own; it has to be approved again.
The one exception is the incremental consent above, which widens the existing connection instead. At most 10 connections per application are live per account; an approval past that revokes the least recently used one.
Protocol details
Everything below is for whoever is implementing or debugging a client. Nobody connecting through a client that already speaks OAuth needs to read it.
Discovery
| Document | URL |
|---|---|
| Protected resource (RFC 9728) | <issuer>/.well-known/oauth-protected-resource and …/oauth-protected-resource/mcp |
| Authorization server (RFC 8414) | <issuer>/.well-known/oauth-authorization-server and …/oauth-authorization-server/mcp |
The same string is the iss on every authorization response and the base of the resource_ URL in the WWW-Authenticate challenge, and clients compare it by exact string equality, so it is never normalised anywhere.
Both .well-known paths return the same body. Neither touches the database, both are unauthenticated, and both answer OPTIONS with 204 and CORS *.
The endpoints they advertise:
| Endpoint | Advertised URL |
|---|---|
| Authorization | <issuer>/oauth/authorize |
| Token | <issuer>/oauth/token |
| Revocation | <issuer>/oauth/revoke |
| Registration | <issuer>/oauth/register |
Use what the discovery document gives you, and if you must hardcode, hardcode the /oauth/* paths above. Root aliases (/authorize, /token, /register, /revoke) are also served for clients written against an older revision of the MCP specification, but they are not the contract and may be refused to a caller that is not a browser.
resource is <issuer>/mcp, with no trailing slash. Tokens are bound to that audience: they are valid at /mcp and nowhere else — not on the REST gateway, which still takes an API key or a cardholder session token.
Registration
POST /oauth/register is open dynamic client registration (RFC 7591): JSON, unauthenticated, limited to 10 registrations an hour per calling address, a 16 KiB body and at most 10 redirect URIs. It accepts client_, redirect_, grant_, response_, token_, scope, client_ and logo_.
It always issues a client_ and a client_ — returned once, stored hashed — even when the client registered as none, because more than one shipping client registers as a public client and then presents a secret at the token endpoint anyway. A client registered as none may simply omit it. token_ is therefore ["none", "client_.
A self-registered client that never completes an authorization stops working after 30 days. One that has been approved at least once is never expired by that rule; the connection is the thing that is revoked.
Redirect URIs. Two ways to be acceptable, and no wildcards ever:
- Loopback over HTTP —
127.0.0.1,localhostor[::1], any port. The port is ignored for loopback and only for loopback: a CLI binds an ephemeral port and an editor falls back to a random one when its preferred port is taken, so no fixed-port list would work.
Registration stores every URI it is given and fails only when none of them is acceptable, so a client that registers one custom-scheme URI alongside three good ones keeps its registration. /oauth/authorize then requires the presented URI to be acceptable and to match a registered one exactly — port ignored for loopback, nothing else.
If your application redirects to a host that is not on the allowlist, write to our team naming the exact host and the product that uses it.
Authorization
- PKCE is required and
S256only.plainis neither accepted nor advertised, andcode_is published so a conformant client can check before it starts.challenge_ methods_ supported stateis opaque and echoed back untouched.issis returned on every authorization response, including errors (RFC 9207).resource(RFC 8707) is accepted on authorization and token requests. When present it must equal<issuer>/mcp; when absent it is assumed.- An invalid
client_or an unacceptableid redirect_produces an inline error page. Moon never redirects to a URI it has not validated.uri
Tokens
| Credential | Lifetime |
|---|---|
| Authorization code | 60 seconds, single use |
| Access token | 1 hour |
| Refresh token | 30 days of idleness, rotated on every use |
| Refresh token, absolute | 90 days from the consent |
| Consent request | 10 minutes for the person to answer |
Access tokens are opaque and start moon_; refresh tokens start moon_. Both are stored only as a hash, so a database read cannot recover one.
Reusing an authorization code revokes the tokens that were minted from it.
The 401 challenge
A /mcp request with no usable credential answers a real 401 — never a 200 carrying isError — with:
HTTP
WWW-Authenticate: Bearer resource_metadata="https://api.themooncard.com/.well-known/oauth-protected-resource/mcp", scope="cards:read cards:write profile:read profile:write cards:pay profile:reveal funds:write autonomy:approve"
Access-Control-Expose-Headers: WWW-AuthenticateThat header is what turns a 401 into a Connect button: the client reads resource_, fetches the document it names, finds the authorization server and starts the flow. Without Access-Control-Expose-Headers a browser-based client cannot read the challenge at all. scope is the default shopping bundle; the consent screen is where the person narrows it.
When the bearer was OAuth-shaped but expired, revoked or unknown, the challenge additionally carries error="invalid_, which tells the client to refresh or reconnect rather than start discovery from nothing.
Asking for a wider scope
The re-authorization after an insufficient_ refusal is an ordinary /oauth/authorize request (authorization code + PKCE) carrying the wider scope — everything the connection holds plus what it needs. When the account already has a live connection to this application whose permissions the request strictly contains, the consent is incremental: the screen shows only the new permissions, and redeeming the code widens that connection — same Connected apps entry, same absolute lifetime. At that moment every token the connection had issued is revoked first, and the client continues on the new token pair. An approval whose code is never redeemed changes nothing.
A refresh is never the way to widen a connection: it cannot add scope, and its answer's scope still lists only the old permissions. A client that refreshes anyway must read that scope, see the permission is still missing, and go on to the authorization request.
Protocol error codes
RFC 6749 shape — { "error": "...", "error_ — on the protocol endpoints only. Tool calls keep the tool-error contract.
error | Status | When |
|---|---|---|
invalid_ | 400 | Malformed, wrong content type, or a required parameter is missing |
invalid_ | 401 | Unknown client, or client authentication failed |
invalid_ | 400 | Every dead credential: unknown, consumed or expired code, PKCE mismatch, wrong redirect_, revoked or already-spent refresh token. One code for all of them — distinguishing them would be an oracle |
invalid_ | 400 | A requested permission is not one Moon supports, or not one this client may be granted |
invalid_ | 400 | resource names something this server does not serve |
unsupported_ | 400 | Not authorization_ or refresh_ |
unsupported_ | 400 | Not code |
access_ | — | On the redirect, when the person declined |
invalid_ | 400 | Registration: none of the redirect URIs is acceptable |
invalid_ | 400 | Registration: the body is too large or is not an object |
/oauth/token takes application/x-www-form-urlencoded only; a JSON body is invalid_. /oauth/register takes JSON.
Rate limits on the protocol endpoints, counted per calling address: registration 10 an hour, authorization 30 a minute, token and revocation 60 a minute each. Discovery is not limited.
Tools
The tables below are the organization toolset — what a partner API key sees. An OAuth connection can call the same tools narrowed to the permissions it was granted (the rest are listed but answer insufficient_), plus the two approval tools described in The person's side; a cardholder token sees the smaller set described in Connection types.
tools/list can show a few more tools than these tables name — for saved checkout details (get_, update_ and their siblings, behind profile:read / profile:write), for requests to turn ask-first off, and for agents buying on their own. On an account where a feature is not enabled they answer with an empty result or a coded refusal and change nothing.
When a tool fails, it answers with a coded error and a next_ rather than a protocol error. See Errors below and Coded tool errors and next_step for every code.
Cards
| Tool | What it does |
|---|---|
create_ | Issues a virtual card funded from the prepaid balance |
get_ | Masked card details by id |
list_ | Paginated card list |
freeze_ | Blocks further authorizations. Reversible |
unfreeze_ | Undoes a freeze |
terminate_ | Permanently terminates a card |
reveal_ | Raw PAN/CVV. Requires the opt-in — see Revealing card credentials. Refuses on an ask-first card |
get_ | Paginated spend on a card; include_ adds top-ups |
list_ | The recent declines on a card, with the descriptor the network really sent |
add_ | Tops up an existing card from the prepaid balance. Takes idempotency_ |
list_ | The card products you may issue from |
Every card the tools return is masked: pan and cvv are stripped and you get id, last_, display_, balance, status and the other non-sensitive fields, plus single_, merchant_ and ask_.
create_card
| Parameter | Type | Notes |
|---|---|---|
amount | number, required | Card balance, funded from the prepaid balance |
currency | currency code | Defaults to USD |
idempotency_ | string, 1–255 | Your own id for this card. Strongly recommended — see Idempotency |
cardholder_ | UUID | An existing Moon cardholder. Mutually exclusive with end_ |
end_ | string | Your own id for the cardholder; resolved, and created on first sight |
name | string | A label for the card |
single_ | boolean | One approved authorization, then declines. See below |
merchant_ | { name?, mcc? } | Locks the card to one merchant. Eligible products only |
ask_ | boolean | Nothing is charged until the person who holds the card approves it |
card_ | UUID | Product to issue from. Must be enabled for you |
metadata | object of string→string | Stored verbatim and returned on reads. See Metadata |
Passing both cardholder_ and end_ returns cardholder_. Either one puts the card on that cardholder, and your organization's cardholder-verification requirement then applies exactly as it does on POST /v2/api-gateway/cards.
Single-use cards
create_ issues a card that accepts exactly one authorization. The first purchase is approved; every later authorization is declined with AUTHORIZATION_. Use it for a one-off payment where the card number goes to a merchant you do not want to be able to charge it again.
The card is not closed, and that is the design. The clearing for that purchase, a reversal of it, and a refund from the merchant all still post to the card, and the balance is still yours to move. A card that closed itself on first use would bounce the refund. Use terminate_ when you want it gone.
There is no hidden single-use card state. The card is created with one fixed velocity control — type: COUNT, velocity_, limit_, mode: STRICT — and that control is what the authorization engine reads. So:
get_andcard list_reportcards single_for as long as that control is on the card.use: true - The control appears in
list_andvelocity_ controls get_like any other, flaggedvelocity_ control fixed: true, andget_reportsvelocity_ control_ status usedout oflimitinAUTHORIZATIONS. update_andvelocity_ control delete_refuse it withvelocity_ control control_. An editable single-use limit is a default, not a limit.is_ fixed - A reversed authorization does not free the slot. The count is authorizations approved, not purchases that stuck.
- A zero-amount verification — what many subscription merchants send to check a card before charging it — moves no money, posts no ledger entry and is not counted. The card still has its one authorization when the real charge arrives.
Merchant lock
create_ issues a card that can only be used at one merchant. An authorization anywhere else is declined with MERCHANT_.
| Tool | What it does |
|---|---|
update_ | Re-points an existing lock at a different merchant |
remove_ | Unlocks the card; succeeds on a card that was never locked |
How a merchant is matched. Give at least one of name and mcc; when you give both, both must match.
nameis matched case-insensitively as a substring of the acquirer's merchant descriptor, after trimming. Acquirers pad and prefix that descriptor unpredictably, soACMEmatchesPAY *ACME SAN FRANCISCOandACME *ORDERalike. Send the shortest string that identifies the merchant, not the padded form you saw on one transaction.mccis an exact four-digit merchant category code. It is never matched as a substring:573does not match5734.- An authorization that carries no merchant name — or no MCC, when the lock asks for one — does not match. A missing descriptor is not a licence to charge the card.
- A zero-amount verification is matched like any other authorization: a merchant the lock does not name cannot even verify the card.
Only on card products that support merchant locks. A lock is only as good as the merchant identity that arrives with an authorization, and not every card product's authorizations carry one. list_ reports supports_ on every product you may issue from; pick one where it is true. A merchant_ on a product that does not support one returns merchant_, whose details.merchant_ lists the eligible products enabled for your organization. The refusal happens before the card is issued, so a rejected request costs you nothing to clean up.
Like single-use, the lock is a fixed velocity control — type: SPEND, velocity_, limit_, mode: STRICT, only_. limit_ is the "no cap" convention here: the lock restricts where the card works, never how much it may spend. So:
get_andcard list_reportcards merchant_(the entry, orlock null) for as long as the control is on the card.update_andvelocity_ control delete_refuse it withvelocity_ control control_. Useis_ fixed update_andcard_ merchant_ lock remove_instead.card_ merchant_ lock update_replaces the lock; it is one merchant, not a list you append to. It does not create one, so a card issued without a lock answerscard_ merchant_ lock merchant_— set the lock when you issue the card.lock_ not_ found remove_is safe on an unlocked card: it succeeds withcard_ merchant_ lock removed: false.
A card can be single-use and merchant-locked and ask-first. Those are three independent fixed controls, and any one of them can decline an authorization on its own.
Ask-first cards and purchase intents
An ask-first card charges nothing until the person who holds it has approved that exact purchase.
| Tool | What it does |
|---|---|
enable_ | Puts an existing card in ask-first mode |
disable_ | Takes it back out |
request_ | Asks the person to approve a purchase. Takes idempotency_ |
wait_ | Waits (1–45 s) until the person answers a request (cards:write) |
get_ | The card credential for one approved purchase, plus what that approval covers (cards:pay on OAuth) |
get_ | The personal details the person approved for one purchase (profile:reveal on OAuth) |
wait_ | Waits (1–45 s) for the charge, or the decline, after paying (cards:read) |
get_ | One approval: status, what was asked, how much of it is left |
list_ | Paginated list; filter by card_, status or recurring |
cancel_ | Withdraws a request nobody has answered, ends an open order, or stops a standing approval |
list_ | Why the last charges were declined, with the real acquirer descriptor |
The REST equivalents, the full field reference and the errors are on Ask-first cards. The short version for an agent — every wait is a tool, so this works on any MCP client:
request_before you pay, on a card that asks first (the account line names the newest one). When the store prices in another currency, sendpurchase({ card_ id, merchant, merchant_ domain, amount, reason, kind }) merchant_+amount merchant_instead ofcurrency amount.- Moon emails the person a one-time link and answers
{ status: 'approval_.pending', deduplicated, intent_ id, expires_ at, answer_ by, next_ step } answer_is the answer deadline in words — "Answer by 14:45 UTC (in 60 minutes)" — the same sentence the email carries, so you can tell the person when to answer.by deduplicated: truemeans you had already asked for this exact purchase and the person has not answered yet: nothing new was created and nobody was emailed again, so wait for that request rather than asking a third time. - Tell the person where to approve — the emailed link, or Agents → Approvals (
/agents/approvals) on the dashboard — then callwait_. At its timeout it returns the request still awaiting approval withfor_ approval({ intent_ id }) timed_; call it again. (Over REST, or with a webhook receiver, theout: true PURCHASE_webhook says the same.) Never attempt the payment first, and never retry it in a loop. The answer is a person's.INTENT_ APPROVED - Pay, in the browser you are driving: call
get_— pluspayment_ credential({ intent_ id }) get_when the approval named personal details — and type them into that site's checkout form only, never into the chat. See How the agent pays.checkout_ details({ intent_ id }) wait_once, then tell the person what was charged — "charged amount at *merchant*" — or why it was declined.for_ charge({ intent_ id })
reveal_ (cards:reveal) is not needed for any of this.
Waiting for the person and for the charge
Most MCP clients cannot receive a webhook, so the two waits of an ask-first purchase are tools. Both take intent_ and timeout_ (1–45, default 30), and both are organization-tier only; a cardholder connection has neither.
wait_(for_ approval cards:write) returns as soon as the request leavesAWAITING_—APPROVAL APPROVED,DENIED,EXPIRED,CANCELLEDorCONSUMED— with exactly whatget_returns pluspurchase_ intent timed_. At the timeout it returns the same request, stillout: false AWAITING_, withAPPROVAL timed_and aout: true next_to call it again: a result, never a tool error. Another account's request, or none, answersstep purchase_.intent_ not_ found wait_(for_ charge cards:read) resolves when an authorization spent the approval, with{ charged: true, transaction: { transaction_—id, merchant_ descriptor, amount, currency, merchant_ amount, merchant_ currency, status } } amountin the card's currency,merchant_in the currency the merchant charged in. When the card has a decline since the approval that belongs to this approval, it answersamount { charged: false, declined: true, decline: { reason_. A decline another live approval on the card could equally own is never named: the wait times out with acode, merchant_ descriptor, amount, currency, occurred_ at } } next_to readstep list_. A request that wascard_ declines DENIED,EXPIREDorCANCELLEDwithout being spent answers at once withcharged: falseand anext_to ask again. At the timeout:step { charged: false, timed_. For an order or a standing approval this is the first charge.out: true }
A waiting call counts once against the connection's rate limit, however long it waits. The 45-second ceiling keeps a waiting call inside the idle timeouts between your client and Moon; call again rather than asking for longer.
Under the hood ask-first is one more fixed velocity control (INTENT over TOTAL), so it lists like any other control, flagged fixed: true, and the generic control tools refuse to change it. limit_ is inert on it.
Both helpers are idempotent and say which they were: enable_ on a card that already asks first answers enabled: false, and disable_ on one that did not answers disabled: false. Disabling leaves approvals the person already gave alone — they stop being consulted, and count again if ask-first is turned back on before they expire.
Which kind of approval to ask for
kind | What the person is agreeing to | Use it for |
|---|---|---|
single (default) | One merchant, one amount inside a symmetric tolerance band, exactly one authorization, spendable for 15 minutes once approved | Pay-now digital goods: a subscription renewal, a top-up, an API bill — anything charged at checkout for exactly the number that was quoted |
order | A budget at one merchant, any number of charges, for valid_ days from the moment they approve | Physical goods, marketplaces, food and ride apps, groceries, travel — anything that ships, tips, splits across shipments or saves the card on file |
recurring: true | A standing permission at one merchant, bounded by monthly_ per UTC calendar month, with no expiry | Subscriptions and free trials |
order and recurring cannot be combined: one is a budget for a few days, the other a permission with no end date.
A single approval is declined by a small verification authorization, by a charge that arrives when the order ships rather than when it is placed, by a split shipment, and by a final amount a few percent off the estimate. Every one of those is ordinary, and every one of them is what order is for.
request_purchase
| Parameter | Type | Notes |
|---|---|---|
card_ | UUID, required | Must be an ask-first card, or card_ |
merchant | { name?, mcc? }, required | At least one. name is one name or an array of up to 5; any one matching is enough |
amount | number | In the card's own currency, major units. Either this or merchant_ + merchant_, never both |
merchant_ | number | The price exactly as the store shows it, in merchant_, major units. Moon converts it; do not convert it yourself. See Prices in the store's currency |
merchant_ | ISO 4217, uppercase | One of USD, MXN, EUR, CAD, GBP, JPY, AUD, BRL. Required with merchant_ |
reason | string, 1–200, required | The sentence the person reads in the email. Write it for them |
kind | single or order | Default single. Cannot be combined with recurring |
merchant_ | bare hostname | The site the purchase is made on. Advisory — see below. A URL is rejected, not tidied up |
currency | currency code | Defaults to the card's; must equal it when given |
tolerance | { percent?, absolute? } | percent at most 20; absolute in the card's currency, major units, at most amount. Default one minor unit on a single, 10 percent on an order, and 10 percent on either kind when the price is given as merchant_ |
answer_ | integer 5–1440 | How long the person has to answer. Default 60. Sets expires_ on the request; it does not change how long an approval may be spent |
valid_ | integer 1–90 | An order's window, from the moment the person approves. Default 14. Rejected on any other kind. Marketplaces and pre-orders charge at shipment: cover the latest delivery estimate plus a week |
recurring | boolean, default false | Ask for a standing approval instead of a one-off |
monthly_ | number | Required with recurring, rejected without it. How much this merchant may charge per UTC calendar month, in the card's currency; at least amount |
first_ | integer 1–60 | How long a recurring approval waits for its FIRST charge. Default 45. Rejected without recurring |
idempotency_ | string, 1–255 | A retry with the same key never emails the person twice |
profile_ | array of field names | The saved checkout details this purchase needs — any of name, email, phone, national_, date_, billing_, shipping_. The person sees them on the approval and approves them with it. Needs merchant_; not on a recurring approval. Ask for the minimum the checkout needs |
shipping_ | UUID | Which saved shipping address, with shipping_ in profile_. Default: the profile's default address; with none saved, the billing address |
A price given as merchant_ is converted into the card's currency at a market rate when you ask and stored as amount; the intent also keeps merchant_, merchant_ and request_, and the person is shown both figures. When Moon has no rate for that currency at the moment, the call fails with merchant_ and nothing is sent: ask again with amount.
Asking twice. A request_ identical to one the person has not answered yet — same card, kind, merchant, amount, terms and store price — answers with the request already waiting, deduplicated: true, and emails nobody. Every other answer carries deduplicated: false. idempotency_ is still the tool for your own retries. See Identical requests are asked once for exactly what is compared, and for how duplicate approvals are cancelled once one of them is spent.
How a charge is matched
The merchant is matched exactly as a merchant lock is: name case-insensitively as a substring of the acquirer descriptor, mcc exactly, and every field the approval names has to match.
merchant.name may be one name or an array of up to five, and any one of them matching is enough. The acquirer descriptor is not knowable before the first charge — a marketplace bills as an abbreviation, an app store as a billing domain, a hosted store under its platform's prefix — so an approval naming only the brand misses all of them. Moon also expands a brand it recognises into the statement descriptors that brand is known to bill under, at request time, and the person is shown the whole list before deciding. Reads return every name as merchant_. That table is best-effort: treat a miss as ordinary and use list_.
The amount rule depends on the kind:
- A
singleapproval accepts a charge inside the tolerance band, which is the wider ofpercentandabsoluteand is symmetric — a charge below the approved amount is outside it too, so a merchant that authorizes one cent before the real charge cannot spend a $500 approval on the cent. - An
orderaccepts any charge whilespent + this charge <= max_, with no lower bound per charge.total - A
recurringapproval accepts its first charge up tomonthly_, and every later charge while the UTC calendar month's total stays insidelimit monthly_.limit
When more than one approval at the same merchant could take a charge, Moon tries them in this order: an exact single or recurring band match, then the open order budgets (oldest first), then a recurring approval's first charge against its monthly limit, then the standing ones. A recurring first charge accepts anything up to a month's budget, so it is tried after the orders — otherwise a shipment would spend a subscription's one first charge.
A zero-amount card verification passes on a merchant match alone and does not spend the approval.
get_payment_credential
The agent-facing half of an ask-first card: the credential for one approved purchase, plus exactly what that approval covers.
JSON
{
"intent_id": "6c1f9a84-0b73-4d52-9e18-3a7c5d2b4e60",
"card_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"credential": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"last_four": "1111",
"pan": "4111111111111111",
"cvv": "123",
"expiration": "2029-11-30T00:00:00.000Z",
"display_expiration": "11/29"
},
"approved_for": {
"merchant": "ACME STORE",
"merchant_mcc": "5734",
"merchant_domain": "shop.example.com",
"amount": 24.99,
"currency": "USD",
"merchant_amount": null,
"merchant_currency": null,
"min_amount": 24.98,
"max_amount": 25.00,
"expires_at": "2026-09-20T17:05:00.000Z",
"recurring": false,
"monthly_limit": null,
"recurring_spent_this_month": 0,
"kind": "single",
"merchant_names": ["ACME STORE", "ACME*ORDER"],
"max_total": null,
"spent": null,
"remaining": null
},
"reveals_remaining": 9,
"handling": "…"
}- Opt-in:
x-moon-allow-pan: trueon an API-key connection (the same headerreveal_needs); thecard_ credentials cards:paypermission on an OAuth one, which the default Shop with my approval preset carries. It is a different permission fromcards:revealbecause it releases a number only for a purchase the person approved, and the authorization gate declines anything outside that approval. - Same credential, read through the same path. There is no second way to read a PAN.
min_/amount max_are the band the authorization gate will actually accept, and there are three answers because three different rules decide the charge. On aamount singleapproval it is the symmetric band aroundamount, so a charge below it is outside just as one above it is. On an order,min_isamount nullandmax_isamount remaining. On a recurring approval,min_isamount nullandmax_is the monthly limit — the whole of it before the first charge, and what is left of this UTC month afterwards.amount handlingis a fixed paragraph and part of the contract: use the credential only on the approved site, never print, summarise, log or store the number, expiry or security code, expect a decline at another merchant or outside the range, and stop and ask the person if the page asks for anything unexpected. It arrives in the same payload as the number because rules that arrive separately are rules nobody read.- No personal details. Moon never returns a name, an address or a national ID with the credential. If the person approved some for this purchase (
profile_), a connection that opted in reads them withfields get_. Anything else the checkout asks for, ask the person and enter exactly what they give — never invent it and never reuse it from another order.checkout_ details - One-time verification codes are not returned here either. They arrive through the channels that already carry them: by email for a consumer card, as a
THREE_webhook for a partner card. The agent waits for the code and uses it once; it must never guess one, reuse one, or ask the person to turn the check off.DS_ OTP reveals_counts down from a cap of 10 per approval. Call it once per purchase and complete the payment with what it returns; it is not a per-retry call. The count is recorded before the card is read, and nothing ever puts it back.remaining - When it refuses.
purchase_withintent_ not_ usable details.status:AWAITING_means wait (APPROVAL wait_), andfor_ approval DENIED,EXPIRED,CANCELLEDor a spent one-offCONSUMEDall mean ask again with a freshrequest_. A standing recurring approval readspurchase CONSUMEDand stays usable while it is in force, and an open order goes on working for its whole window.too_when the cap is used up. An approval that is not yours, or does not exist, answersmany_ credential_ reveals purchase_— the same answer for both.intent_ not_ found - Audited. Every call writes the same warning line
reveal_does — card, principal, client id, agent key thumbprint — plus the approval id, and never any part of the credential.card_ credentials
The cap and the count are per approval, not per agent: two agents sharing one approval share its budget of reveals.
get_checkout_details
The checkout details a person approved for one purchase, handed to the connection that asked, for its own code to type into that checkout. The same bargain as get_, applied to personal details.
get_checkout_details({ intent_id, fields? })
→ { intent_id,
profile: { name?, email?, phone?, national_id?, date_of_birth?,
shipping_address?, billing_address? },
fields, // the names released
profile_shipping_address_source, // "shipping" | "billing" | null
approved_for: { merchant, merchant_domain },
profile_releases_remaining,
handling: "…" }- Opt-in. OAuth: the
profile:revealpermission, part of Shop with my approval. API key: the headerx-moon-allow-profile: true; without it the answer isprofile_and nothing is read or counted. A cardholder connection is never offered the tool. On an account where checkout details are not released to agents, the answer isreveal_ not_ enabled checkout_— the person enters them at checkout.details_ not_ available - Only what the approval named.
fields(optional) narrows the answer to a subset of the approved details; a field the approval did not name refuses the whole call (profile_,fields_ not_ approved details.not_) before anything is counted. Ask for the details withapproved profile_onfields request_.purchase - Only a usable approval, on your own account.
purchase_for another account's request (the same answer as an id that does not exist);intent_ not_ found purchase_withintent_ not_ usable details.statusfor one that is not approved, or has expired, been cancelled or been spent. - Only the connection that asked. An OAuth connection receives the details only of a request it made; an API key only of a request that same key made. Anything else on the same account answers
checkout_.details_ not_ for_ this_ connection handlingis part of the contract: use the details only for this purchase atapproved_, keep them in your tool code and write them into the page from there, and never print, summarise, log, store or repeat them in the conversation — not even back to the person.for.merchant_ domain - Audited, without values. The person sees each release on the connection's timeline in Settings → Connected apps ("Received your checkout details for the merchant purchase").
The approval email and page tell the person who can receive the details: "Moon gives them only to that checkout, never to the assistant", or, when the request came from a connection that holds profile:reveal or an API key, "The assistant you connected can receive these details to complete this purchase."
merchant_domain , and why it is advisory
A bare hostname — shop.example.com, never https://shop.example.com/checkout. It is trimmed and lower-cased; a URL, a port, an @, an IP address or a unicode domain is rejected with a validation error rather than repaired (send the punycode form of an internationalised name). Guessing which part of a URL was the merchant's site, in a value that goes in front of a person deciding whether to spend money, is worse than a clear refusal.
It is advisory at the network layer, and always will be: the card networks carry no domain in an authorization, so the gate does not and cannot check it, and no charge is ever declined for coming from a different site. What it buys is three things: the approval email and page show it next to the merchant, so ACME STORE stops being a descriptor the person has to take on trust; it is part of the idempotency fingerprint, because a different site is a different question; and get_ names it as the one place the agent may use the credential.
See below.
list_card_declines
list_ returns the most recent declines on a card, newest first, with the acquirer descriptor the network actually sent:
JSON
{
"card_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"declines": [
{
"occurred_at": "2026-09-20T16:52:11.000Z",
"merchant_descriptor": "ACME*ORDER 8821",
"merchant_id": "512339001",
"amount": 24.99,
"currency": "USD",
"decline_type": "INTENT_MISMATCH"
}
]
}This is the self-healing half of the descriptor problem. After an INTENT_: read merchant_, compare it with the name the approval carried, and call request_ again naming what the network really sent (merchant.name takes an array, so both can be named). Do not retry the payment against the old approval.
At most 20 rows, and limit defaults to 20. It returns no card data — no PAN, no expiry, no raw processor payload — and no merchant category, because a decline does not record one.
Lifetime, and what ends an approval
Two clocks. The person has answer_ to answer (60 by default, 5–1440); until then expires_ is that deadline. The moment they approve, expires_ is rewritten to how long the approval may be spent: 15 minutes from the approval for a single approval, which pays for exactly one purchase however long the person took to answer; valid_ for an order; first_ for a recurring approval. get_ and list_ then also report it as spendable_ (null before approval and on a recurring approval). See The answer window.
There is no sweep job: an approval past expires_ reads as EXPIRED the first time it is read, and the PURCHASE_ webhook is emitted at that point. An authorization never emits it — it answers the card network under a deadline, so it treats an expired approval as absent and declines INTENT_. Treat expires_ as the deadline, not the event's arrival.
- An order closes only when its budget is gone (
spent + this charge >= max_). There is no "that was the final capture" signal in an authorization stream, so until the budget runs out the order staystotal APPROVEDand ends when its window does. A closed order readsCONSUMEDand authorizes nothing further. At the end of its window, an order that was charged moves toCONSUMEDand emits noPURCHASE_— it is a purchase that happened, not an approval nobody answered.INTENT_ EXPIRED - A charge that would take an order past
max_, or that arrives after the window, is declinedtotal INTENT_and nothing is recorded.MISMATCH - Refunds and reversals do not give budget back, on an order or on a recurring approval. This is deliberately conservative.
- A standing recurring approval reads
CONSUMEDwhile it is still in force, because the first charge consumed it. It is also pinned to the merchant category it was first used at (recurring_); a later authorization carrying a different category is refused.mcc - A card holds at most 20 live approvals and at most 20 standing approvals at once (
too_,many_ pending_ intents too_). An approved order stays live for its whole window and a standing approval never expires, so those caps are cleared by cancelling, not by waiting.many_ recurring_ approvals
cancel_ withdraws a request nobody has answered. It cannot take back an approval a person already gave: an APPROVED single or a spent one-off CONSUMED answers purchase_. Two exceptions, and both are endings rather than reversals — an open order (APPROVED) is closed early, and a standing recurring approval is stopped. The person can do the same from their approval link, and either party ending it emits PURCHASE_ carrying cancelled_.
Declines
decline_ | Meaning | What to do |
|---|---|---|
INTENT_ | No approved, unspent, unexpired approval for this card | Ask. request_, then wait |
INTENT_ | An approval exists, but for another merchant, or outside its band or budget | Ask again for the real purchase. Read list_ for the descriptor the network sent |
Both arrive as an ordinary CARD_ webhook as well. They mean different things and are answered differently, so branch on the type.
The person's side
Two tools record the approver's answer: list_ and decide_. They are registered on two kinds of connection, and never on a partner API key — that is the credential the asking agent uses, and letting it answer too would turn ask-first into self-approval.
| Connection | What it can answer |
|---|---|
| Cardholder token | Purchases on that cardholder's own cards |
OAuth connection holding cards:write | Purchases whose approver is the account itself: cards with no cardholder (approver_), and cards issued without naming a cardholder, which carry your organization's own cardholder. A purchase on a card you issued to somebody else is theirs to answer and answers purchase_ here |
Both tools carry this sentence in their description and in the instructions:
A decision recorded this way reads approved_, and Moon emails the approver a receipt of it — approve and deny alike — so a person always hears about a purchase answered in their name. decide_ works once: an intent that has already been answered, spent, cancelled or has expired answers purchase_.
The person can also answer without any assistant:
- From the email. The link opens a page showing the merchant, the amount, the card's last four digits and the reason, with an Approve and a Deny button, and — for an order or a standing approval — a Stop button afterwards that ends it. The link takes no login and stops working at
expires_. The decision readsat approved_.via: email
How the agent pays
The agent pays with an ask-first card in the browser it is driving; it never hands the purchase off.
- The agent types it.
request_(withpurchase merchant_) →domain wait_→for_ approval get_(andpayment_ credential get_when the approval named details) → enter them into that site's checkout form →checkout_ details wait_. The number is in the agent's transcript, and what makes that acceptable is that the approval makes it worthless anywhere else. On OAuth this needsfor_ charge cards:pay, which the default preset carries; without it the agent cannot pay, and tells the person to reconnect and tick "Card details for approved purchases". Sending the person to another browser to finish is the last resort, never the default.
If the shop asks for a sign-in or a CAPTCHA, the agent tells the person to sign in (or solve it) on that site in the browser the agent is driving, waits, and continues there.
Agent keys and signed requests
Signing is optional, and the reason to bother shows up on the approval page: an unsigned request has no agent identity, so the person deciding whether to let a purchase through reads "an agent", and the intent, the webhooks and the audit lines carry nothing that says which one asked. A signed request names you.
| Tool | What it does |
|---|---|
register_ | Registers an Ed25519 public key; returns the key_ |
list_ | Every key, revoked ones included |
revoke_ | Stops accepting signatures from a key, from the next request on |
Organization tier only. A cardholder connection answers approvals; it does not administer agents.
The format is RFC 9421 HTTP Message Signatures over Ed25519, keyed by the RFC 7638 JWK thumbprint and tagged web-bot-auth, so one key pair serves Moon and anything else that speaks the same format.
Registering
Generate an Ed25519 key pair, keep the private half, and send the public half:
JSON
{
"label": "Acme checkout agent",
"public_key_jwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "7UkoxijRwsbq6QM4kFmVYSlZJzpcY_k2NsFGFKyHN9E"
}
}Moon computes the key_; you do not choose it. It is the RFC 7638 thumbprint, base64url(sha256({"crv","kty","x"})) with the members in lexicographic order — for the key above, nRIE2VmKdMjL1JD7tbV7fXVgXxmv0GKnMFWRUJMTf9Q. label is what the person reads ("Requested by Acme checkout agent (key …JMTf9Q)"), so write it for them.
Signing a request
Build the signature base exactly as RFC 9421 §2.5 describes — one line per covered component, "name": value, in the order you listed them, then "@signature-params" last, joined with single newlines and with no trailing newline:
"@authority": https://api.themooncard.com
"@method": POST
"@path": /mcp
"content-digest": sha-256=:Zv+ozntR3wbCbtdJ7RUqqaGeyc53kY3ILHPUXv9UHx0=:
"@signature-params": ("@authority" "@method" "@path" "content-digest");created=1789592400;expires=1789592700;keyid="nRIE2VmKdMjL1JD7tbV7fXVgXxmv0GKnMFWRUJMTf9Q";alg="ed25519";nonce="Zk9xQ2pUbVZzWDdhTg";tag="web-bot-auth"Sign those bytes with the private key and send three headers alongside the usual Authorization header:
HTTP
Content-Digest: sha-256=:Zv+ozntR3wbCbtdJ7RUqqaGeyc53kY3ILHPUXv9UHx0=:
Signature-Input: sig1=("@authority" "@method" "@path" "content-digest");created=1789592400;expires=1789592700;keyid="nRIE2VmKdMjL1JD7tbV7fXVgXxmv0GKnMFWRUJMTf9Q";alg="ed25519";nonce="Zk9xQ2pUbVZzWDdhTg";tag="web-bot-auth"
Signature: sig1=:r5P4wDUyQU0mlBnDtjqFZjoN7u/E6jcMXB5aHaVI2TH+66dG2OcqoeR9kyxFM12tMJfPHb991By7RruSoJjgDA==:The @signature-params line must repeat the Signature-Input value byte for byte. Moon verifies it against its own public hostname rather than against the Host header that reaches the service, so no proxy between you and Moon can make an honest signature fail, and a signature made for another host is refused.
What Moon checks
| Rule | Detail |
|---|---|
tag | Must be web-bot-auth. Exactly one signature may carry it |
alg | ed25519, or omitted |
keyid | A key you registered and have not revoked |
| Covered components | @authority and content-digest, plus either signature-agent or both @method and @path |
content-digest | Required on a signed request, verified against the body bytes |
created / expires | expires in the future, created at most 60 s ahead of now, at most 300 s apart |
nonce | Required, and accepted once |
content-digest is not optional. Without it a signature binds the endpoint and the time window but not the body, so anyone who can see the request could re-attach the same signature to different arguments until it expires — and the purchase intent, card or credential reveal that resulted would be attributed, on the approval email the person reads and in the audit log, to an agent that never asked for it.
Get any of this wrong and the request is refused 401 with a body carrying data.code: "invalid_ and a next_. It is never silently downgraded to unsigned — a signature that can be stripped for free is not a signature. The body says only that the signature did not verify, never which check failed: that distinction would let anyone holding a valid bearer enumerate your live keys.
Two limits worth knowing:
- Replay protection is fleet-wide, and falls back to per process. A nonce is claimed in a store shared by every API instance, so a captured request is refused at the second instance as well as the first. If that store cannot be reached, the instance answering falls back to its own memory for that request rather than refusing every signed call for the duration of an outage — during which a captured request could again be replayed once per instance inside its window. The required
content-digestmeans such a replay can only ever repeat the same call, and the short window bounds how long even that is possible. - Signatures are verified on the MCP endpoint only. The REST gateway authenticates an API key and nothing more, so an intent created through
POST /v2/api-gateway/purchase-intentshonestly reportsagent_.key_ id: null
Where the identity shows up
get_,purchase_ intent list_,purchase_ intents list_and the REST reads reportpending_ approvals agent: { key_, orid, label } nullwhen the request was unsigned.- The approval email and the approval page say "Requested by label (key …last 8 of the thumbprint)".
PURCHASE_webhooks carryINTENT_ * agent_(the thumbprint) andkey_ id agent_. See Webhooks.label create_and the credential tools name the key on their audit lines.card
The label is read fresh on every request, so renaming a key renames it everywhere, including on intents that already exist. The thumbprint never changes, which is why it — and not the label — is what an audit trail should be keyed on. Revoking a key does not erase any of this.
Velocity controls
| Tool | What it does |
|---|---|
create_ | Caps spend or usage on one card, cardholder or external customer id |
list_ | Paginated control list, filterable by card and searchable |
get_ | One control: limit, type, window, mode, allowlist, target |
update_ | Changes the limit, window, mode, allowlist, enabled flag or metadata |
delete_ | Removes a control |
get_ | Consumed and remaining, in the control's unit, by id or card_ |
get_ | The same status for a cardholder, by cardholder_ or external_ |
A control is two choices — what is capped and over what window — plus how a breach is handled and, optionally, where the card may be used:
| Argument | Values | Meaning |
|---|---|---|
type | SPEND (default), COUNT | SPEND caps the money spent in the window; COUNT caps how many authorizations are approved in it |
velocity_ | MONTH (default), DAY, WEEK, TOTAL | DAY is the UTC calendar day, WEEK the ISO week from Monday, TOTAL the lifetime of the card or cardholder |
mode | STRICT (default), WATCH | STRICT declines a breaching authorization. WATCH never declines — it sends a VELOCITY_ webhook instead |
only_ | [{ name?, mcc? }], at most 50 | Allowlist. An authorization whose merchant matches no entry breaches the control |
Every window is UTC. Omitting type and velocity_ still produces the SPEND / MONTH control they always produced, so an agent written before these arguments existed keeps working unchanged.
limit_ is in the control's unit: on a SPEND control, an amount in the card's own currency — the currency of the ledger account the card draws on, which is what the authorization engine compares against, so a limit of 100 on an MXN account is 100 MXN and not 100 USD. On a COUNT control it is a whole number of authorizations, at least 1; a fractional or sub-1 COUNT limit is a validation_.
A merchant matches an only_ entry when every field the entry gives matches — name case-insensitively as a substring of the acquirer descriptor, mcc exactly. Any entry matching is enough, and an empty list means no restriction.
On a SPEND control limit_ means "nothing may be spent" as it always has — unless the control also carries an only_ list, in which case it means "no spending cap, the allowlist is the whole point". That exception exists so a merchant lock does not have to invent a large fake limit, and it is narrow, so a card frozen with a zero-limit control stays frozen.
Fixed controls. A control read back with fixed: true was created by Moon as part of the card it belongs to — the one-authorization limit on a single-use card, the merchant lock on a locked one, or the ask-first marker. It lists and reads like any other control, but update_ and delete_ refuse it with control_. fixed is read-only, and it is a reserved key in control metadata: sending it on a control of your own returns velocity_.
mode: WATCH
A WATCH control is how you size a limit before enforcing it. It never declines: when an authorization would have breached it, the authorization is decided as if the control were not there and a VELOCITY_ webhook is sent instead, carrying limit, observed (which includes that transaction), the merchant and the transaction id. Collect those for a while, then update_ the mode to STRICT. See Webhooks.
Status reads
get_ and get_ report used and limit in the control's unit, with unit (AMOUNT or AUTHORIZATIONS), the currency an AMOUNT is denominated in (null on a COUNT control) and the window_ / window_ the number was measured over. Both boundaries are null for a TOTAL control: the lifetime of a card has neither.
Common shapes
JSON
{ "card_id": "…", "type": "COUNT", "velocity_window": "TOTAL", "limit_amount": 1 }JSON
{ "card_id": "…", "type": "SPEND", "velocity_window": "TOTAL", "limit_amount": 0,
"only_merchants": [{ "name": "ACME" }] }JSON
{ "card_id": "…", "velocity_window": "DAY", "limit_amount": 50, "mode": "WATCH" }create_ takes exactly one of card_, cardholder_ or external_; two targets or none returns validation_ naming the fields.
Cardholders and verification
| Tool | What it does |
|---|---|
create_ | Creates the person a card is issued to |
get_ | Cardholder by id |
list_ | Paginated cardholder list, with partial-match search |
get_ | Masked cards issued to a cardholder |
create_ | A hosted verification URL to share with that person |
get_ | Identity-verification status of a cardholder |
Onboarding an end customer:
create_with your owncardholder external_(andid emailif you have one). Keep the returnedid.create_with that id, and share the returnedcardholder_ kyc_ link kyc_with the person — it is a hosted flow, and Moon never asks you to collect the documents.url kyc_isurl nullwhen the cardholder is already in a status that cannot be restarted (approved, in review, declined); readstatusinstead.- Poll
get_until it reports approved. Acardholder_ kyc_ status kyc_error from any card call means this step is not finished.required create_with thatcard cardholder_.id
Webhooks
| Tool | What it does |
|---|---|
get_ | The registered endpoint (never the secret) |
create_ | Registers the URL Moon posts events to |
delete_ | Stops deliveries |
rotate_ | Issues a new signing secret |
There is one endpoint per organization and it receives every event type, so create_ replaces any previously registered URL. The signing secret is returned only by create_ and rotate_, in the response that creates it; there is no read endpoint for it. See Webhooks.
Connected applications
| Tool | What it does |
|---|---|
list_ | Every application connected through the Moon consent screen, newest first: id, name, permissions, who approved it, when, last used |
revoke_ | Disconnects one, by grant_. Takes effect on its very next request |
Both sit behind cards:write, because reading the list of connections is a step in removing one. Neither lists API keys or agent signing keys; those are separate. Note that a connection can revoke itself, which ends that session.
Sandbox simulation
Both tools are Sandbox only. They are advertised in every environment, but in production they return sandbox_ without doing anything.
| Tool | What it does |
|---|---|
simulate_ | Makes a card spend — purchase, refund or withdrawal — with no merchant |
Getting unblocked
Every organization brief has a GETTING UNBLOCKED section (get_), one line per situation, with the code a tool returns:
| Situation | What happens next |
|---|---|
A tool answers insufficient_ | Ask the client to reconnect with the permission in missing_ (a one-step re-approval); if it cannot, the person reconnects at /settings/connected-apps |
| No card asks first | The person picks one at /agents/connect. On an API key: create_ with ask_ |
| The approval is still pending | wait_ again |
agent_ | Use request_ on a card that asks first; never turn ask-first off |
purchase_, or a decline INTENT_ | Ask again with the right amount, currency and merchant |
| The shop asks for a sign-in or a CAPTCHA | The person signs in (or solves it) on that site in the browser the agent is driving; the agent waits, then goes on there |
approval_ | Do what next_ says: the cardholder, or the account, needs an email address |
Where the person goes
Some things only the person can do, in the Moon app.
| The person needs to | Page |
|---|---|
| Approve a purchase | The emailed link, or /agents/approvals |
| Add funds | /settings/moon-credit |
| Save personal details for checkout | /settings/checkout-details — never typed into the chat |
| Change permissions, disconnect or reconnect an application | /settings/connected-apps |
| Connect a new assistant, choose the cards that ask first | /agents/connect |
The agent may say "open your approvals at *url*"; it never approves or denies for the person.
Idempotency
create_, add_ and request_ take an idempotency_ (1–255 characters; use your own order id). A retry with the same key and the same arguments replays the first answer instead of issuing a second card, topping up twice, or emailing the person a second time. Keys expire after 24 hours, the same as the REST Idempotency-Key header — see Idempotency.
- The key is scoped to your organization and bound to the arguments of the call.
- The same key with different arguments returns
idempotency_. Use a new key for a different card.mismatch - The same key sent while the first call is still running returns
idempotency_. That one isin_ progress retryable: wait a few seconds and call again with the same key. - A call refused before any money moved — not enough the prepaid balance, a frozen card, an unverified cardholder — releases its key. Fix the cause and retry with the same key.
- A call that fails after the money-moving step began binds the key to that failure, and a retry with the same key replays the failure rather than executing again. Its
next_tells you what to read before deciding whether to call again with a new key. This is what keeps one unlucky attempt from becoming two funded cards.step
Without a key nothing changes and a blind retry funds a second card — so if you must omit it, do not retry a create_ whose outcome you do not know. Call list_ and check first.
Limits
Counted per connection — an OAuth connection, a partner API key, or a cardholder — in one-minute windows shared by every Moon API instance:
| Budget | Counts | Limit per minute |
|---|---|---|
| General | Every JSON-RPC message (a batch of twenty counts twenty) | 600 |
| Reveal | Calls to reveal_, get_ or get_ | 30 |
A purchase needs one reveal or one fill, so these sit an order of magnitude above normal use; they bound what a leaked credential can do. A refused call still counts, so retrying before Retry-After only keeps the window full.
These refusals arrive before a tool runs, as a JSON-RPC error whose error.data carries the same coded fields as a tool error (code, message, next_, retryable):
| Status | data.code | When |
|---|---|---|
| 503 | maintenance_ | Moon is in maintenance. Retryable |
| 413 | request_ | The request body is over 1,000,000 bytes |
| 403 / 409 | account_, account_, api_ | The account behind the connection may not use the API right now |
| 400 | batch_ | A JSON-RPC batch of more than 20 messages |
| 429 | rate_ | A budget above is used up. Comes with Retry-After. Retryable |
| 503 | rate_ | The shared counter could not be reached, for a reveal or fill call only. Comes with Retry-After. Retryable |
| 401 | invalid_ | A signed request did not verify |
Errors
A tool failure is not a protocol error. The call succeeds and the result carries isError: true with exactly one JSON text block:
codeis stable and lower-cased. Branch on it, not onmessage.next_names the tool to call next. Follow it.step retryableistrueonly for genuinely transient failures. Retry those with the sameidempotency_; never retry akey false.detailsnever containspanorcvv, whatever raised the error.
Common codes are below; Coded tool errors and next_step lists every code, how retryable is decided and how to write a handler that follows next_.
| Code | Meaning | What to do |
|---|---|---|
validation_ | An argument the handler parsed was invalid | Fix the fields in details.errors |
card_ | No such card for this connection | Check the id, or call list_ |
kyc_ | The cardholder is not verified | Send them the verification link, then poll the status tool |
reveal_ | An API-key connection has not opted in to card numbers | Send x-moon-allow-pan: true. On OAuth the permissions cards:pay and cards:reveal take its place |
insufficient_ | The connection was not granted the permission this tool needs; details.missing_ names it | Ask the client to reconnect with that permission (a one-step re-approval); if it cannot, the person reconnects at /settings/connected-apps. No header turns it on |
profile_ | An API-key connection has not opted in to receiving approved details | Send x-moon-allow-profile: true. On OAuth, profile:reveal takes its place |
checkout_ | The account does not release checkout details to agents | The person enters them at checkout |
profile_ | The approval did not name the details asked for | request_ naming them in profile_ (details.not_ lists them) |
checkout_ | A detail named in profile_ is not saved | details.missing names it; the person adds it at /settings/checkout-details, then ask again |
control_ | The control belongs to a card feature | Use the card-level tool, or terminate the card |
merchant_ | The card product does not support locks | Pick a product from details.merchant_ |
card_ | request_ on a card that does not ask first | Call enable_ first |
merchant_ | Moon has no rate right now to convert merchant_ into the card's currency | Ask again with amount in the card's currency (details.card_). Not retryable as-is |
approval_ | Nobody can be asked | Read details.approver_: cardholder means that person needs an email, account means the account that owns the card does |
purchase_ | reveal_ on an ask-first card | request_, wait, then get_ |
purchase_ | The approval cannot be paid with right now | Read details.status: AWAITING_ means wait, anything else means ask again |
too_ | This approval has handed the credential over as often as it may | Ask for a new approval; the count never resets |
too_ | The card already holds the most live approvals it may | Cancel one with cancel_, or wait for the short-lived ones to expire |
too_ | The card already holds the most standing approvals it may | Cancel one; waiting will not clear it |
purchase_ | Cancel on something already answered, spent or finished | Read it with get_; ask again if the purchase still has to happen |
no_ | Nothing is approved for the site the person is on | request_ naming that site in merchant_, then wait |
merchant_ | The named approval covers another site | details.expected names it; ask again for the site the person is actually on |
invalid_ | A fill named its page with a URL, or with an origin that is not https | Send scheme, host and optional port only |
agent_ | The account owner requires every card a connected agent creates to ask first | Call create_ without ask_ — the card is issued asking first — then request_ for each purchase. Do not ask the person to turn the setting off |
idempotency_ | The first call with this key is still running | Retryable; wait a few seconds, retry with the same key |
idempotency_ | The key is bound to different arguments | Use a new idempotency_, or repeat the originals |
internal_ | Moon failed | Retryable; retry once, then contact support |
Two classes of failure stay outside this envelope by design:
- Transport failures. A missing or invalid bearer is HTTP
401; an unsupported method is HTTP405. - Malformed tool arguments. Arguments that fail a tool's advertised schema are rejected before the handler runs and come back as a tool result whose single text block is a plain message starting with
Input validation error:. Treat anyisErrorresult whose text does not parse as JSON as a malformed-arguments refusal.
Revealing card credentials
Two tools return the raw PAN and CVV, and both sit behind the same opt-in: reveal_ for an ordinary card, and get_ for one approved purchase on an ask-first card.
On an API-key or cardholder connection, add a header:
x-moon-allow-pan: trueJSON
{
"mcpServers": {
"moon": {
"type": "http",
"url": "https://api.themooncard.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY",
"x-moon-allow-pan": "true"
}
}
}
}Without it either tool returns reveal_ explaining how to opt in.
On an OAuth connection the header is not consulted at all: two permissions replace it. get_ needs cards:pay — part of the default Shop with my approval preset, because it only ever releases the number of a purchase the person approved. reveal_ needs cards:reveal — unticked, in no preset, because it releases a number with no approval behind it. A connection without the permission is refused with insufficient_. A hosted client cannot be told to send a custom header, so the gate is a checkbox a human saw rather than a header the agent set for itself.
Every other tool stays available with the opt-in absent, so the safe default is to leave it off and turn it on for the one connection that needs it. Every reveal is logged with the card id, the connection's principal, the client id and the agent key thumbprint — never the PAN. get_ adds the approval id, so the audit line says which approved purchase the number was handed over for. Agents must not log, echo, summarise or store the credentials, and should call either tool only when the number is actually needed to complete a payment.
Sandbox notes
get_reportsinstructions "environment": "sandbox". Check it before anything irreversible.
See Environments for what else differs between Sandbox and production.
Related
- Ask-first cards — the approval flow over REST, its matching rules and its errors.
- Purchase intents — single, order and recurring approvals, prices in the store's currency, the answer and spend windows, duplicate requests, and how the approver answers.
- Coded tool errors and next_step — every tool error code and what to do about it.
- Authentication — API keys, cardholder session tokens and where OAuth fits.
- Webhooks —
PURCHASE_,INTENT_ * VELOCITY_and the decline types these features produce.CONTROL_ WATCHED - Quickstart (V2) — the same operations over REST.
Last updated October 22, 2026