Browse the docs

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

EnvironmentMCP URLDashboard (sign-in, approvals, funds)
Productionhttps://api.themooncard.com/mcphttps://dashboard.paywithmoon.com

The transport is streamable HTTP, POST only. Authenticate with a bearer token on every request:

Authorization: Bearer YOUR_API_KEY

The 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-Id is issued and none is accepted. Do not cache or replay one.
  • initialize still works and still returns the server identity, instructions and protocol version; it simply carries no session.
  • GET and DELETE on /mcp return 405. 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.

BearerActs forToolset
Partner API keyYour organizationOrganization — everything in Tools
OAuth access token (moon_oat_…)Your organization, narrowedOrganization, limited to the permissions the person ticked — see Connecting with OAuth
Cardholder tokenOne cardholderCardholder — 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 yourselfa partner API key
an application a person connects with a button, or a developer's coding agentOAuth — nobody pastes a credential anywhere
acting for one cardholder, over that cardholder's own cardsa cardholder token

Cardholder connections

A cardholder token is minted through the ordinary cardholder login route; there is no MCP-specific endpoint.

  1. POST /v2/api-gateway/cardholders/auth/login with { email, organization_id } emails the cardholder a one-time code.
  2. Call it again with { email, organization_id, code } to receive { token, expiresAt }.
  3. 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:

ToolScope
get_instructionsCardholder-level instructions, principal type and cardholder id
list_cardsThe cardholder's own cards. Takes page / limit only
get_cardOne of the cardholder's cards
get_card_transactionsTransactions on one of the cardholder's cards
reveal_card_credentialsPAN/CVV, behind the same opt-in header. Refuses on an ask-first card (purchase_intent_required)
freeze_card / unfreeze_cardFreeze and unfreeze the cardholder's own cards
get_cardholder_kyc_statusThis cardholder's verification status. Takes no arguments
create_cardholder_kyc_linkA hosted verification link for this cardholder. No arguments
list_pending_approvalsPurchases waiting on this person's decision. No arguments
decide_purchase_intentRecords 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_not_found, 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.
  • The verification tools take no id. The cardholder is the connection.
  • A cardholder connection cannot obtain the credential of an ask-first card. request_purchase and get_payment_credential are organization-tier — a person does not ask themselves for permission — and reveal_card_credentials refuses on such a card. The purchase has to be requested by the organization's agent and approved by the person.

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_instructions tool that returns the instructions for this connection plus the environment, the connection type, whether card details can be released (pan_reveal_enabled, profile_reveal_enabled) and — on an OAuth connection — which permissions it holds and which it is missing (granted_scopes, missing_scopes). Have your agent call get_instructions 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_instructions 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_instructions 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_purchase needs no list_cards 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

  1. In their client they add Moon as a connector and the client sends them to Moon.
  2. If they are not signed in, they sign in as usual and land back on the consent screen with nothing lost.
  3. 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.
  4. 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.
  5. Approve sends them back to their client, already connected. Deny sends them back with access_denied, so the client learns the answer instead of hanging.
  6. Asking for more later. When a connected application comes back asking for permissions it does not hold yet (after a tool answered insufficient_scope), 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.
  7. 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.

PermissionBy defaultOn the consent screen
cards:readRequested, tickedSee your cards, cardholders, transactions, balances, spending controls and webhooks.
cards:writeRequested, tickedCreate and manage cards, cardholders, spending controls, purchase approvals and webhooks.
profile:readRequested, tickedSee which personal details are saved for checkout, masked (for example ***.***.***-01). Never the full values.
profile:writeRequested, tickedAdd, change and delete the personal details saved for checkout. Anything you enter through the application passes through it.
cards:payRequested, tickedReceive 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:revealRequested, tickedReceive 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:approveRequested, untickedRecord 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:revealNot requested, untickedReveal 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_supported. 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_purchase and wait_for_approval 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

