Errors
How the API returns errors and how to handle them.
The API uses conventional HTTP status codes and returns a JSON body with every error. Handle errors in this order:
- Branch on the HTTP status code. It tells you whether the request was malformed, unauthenticated, not permitted, not found, in conflict, rate limited, or failed on our side.
- Then branch on
code. Every error except a validation failure carries a stable, machine-readablecode. - Never branch on
message. It is written for humans and its wording can change.
Both environments return the same shapes.
Error response format
Every error except a validation failure has the same two-field body:
JSON
{
"message": "Card with id 6b1e2c3d-4f5a-4b6c-8d7e-9f0a1b2c3d4e not found",
"code": "CARD_NOT_FOUND"
}message— a human-readable description of what went wrong.code— anUPPER_identifier you can branch on. The full list is in Error codes below.SNAKE_ CASE
Validation errors (400)
When the request fails schema validation — a required field is missing, a value has the wrong type or format, a path or query parameter is malformed — the body is a list of offending fields instead of { message, code }:
JSON
{
"errors": [
{
"msg": "Invalid value",
"param": "external_id",
"location": "body"
}
]
}msg— a human-readable description of the problem with this field.param— the name of the field, without any parent path.location— where the field was read from:body,queryorparams.
Two rules to encode in your client:
- Query parameters are validated the same way:
GET /v2/api-gateway/cards?limit=50returns{"errors":[{"msg":"Too big: expected number to be <50","param":"limit","location":"query"}]}. - Validation bodies never include the rejected value, and never include top-level
messageorcode. Detect a validation error by the presence oferrors.
Some 400 responses are not validation failures but business rules — not enough balance, a card product that does not support the operation, a required id missing on a create. Those use the standard { message, code } body. Endpoint pages list which code values each operation can return.
Authentication errors (401)
Returned when the x-api-key header is missing, unknown, inactive, or not enabled for the API. The body follows the standard shape:
JSON
HTTP/1.1 401 Unauthorized
{"message":"Invalid API Key","code":"UNAUTHORIZED"}message is Authentication required (no credentials sent), Invalid API Key (unknown or inactive key), or User is not allowed to access the API Gateway (the key's account is not enabled for the API). An invalid or expired cardholder token also returns 401 UNAUTHORIZED. See Authentication.
A 403 with code: "FORBIDDEN" means the credentials are valid but this action is not allowed for them — for example a cardholder token used on an endpoint that requires an organization API key, or an account whose API access has been restricted.
Requests that never reach an endpoint
Three kinds of request are rejected before they reach an endpoint and return a different, six-field body: an unknown path (typo in the URL, wrong version prefix), a request body that is not valid JSON, and a body larger than the size limit.
GET https://api.themooncard.com/v2/api-gateway/nopeJSON
HTTP/1.1 404 Not Found
{
"statusCode": 404,
"code": "HTTP_EXCEPTION",
"message": "Cannot GET /v2/api-gateway/nope",
"timestamp": "2026-08-16T19:39:42.900Z",
"path": "/v2/api-gateway/nope",
"requestId": "04127f3f-630b-440e-a085-79a82f8d63b9"
}A malformed JSON body returns 400 with the same six fields ("message": "Unterminated string in JSON at position 10 …"). Write your error parser to accept this shape: read the HTTP status, then treat a body with errors as a validation failure, and otherwise read code and message. The presence of statusCode in the body tells you the request never reached an endpoint.
HTTP status codes
| Status | Meaning | How to handle |
|---|---|---|
200 / 201 | Success. 201 is returned by most create operations. | — |
204 | Success, no body. Returned by webhook and velocity-control deletes. | — |
400 | Validation failed (errors body), or a business rule rejected the request ({ message, code }). | Fix the request. Do not retry unchanged. |
401 | Missing or invalid API key. | Check the x-api-key header and that you are calling the environment the key belongs to. |
403 | Not permitted: an issuance limit or cap reached, a feature not enabled for your organization, a restricted account, the wrong kind of credential for the endpoint, a Sandbox-only endpoint called in production. | Read code; the condition must be resolved before retrying. |
404 | The resource does not exist, or exists but belongs to another organization. Never a malformed UUID (that is 400). | Verify the id. Do not treat 404 as "deleted". |
409 | Conflict with current state, e.g. a duplicate cardholder external_, or a verification that is already complete. | Read code; reconcile before retrying. |
429 | Rate limited, or too many failed cardholder login attempts. | Wait for Retry-After and retry — see Rate limits. |
500 | Unexpected error on our side. | Retry with exponential backoff and jitter; if it persists, contact support with the x-correlation-id. |
503 | Temporarily unavailable: scheduled maintenance, or no usable exchange rate for the currency pair. | Retry with backoff. For MAINTENANCE_, back off for minutes rather than seconds. |
422 and 501 are not used.
Error codes
Codes are grouped by the HTTP status they are returned with. A code always comes with the same status unless noted. Endpoint pages list the codes each operation can return.
400 — request rejected by a business rule
| Code | When |
|---|---|
INSUFFICIENT_ | Not enough the prepaid balance, card balance or account balance for the operation. On card create, the balance must cover the initial amount plus the card-creation fee. |
CARD_ | The card product is not enabled for your organization, or does not support this operation (reload, freeze, an initial load on create). |
USER_ | Card create with card_: physical cards are not enabled for your organization. |
USER_ | V1 card create: your organization has no prepaid balance to pay for the card. On the balance endpoints the same condition is a 404, see below. |
INVALID_ | Card create: card_ differs from the currency of your prepaid balance. |
AMOUNT_ | Card create: the initial amount is below the minimum first load (5 USD in production, 1 USD in Sandbox). |
CARD_ | Move-balance called on a card that is not a reloadable prepaid card. |
CARD_ | The card cannot be deleted while its balance is negative. |
CARD_ | The card cannot be deleted while transactions are pending. |
CARD_ | The card could not be terminated. |
END_ | V1 create card: external_ is blank. |
ACCOUNTS_ | Account transfer between accounts of different organizations. |
CARD_ / CARDHOLDER_ | Velocity control create: no target given, or both cardholder_ and external_ given. |
KYC_ / KYB_ / APPLICANT_ | The cardholder (KYC) or organization (KYB) has no verification session that can accept documents or be submitted. |
EXCHANGE_ | Exchange rate requested for a pair that is not quoted. |
WEBHOOK_ | Webhook create: the URL is not a publicly reachable http(s) endpoint. |
ERROR_ | Webhook create: the URL did not answer the registration probe, or answered with a 5xx. |
NO_ / CODE_ / INVALID_ | Cardholder login: no code requested, code older than 10 minutes, or wrong code. |
INVALID_ / OTP_ / INVALID_ / MAX_ | Card activation with a one-time code (/v1/public/2fa/…): unknown session, expired code, wrong code, or more than three attempts. |
CARD_ | Card activation with a one-time code only: no card matches. Everywhere else CARD_ is a 404. |
401 — not authenticated
| Code | When |
|---|---|
UNAUTHORIZED | Missing, invalid or inactive API key; invalid or expired cardholder token. |
403 — not permitted
| Code | When |
|---|---|
DEV_ | A simulation endpoint was called in production. |
HTTP_ | Generic. Card create: an issuance limit or the lifetime card cap for this product would be exceeded. Some feature-gating 403s also use it. |
404 — not found
The message is usually <Resource> with id <id> not found or <Resource> not found. Resources that belong to another organization are reported as not found.
| Code | When |
|---|---|
CARD_ | Card id unknown or not yours. |
TRANSACTION_ | Transaction id unknown or not on this card. |
CARDHOLDER_ | Cardholder id or external_ / external_ unknown or not yours. |
ORGANIZATION_ / ORGANIZATIONS_ / TOP_ | Organization id unknown or not yours; no organizations on the requested page; caller has no top-level organization. |
ACCOUNT_ / ORIGIN_ / DESTINATION_ | Account id unknown or not yours (transfer names the side that failed). |
VELOCITY_ | Velocity control id unknown, deleted, or not yours. |
WEBHOOK_ | Rotate signing secret: no webhook is registered. |
USER_ / MOON_ / CARD_ | Your organization or the card has no balance record yet, so there is nothing to move or refund. V1 card create returns USER_ as a 400, not a 404. |
HTTP_ | Generic. Examples: Organization not found, KYC not initiated for this cardholder, End customer not found. Branch on the status. |
409 — conflict
| Code | When |
|---|---|
EXTERNAL_ | Cardholder create: a cardholder with this external_ or email already exists in the organization. |
CARDHOLDER_ | KYC link requested for a cardholder whose KYC is already complete. |
ORGANIZATION_ | KYB link requested for an organization whose KYB is already complete. |
429 — too many requests
| Code | When |
|---|---|
HTTP_ | The per-endpoint rate limit was exceeded. Retry-After header is set. See Rate limits. |
TOO_ | Cardholder login: five wrong codes for the current login code. Request a new code. No Retry-After. |
500 — server error
| Code | When |
|---|---|
INTERNAL_ | Unexpected failure. message is Internal server error or a short description such as Failed to create cardholder. |
HTTP_ | Generic; a few operations report unexpected failures with this code. Treat exactly like INTERNAL_. |
503 — temporarily unavailable
| Code | When |
|---|---|
MAINTENANCE_ | Scheduled maintenance. message: Moon is under maintenance. |
EXCHANGE_ | No exchange rate available for the currency pair (simulated transactions in a foreign currency). |
EXCHANGE_ | The quoted rate is outside the accepted band; retry shortly. |
Card declines are not HTTP errors
A declined card transaction is not returned as a 4xx on an API call. Declines are delivered asynchronously as a CARD_ webhook with a customer_ and a decline_ you can branch on. Do not treat an HTTP error as a decline, or a decline as an HTTP error.
Retries and idempotency
Send an idempotency key and any POST becomes safe to retry. With Idempotency-Key set, a repeat of a request that timed out returns the first attempt's result instead of performing the operation again — that is the mechanism this section used to work around.
Without a key, a request that timed out may or may not have landed, so retry according to what the operation does:
| Operation type | Retry after a timeout or 5xx? |
|---|---|
Reads (GET) | Yes, freely. |
| State-setting updates: freeze/unfreeze, activate, assign cardholder, update or delete a velocity control, delete a webhook, update a cardholder or organization | Yes. Repeating them yields the same end state. |
| Register a webhook, rotate the signing secret | Yes, but each successful call issues a new signing secret and invalidates the previous one — always store the secret from the last response you received. |
| Create a cardholder (V2) | Yes: external_ is unique per organization, so a duplicate returns 409 EXTERNAL_ instead of a second cardholder. |
How to check before retrying a create, when it was sent without a key:
- Card issued? List cards for the cardholder —
GET /v1/api-gateway/card?external_orid=… GET /v2/api-gateway/cards?external_— and look for a card created after your request. Both lists are newest first. V1 card create also debits your prepaid balance, so a second attempt that lands charges you twice.id=… - Balance added or moved? Fetch the card (
balance) or its transactions withinclude_; for accounts,loads=true GET /v2/api-gateway/accounts/{id}or its transactions. - Account, organization or velocity control created? List them and match on the name or target you sent.
General rules:
- Retry
429afterRetry-After, and5xxwith exponential backoff and jitter. Cap retries; if a503 MAINTENANCE_persists, wait minutes, not seconds.MODE - Do not retry other
4xxunchanged — the request needs to change first. A changed request needs a new idempotency key. - Webhook deliveries can be retried by Moon; deduplicate on
webhook_and always return HTTPevent_ id 200from your webhook endpoint.
Getting help with an error
Every response to a request that reached an endpoint carries an x-correlation-id header (send your own value and it is echoed back). Log it together with the status and the full body. When you contact our team, quote the correlation id, the endpoint, the timestamp and the response body — that lets us find the request. Responses rejected before an endpoint (401, 403 from authentication, 429, unknown paths) do not carry the header; for the six-field body quote its requestId instead.
Show message to your own users only after mapping it to your own copy — the wording can change; code will not.
Last updated August 17, 2026