PresetTicks
Shop with my approval (recommended, pre-selected)cards:read, cards:write, profile:read, profile:write, cards:pay, profile:reveal
Look onlycards: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_payment_credential and, when the approval names them, the personal details with get_checkout_details. 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_instructions reports it as granted_scopes.

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_payment_credential 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_card_credentials 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_checkout_details: 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_scope 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_access 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_instructions reports the same thing up front: granted_scopes, missing_scopes and a scope_next_step 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_payment_credential — 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:

WhereHow
Moon appSettings → Connected apps, Revoke
MCPlist_connected_apps, then revoke_connected_app({ grant_id }) — both behind cards:write
ProtocolPOST /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

DocumentURL
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_metadata 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:

EndpointAdvertised 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_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, client_uri and logo_uri.

It always issues a client_id and a client_secret — 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_endpoint_auth_methods_supported is therefore ["none", "client_secret_post"].

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, localhost or [::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 S256 only. plain is neither accepted nor advertised, and code_challenge_methods_supported is published so a conformant client can check before it starts.
  • state is opaque and echoed back untouched.
  • iss is 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_id or an unacceptable redirect_uri produces an inline error page. Moon never redirects to a URI it has not validated.

Tokens

CredentialLifetime
Authorization code60 seconds, single use
Access token1 hour
Refresh token30 days of idleness, rotated on every use
Refresh token, absolute90 days from the consent
Consent request10 minutes for the person to answer

Access tokens are opaque and start moon_oat_; refresh tokens start moon_ort_. 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-Authenticate

That header is what turns a 401 into a Connect button: the client reads resource_metadata, 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_token", 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_scope 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_description": "..." } — on the protocol endpoints only. Tool calls keep the tool-error contract.

errorStatusWhen
invalid_request400Malformed, wrong content type, or a required parameter is missing
invalid_client401Unknown client, or client authentication failed
invalid_grant400Every dead credential: unknown, consumed or expired code, PKCE mismatch, wrong redirect_uri, revoked or already-spent refresh token. One code for all of them — distinguishing them would be an oracle
invalid_scope400A requested permission is not one Moon supports, or not one this client may be granted
invalid_target400resource names something this server does not serve
unsupported_grant_type400Not authorization_code or refresh_token
unsupported_response_type400Not code
access_denied—On the redirect, when the person declined
invalid_redirect_uri400Registration: none of the redirect URIs is acceptable
invalid_client_metadata400Registration: the body is too large or is not an object

/oauth/token takes application/x-www-form-urlencoded only; a JSON body is invalid_request. /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_scope), 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_checkout_profile, update_checkout_profile 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_step rather than a protocol error. See Errors below and Coded tool errors and next_step for every code.

Cards

ToolWhat it does
create_cardIssues a virtual card funded from the prepaid balance
get_cardMasked card details by id
list_cardsPaginated card list
freeze_cardBlocks further authorizations. Reversible
unfreeze_cardUndoes a freeze
terminate_cardPermanently terminates a card
reveal_card_credentialsRaw PAN/CVV. Requires the opt-in — see Revealing card credentials. Refuses on an ask-first card
get_card_transactionsPaginated spend on a card; include_loads adds top-ups
list_card_declinesThe recent declines on a card, with the descriptor the network really sent
add_card_balanceTops up an existing card from the prepaid balance. Takes idempotency_key
list_card_productsThe card products you may issue from

Every card the tools return is masked: pan and cvv are stripped and you get id, last_four, display_expiration, balance, status and the other non-sensitive fields, plus single_use, merchant_lock and ask_first.

create_card

ParameterTypeNotes
amountnumber, requiredCard balance, funded from the prepaid balance
currencycurrency codeDefaults to USD
idempotency_keystring, 1–255Your own id for this card. Strongly recommended — see Idempotency
cardholder_idUUIDAn existing Moon cardholder. Mutually exclusive with end_customer_id
end_customer_idstringYour own id for the cardholder; resolved, and created on first sight
namestringA label for the card
single_usebooleanOne approved authorization, then declines. See below
merchant_lock{ name?, mcc? }Locks the card to one merchant. Eligible products only
ask_firstbooleanNothing is charged until the person who holds the card approves it
card_product_idUUIDProduct to issue from. Must be enabled for you
metadataobject of string→stringStored verbatim and returned on reads. See Metadata

Passing both cardholder_id and end_customer_id returns cardholder_id_and_end_customer_id_exclusive. 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_card({ single_use: true }) issues a card that accepts exactly one authorization. The first purchase is approved; every later authorization is declined with AUTHORIZATION_COUNT_LIMIT_REACHED. 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_card 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_window: TOTAL, limit_amount: 1, mode: STRICT — and that control is what the authorization engine reads. So:

  • get_card and list_cards report single_use: true for as long as that control is on the card.
  • The control appears in list_velocity_controls and get_velocity_control like any other, flagged fixed: true, and get_velocity_control_status reports used out of limit in AUTHORIZATIONS.
  • update_velocity_control and delete_velocity_control refuse it with control_is_fixed. An editable single-use limit is a default, not a limit.
  • 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_card({ merchant_lock: { name?, mcc? } }) issues a card that can only be used at one merchant. An authorization anywhere else is declined with MERCHANT_NOT_ALLOWED.

ToolWhat it does
update_card_merchant_lockRe-points an existing lock at a different merchant
remove_card_merchant_lockUnlocks 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.

  • name is matched case-insensitively as a substring of the acquirer's merchant descriptor, after trimming. Acquirers pad and prefix that descriptor unpredictably, so ACME matches PAY *ACME SAN FRANCISCO and ACME *ORDER alike. Send the shortest string that identifies the merchant, not the padded form you saw on one transaction.
  • mcc is an exact four-digit merchant category code. It is never matched as a substring: 573 does not match 5734.
  • 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_card_products reports supports_merchant_lock on every product you may issue from; pick one where it is true. A merchant_lock on a product that does not support one returns merchant_lock_unsupported, whose details.merchant_lock_card_product_ids 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_window: TOTAL, limit_amount: 0, mode: STRICT, only_merchants: [ your entry ]. limit_amount: 0 is the "no cap" convention here: the lock restricts where the card works, never how much it may spend. So:

  • get_card and list_cards report merchant_lock (the entry, or null) for as long as the control is on the card.
  • update_velocity_control and delete_velocity_control refuse it with control_is_fixed. Use update_card_merchant_lock and remove_card_merchant_lock instead.
  • update_card_merchant_lock 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 answers merchant_lock_not_found — set the lock when you issue the card.
  • remove_card_merchant_lock is safe on an unlocked card: it succeeds with 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.

ToolWhat it does
enable_ask_firstPuts an existing card in ask-first mode
disable_ask_firstTakes it back out
request_purchaseAsks the person to approve a purchase. Takes idempotency_key
wait_for_approvalWaits (1–45 s) until the person answers a request (cards:write)
get_payment_credentialThe card credential for one approved purchase, plus what that approval covers (cards:pay on OAuth)
get_checkout_detailsThe personal details the person approved for one purchase (profile:reveal on OAuth)
wait_for_chargeWaits (1–45 s) for the charge, or the decline, after paying (cards:read)
get_purchase_intentOne approval: status, what was asked, how much of it is left
list_purchase_intentsPaginated list; filter by card_id, status or recurring
cancel_purchase_intentWithdraws a request nobody has answered, ends an open order, or stops a standing approval
list_card_declinesWhy 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:

  1. request_purchase({ card_id, merchant, merchant_domain, amount, reason, kind }) before you pay, on a card that asks first (the account line names the newest one). When the store prices in another currency, send merchant_amount + merchant_currency instead of amount.
  2. Moon emails the person a one-time link and answers { status: 'approval_pending', deduplicated, intent_id, expires_at, answer_by, next_step }. answer_by 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. deduplicated: true means 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.
  3. Tell the person where to approve — the emailed link, or Agents → Approvals (/agents/approvals) on the dashboard — then call wait_for_approval({ intent_id }). At its timeout it returns the request still awaiting approval with timed_out: true; call it again. (Over REST, or with a webhook receiver, the PURCHASE_INTENT_APPROVED webhook says the same.) Never attempt the payment first, and never retry it in a loop. The answer is a person's.
  4. Pay, in the browser you are driving: call get_payment_credential({ intent_id }) — plus get_checkout_details({ intent_id }) 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.
  5. wait_for_charge({ intent_id }) once, then tell the person what was charged — "charged amount at *merchant*" — or why it was declined.

reveal_card_credentials (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_id and timeout_seconds (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 leaves AWAITING_APPROVAL — APPROVED, DENIED, EXPIRED, CANCELLED or CONSUMED — with exactly what get_purchase_intent returns plus timed_out: false. At the timeout it returns the same request, still AWAITING_APPROVAL, with timed_out: true and a next_step to call it again: a result, never a tool error. Another account's request, or none, answers 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 } } — amount in the card's currency, merchant_amount in the currency the merchant charged in. When the card has a decline since the approval that belongs to this approval, it answers { charged: false, declined: true, decline: { reason_code, merchant_descriptor, amount, currency, occurred_at } }. A decline another live approval on the card could equally own is never named: the wait times out with a next_step to read list_card_declines. A request that was DENIED, EXPIRED or CANCELLED without being spent answers at once with charged: false and a next_step to ask again. At the timeout: { charged: false, timed_out: true }. For an order or a standing approval this is the first charge.

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_amount is inert on it.

Both helpers are idempotent and say which they were: enable_ask_first on a card that already asks first answers enabled: false, and disable_ask_first 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

kindWhat the person is agreeing toUse it for
single (default)One merchant, one amount inside a symmetric tolerance band, exactly one authorization, spendable for 15 minutes once approvedPay-now digital goods: a subscription renewal, a top-up, an API bill — anything charged at checkout for exactly the number that was quoted
orderA budget at one merchant, any number of charges, for valid_for_days days from the moment they approvePhysical goods, marketplaces, food and ride apps, groceries, travel — anything that ships, tips, splits across shipments or saves the card on file
recurring: trueA standing permission at one merchant, bounded by monthly_limit per UTC calendar month, with no expirySubscriptions 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

ParameterTypeNotes
card_idUUID, requiredMust be an ask-first card, or card_not_ask_first
merchant{ name?, mcc? }, requiredAt least one. name is one name or an array of up to 5; any one matching is enough
amountnumberIn the card's own currency, major units. Either this or merchant_amount + merchant_currency, never both
merchant_amountnumberThe price exactly as the store shows it, in merchant_currency, major units. Moon converts it; do not convert it yourself. See Prices in the store's currency
merchant_currencyISO 4217, uppercaseOne of USD, MXN, EUR, CAD, GBP, JPY, AUD, BRL. Required with merchant_amount
reasonstring, 1–200, requiredThe sentence the person reads in the email. Write it for them
kindsingle or orderDefault single. Cannot be combined with recurring
merchant_domainbare hostnameThe site the purchase is made on. Advisory — see below. A URL is rejected, not tidied up
currencycurrency codeDefaults 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_amount
answer_within_minutesinteger 5–1440How long the person has to answer. Default 60. Sets expires_at on the request; it does not change how long an approval may be spent
valid_for_daysinteger 1–90An 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
recurringboolean, default falseAsk for a standing approval instead of a one-off
monthly_limitnumberRequired with recurring, rejected without it. How much this merchant may charge per UTC calendar month, in the card's currency; at least amount
first_charge_within_daysinteger 1–60How long a recurring approval waits for its FIRST charge. Default 45. Rejected without recurring
idempotency_keystring, 1–255A retry with the same key never emails the person twice
profile_fieldsarray of field namesThe saved checkout details this purchase needs — any of name, email, phone, national_id, date_of_birth, billing_address, shipping_address. The person sees them on the approval and approves them with it. Needs merchant_domain; not on a recurring approval. Ask for the minimum the checkout needs
shipping_address_idUUIDWhich saved shipping address, with shipping_address in profile_fields. Default: the profile's default address; with none saved, the billing address

A price given as merchant_amount is converted into the card's currency at a market rate when you ask and stored as amount; the intent also keeps merchant_amount, merchant_currency and request_fx_rate, and the person is shown both figures. When Moon has no rate for that currency at the moment, the call fails with merchant_currency_unsupported and nothing is sent: ask again with amount.

Asking twice. A request_purchase 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_key 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_names. That table is best-effort: treat a miss as ordinary and use list_card_declines.

The amount rule depends on the kind:

  • A single approval accepts a charge inside the tolerance band, which is the wider of percent and absolute and 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 order accepts any charge while spent + this charge <= max_total, with no lower bound per charge.
  • A recurring approval accepts its first charge up to monthly_limit, and every later charge while the UTC calendar month's total stays inside 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: true on an API-key connection (the same header reveal_card_credentials needs); the cards:pay permission on an OAuth one, which the default Shop with my approval preset carries. It is a different permission from cards:reveal because 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_amount are the band the authorization gate will actually accept, and there are three answers because three different rules decide the charge. On a single approval it is the symmetric band around amount, so a charge below it is outside just as one above it is. On an order, min_amount is null and max_amount is remaining. On a recurring approval, min_amount is null and max_amount is the monthly limit — the whole of it before the first charge, and what is left of this UTC month afterwards.
  • handling is 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_fields), a connection that opted in reads them with get_checkout_details. 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.
  • 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_DS_OTP 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.
  • reveals_remaining 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.
  • When it refuses. purchase_intent_not_usable with details.status: AWAITING_APPROVAL means wait (wait_for_approval), and DENIED, EXPIRED, CANCELLED or a spent one-off CONSUMED all mean ask again with a fresh request_purchase. A standing recurring approval reads CONSUMED and stays usable while it is in force, and an open order goes on working for its whole window. too_many_credential_reveals when the cap is used up. An approval that is not yours, or does not exist, answers purchase_intent_not_found — the same answer for both.
  • Audited. Every call writes the same warning line reveal_card_credentials does — card, principal, client id, agent key thumbprint — plus the approval id, and never any part of the credential.

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_payment_credential, 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:reveal permission, part of Shop with my approval. API key: the header x-moon-allow-profile: true; without it the answer is profile_reveal_not_enabled 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 is checkout_details_not_available — the person enters them at checkout.
  • 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_approved) before anything is counted. Ask for the details with profile_fields on request_purchase.
  • Only a usable approval, on your own account. purchase_intent_not_found for another account's request (the same answer as an id that does not exist); purchase_intent_not_usable with details.status for 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.
  • handling is part of the contract: use the details only for this purchase at approved_for.merchant_domain, 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.
  • 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_payment_credential names it as the one place the agent may use the credential.

See below.

list_card_declines

list_card_declines({ card_id, limit? }) 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_MISMATCH: read merchant_descriptor, compare it with the name the approval carried, and call request_purchase 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_within_minutes to answer (60 by default, 5–1440); until then expires_at is that deadline. The moment they approve, expires_at 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_days for an order; first_charge_within_days for a recurring approval. get_purchase_intent and list_purchase_intents then also report it as spendable_until (null before approval and on a recurring approval). See The answer window.

There is no sweep job: an approval past expires_at reads as EXPIRED the first time it is read, and the PURCHASE_INTENT_EXPIRED 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_REQUIRED. Treat expires_at as the deadline, not the event's arrival.

  • An order closes only when its budget is gone (spent + this charge >= max_total). There is no "that was the final capture" signal in an authorization stream, so until the budget runs out the order stays APPROVED and ends when its window does. A closed order reads CONSUMED and authorizes nothing further. At the end of its window, an order that was charged moves to CONSUMED and emits no PURCHASE_INTENT_EXPIRED — it is a purchase that happened, not an approval nobody answered.
  • A charge that would take an order past max_total, or that arrives after the window, is declined INTENT_MISMATCH and nothing is recorded.
  • 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 CONSUMED while 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_mcc); a later authorization carrying a different category is refused.
  • A card holds at most 20 live approvals and at most 20 standing approvals at once (too_many_pending_intents, too_many_recurring_approvals). 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.

cancel_purchase_intent 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_intent_not_pending. 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_INTENT_CANCELLED carrying cancelled_by.

Declines

decline_typeMeaningWhat to do
INTENT_REQUIREDNo approved, unspent, unexpired approval for this cardAsk. request_purchase, then wait
INTENT_MISMATCHAn approval exists, but for another merchant, or outside its band or budgetAsk again for the real purchase. Read list_card_declines for the descriptor the network sent

Both arrive as an ordinary CARD_DECLINE 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_pending_approvals and decide_purchase_intent. 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.

ConnectionWhat it can answer
Cardholder tokenPurchases on that cardholder's own cards
OAuth connection holding cards:writePurchases whose approver is the account itself: cards with no cardholder (approver_type: account), 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_intent_not_found here

Both tools carry this sentence in their description and in the instructions:

A decision recorded this way reads approved_via: mcp, 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_purchase_intent works once: an intent that has already been answered, spent, cancelled or has expired answers purchase_intent_not_pending.

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_at. The decision reads 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.

  1. The agent types it. request_purchase (with merchant_domain) → wait_for_approval → get_payment_credential (and get_checkout_details when the approval named details) → enter them into that site's checkout form → wait_for_charge. 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 needs 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.

ToolWhat it does
register_agent_keyRegisters an Ed25519 public key; returns the key_id
list_agent_keysEvery key, revoked ones included
revoke_agent_keyStops 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_id; 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

RuleDetail
tagMust be web-bot-auth. Exactly one signature may carry it
alged25519, or omitted
keyidA key you registered and have not revoked
Covered components@authority and content-digest, plus either signature-agent or both @method and @path
content-digestRequired on a signed request, verified against the body bytes
created / expiresexpires in the future, created at most 60 s ahead of now, at most 300 s apart
nonceRequired, 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_agent_signature" and a next_step. 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-digest means 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-intents honestly reports agent_key_id: null.

Where the identity shows up

  • get_purchase_intent, list_purchase_intents, list_pending_approvals and the REST reads report agent: { key_id, label }, or null when the request was unsigned.
  • The approval email and the approval page say "Requested by label (key …last 8 of the thumbprint)".
  • PURCHASE_INTENT_* webhooks carry agent_key_id (the thumbprint) and agent_label. See Webhooks.
  • create_card and the credential tools name the key on their audit lines.

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

ToolWhat it does
create_velocity_controlCaps spend or usage on one card, cardholder or external customer id
list_velocity_controlsPaginated control list, filterable by card and searchable
get_velocity_controlOne control: limit, type, window, mode, allowlist, target
update_velocity_controlChanges the limit, window, mode, allowlist, enabled flag or metadata
delete_velocity_controlRemoves a control
get_velocity_control_statusConsumed and remaining, in the control's unit, by id or card_id
get_cardholder_spend_statusThe same status for a cardholder, by cardholder_id or external_id

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:

ArgumentValuesMeaning
typeSPEND (default), COUNTSPEND caps the money spent in the window; COUNT caps how many authorizations are approved in it
velocity_windowMONTH (default), DAY, WEEK, TOTALDAY is the UTC calendar day, WEEK the ISO week from Monday, TOTAL the lifetime of the card or cardholder
modeSTRICT (default), WATCHSTRICT declines a breaching authorization. WATCH never declines — it sends a VELOCITY_CONTROL_WATCHED webhook instead
only_merchants[{ name?, mcc? }], at most 50Allowlist. An authorization whose merchant matches no entry breaches the control

Every window is UTC. Omitting type and velocity_window still produces the SPEND / MONTH control they always produced, so an agent written before these arguments existed keeps working unchanged.

limit_amount 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_error.

A merchant matches an only_merchants 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_amount: 0 means "nothing may be spent" as it always has — unless the control also carries an only_merchants 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_velocity_control and delete_velocity_control refuse it with control_is_fixed. fixed is read-only, and it is a reserved key in control metadata: sending it on a control of your own returns velocity_control_reserved_metadata_key.

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_CONTROL_WATCHED webhook is sent instead, carrying limit, observed (which includes that transaction), the merchant and the transaction id. Collect those for a while, then update_velocity_control the mode to STRICT. See Webhooks.

Status reads

get_velocity_control_status and get_cardholder_spend_status 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_start / window_end 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_velocity_control takes exactly one of card_id, cardholder_id or external_id; two targets or none returns validation_error naming the fields.

Cardholders and verification

ToolWhat it does
create_cardholderCreates the person a card is issued to
get_cardholderCardholder by id
list_cardholdersPaginated cardholder list, with partial-match search
get_cardholder_cardsMasked cards issued to a cardholder
create_cardholder_kyc_linkA hosted verification URL to share with that person
get_cardholder_kyc_statusIdentity-verification status of a cardholder

Onboarding an end customer:

  1. create_cardholder with your own external_id (and email if you have one). Keep the returned id.
  2. create_cardholder_kyc_link with that id, and share the returned kyc_url with the person — it is a hosted flow, and Moon never asks you to collect the documents. kyc_url is null when the cardholder is already in a status that cannot be restarted (approved, in review, declined); read status instead.
  3. Poll get_cardholder_kyc_status until it reports approved. A kyc_required error from any card call means this step is not finished.
  4. create_card with that cardholder_id.

Webhooks

ToolWhat it does
get_webhookThe registered endpoint (never the secret)
create_webhookRegisters the URL Moon posts events to
delete_webhookStops deliveries
rotate_webhook_secretIssues a new signing secret

There is one endpoint per organization and it receives every event type, so create_webhook replaces any previously registered URL. The signing secret is returned only by create_webhook and rotate_webhook_secret, in the response that creates it; there is no read endpoint for it. See Webhooks.

Connected applications

ToolWhat it does
list_connected_appsEvery application connected through the Moon consent screen, newest first: id, name, permissions, who approved it, when, last used
revoke_connected_appDisconnects one, by grant_id. 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_only without doing anything.

ToolWhat it does
simulate_card_transactionMakes a card spend — purchase, refund or withdrawal — with no merchant

Getting unblocked

Every organization brief has a GETTING UNBLOCKED section (get_instructions({ section: "getting-unblocked" })), one line per situation, with the code a tool returns:

SituationWhat happens next
A tool answers insufficient_scopeAsk the client to reconnect with the permission in missing_scopes (a one-step re-approval); if it cannot, the person reconnects at /settings/connected-apps
No card asks firstThe person picks one at /agents/connect. On an API key: create_card with ask_first: true
The approval is still pendingwait_for_approval again
agent_cards_must_ask_firstUse request_purchase on a card that asks first; never turn ask-first off
purchase_intent_required, or a decline INTENT_MISMATCHAsk again with the right amount, currency and merchant
The shop asks for a sign-in or a CAPTCHAThe person signs in (or solves it) on that site in the browser the agent is driving; the agent waits, then goes on there
approval_email_missingDo what next_step 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 toPage
Approve a purchaseThe 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_card, add_card_balance and request_purchase take an idempotency_key (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_mismatch. Use a new key for a different card.
  • The same key sent while the first call is still running returns idempotency_in_progress. That one is 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_step 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.

Without a key nothing changes and a blind retry funds a second card — so if you must omit it, do not retry a create_card whose outcome you do not know. Call list_cards 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:

BudgetCountsLimit per minute
GeneralEvery JSON-RPC message (a batch of twenty counts twenty)600
RevealCalls to reveal_card_credentials, get_payment_credential or get_checkout_details30

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_step, retryable):

Statusdata.codeWhen
503maintenance_modeMoon is in maintenance. Retryable
413request_too_largeThe request body is over 1,000,000 bytes
403 / 409account_restricted, account_not_provisioned, api_access_disabledThe account behind the connection may not use the API right now
400batch_too_largeA JSON-RPC batch of more than 20 messages
429rate_limitedA budget above is used up. Comes with Retry-After. Retryable
503rate_limiter_unavailableThe shared counter could not be reached, for a reveal or fill call only. Comes with Retry-After. Retryable
401invalid_agent_signatureA 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:

  • code is stable and lower-cased. Branch on it, not on message.
  • next_step names the tool to call next. Follow it.
  • retryable is true only for genuinely transient failures. Retry those with the same idempotency_key; never retry a false.
  • details never contains pan or cvv, 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_step.

CodeMeaningWhat to do
validation_errorAn argument the handler parsed was invalidFix the fields in details.errors
card_not_foundNo such card for this connectionCheck the id, or call list_cards
kyc_requiredThe cardholder is not verifiedSend them the verification link, then poll the status tool
reveal_not_enabledAn API-key connection has not opted in to card numbersSend x-moon-allow-pan: true. On OAuth the permissions cards:pay and cards:reveal take its place
insufficient_scopeThe connection was not granted the permission this tool needs; details.missing_scopes names itAsk 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_reveal_not_enabledAn API-key connection has not opted in to receiving approved detailsSend x-moon-allow-profile: true. On OAuth, profile:reveal takes its place
checkout_details_not_availableThe account does not release checkout details to agentsThe person enters them at checkout
profile_fields_not_approvedThe approval did not name the details asked forrequest_purchase naming them in profile_fields (details.not_approved lists them)
checkout_profile_field_missingA detail named in profile_fields is not saveddetails.missing names it; the person adds it at /settings/checkout-details, then ask again
control_is_fixedThe control belongs to a card featureUse the card-level tool, or terminate the card
merchant_lock_unsupportedThe card product does not support locksPick a product from details.merchant_lock_card_product_ids
card_not_ask_firstrequest_purchase on a card that does not ask firstCall enable_ask_first first
merchant_currency_unsupportedMoon has no rate right now to convert merchant_currency into the card's currencyAsk again with amount in the card's currency (details.card_currency). Not retryable as-is
approval_email_missingNobody can be askedRead details.approver_type: cardholder means that person needs an email, account means the account that owns the card does
purchase_intent_requiredreveal_card_credentials on an ask-first cardrequest_purchase, wait, then get_payment_credential
purchase_intent_not_usableThe approval cannot be paid with right nowRead details.status: AWAITING_APPROVAL means wait, anything else means ask again
too_many_credential_revealsThis approval has handed the credential over as often as it mayAsk for a new approval; the count never resets
too_many_pending_intentsThe card already holds the most live approvals it mayCancel one with cancel_purchase_intent, or wait for the short-lived ones to expire
too_many_recurring_approvalsThe card already holds the most standing approvals it mayCancel one; waiting will not clear it
purchase_intent_not_pendingCancel on something already answered, spent or finishedRead it with get_purchase_intent; ask again if the purchase still has to happen
no_matching_purchase_intentNothing is approved for the site the person is onrequest_purchase naming that site in merchant_domain, then wait
merchant_domain_mismatchThe named approval covers another sitedetails.expected names it; ask again for the site the person is actually on
invalid_fill_originA fill named its page with a URL, or with an origin that is not httpsSend scheme, host and optional port only
agent_cards_must_ask_firstThe account owner requires every card a connected agent creates to ask firstCall create_card without ask_first — the card is issued asking first — then request_purchase for each purchase. Do not ask the person to turn the setting off
idempotency_in_progressThe first call with this key is still runningRetryable; wait a few seconds, retry with the same key
idempotency_mismatchThe key is bound to different argumentsUse a new idempotency_key, or repeat the originals
internal_errorMoon failedRetryable; 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 HTTP 405.
  • 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 any isError result 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_card_credentials for an ordinary card, and get_payment_credential for one approved purchase on an ask-first card.

On an API-key or cardholder connection, add a header:

x-moon-allow-pan: true

JSON

{
  "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_not_enabled explaining how to opt in.

On an OAuth connection the header is not consulted at all: two permissions replace it. get_payment_credential 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_card_credentials 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_scope. 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_payment_credential 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_instructions reports "environment": "sandbox". Check it before anything irreversible.

See Environments for what else differs between Sandbox and production.

  • 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_CONTROL_WATCHED and the decline types these features produce.
  • Quickstart (V2) — the same operations over REST.

Last updated October 22, 2026