> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moove.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Every error returns a non-2xx status and an errors array. Which codes to retry, and which mean the caller has to act.

Every failed request returns a non-2xx status and a body of the same shape.

## The error body

```json theme={null}
{
  "errors": [
    {
      "message": "toAmount has more decimal places than the settlement token supports.",
      "code": "INVALID_PAYMENT_LINK_AMOUNT"
    }
  ]
}
```

<ResponseField name="errors" type="array" required>
  One or more error details.

  <Expandable title="properties">
    <ResponseField name="message" type="string" required>
      Human-readable description. For a schema `422`, it describes the rule that failed, such as `Field required` — it does not name the field.
    </ResponseField>

    <ResponseField name="code" type="string" required>
      Stable machine-readable identifier. Branch on this, not on `message`.
    </ResponseField>
  </Expandable>
</ResponseField>

<Note>
  Always read `code`, never `message`. Message text is written for humans and may be reworded; codes are stable.
</Note>

## Status codes

| Status | Meaning                                       | Retry?                                    |
| ------ | --------------------------------------------- | ----------------------------------------- |
| `401`  | Missing, invalid or expired key               | **No** — fix the key                      |
| `403`  | Key lacks the required scope                  | **No** — issue a key with the right agent |
| `404`  | No payment link with that id                  | **No** — check the id                     |
| `409`  | The account is not set up to receive payments | **No** — its owner has to act first       |
| `422`  | Request body failed validation                | **No** — fix the request                  |
| `429`  | Rate limit exceeded                           | **Yes**, with backoff                     |
| `500`  | Unexpected server error                       | **Yes**, with backoff                     |

<Warning>
  Only `429` and `5xx` are worth retrying. Retrying a `401`, `403`, `409` or `422` will fail identically every time and will count against your rate limit.
</Warning>

## Error codes

`code` is the stable identifier. These are every code the payment-link endpoints return.

| `code`                           | Status | Meaning                                                     |
| -------------------------------- | ------ | ----------------------------------------------------------- |
| `UNAUTHENTICATED`                | `401`  | No `X-API-Key` header on a route that requires one          |
| `INVALID_API_KEY`                | `401`  | Unknown, revoked or deactivated key                         |
| `EXPIRED_API_KEY`                | `401`  | The key is past its expiry — create a new one               |
| `INSUFFICIENT_API_SCOPE`         | `403`  | The key does not hold the scope the endpoint needs          |
| `CANNOT_FIND_PAYMENT_LINK`       | `404`  | No link with that id                                        |
| `PAYMENT_LINK_ACCOUNT_NOT_READY` | `409`  | Owner has no default wallet, or no handle                   |
| `INVALID_PAYMENT_LINK_AMOUNT`    | `422`  | `toAmount` exceeds the settlement token's decimal precision |
| `CANNOT_CREATE_PAYMENT_LINK`     | `500`  | Creation failed                                             |
| `429`                            | `429`  | Rate limited. This one carries the status as its code       |

<Note>
  A malformed request body is also a `422`, raised by request validation before any of the codes above. Its `message` names the offending field and its `code` is not from this list — treat any unrecognised `422` as "fix the request".
</Note>

<Warning>
  `INVALID_API_KEY` deliberately covers unknown, revoked **and** deactivated keys with one identical message. Distinguishing them would tell an attacker whether a guessed key ever existed. `EXPIRED_API_KEY` is separate because expiry is a state the legitimate owner needs to diagnose, and it reveals nothing to anyone who did not already hold the key.
</Warning>

## The ones worth understanding

<AccordionGroup>
  <Accordion title="409 — the account is not set up to receive payments">
    The key is valid and correctly scoped, but its owner cannot receive money yet: no default wallet, or no handle.

    This is not a transient failure and there is nothing your code can do about it. The account owner has to claim a [Moove Handle](/brand/moove-handle) and set a default wallet with a [settlement token](/concepts/settlement-and-auto-routing).

    Surface it to the user rather than retrying.
  </Accordion>

  <Accordion title="422 — validation">
    The request body did not match the schema. `message` names the field.

    Common causes: `toAmount` missing or not greater than zero, `description` over 500 characters, `maxUsage` below 1, `expirationDate` not an ISO 8601 timestamp or not in the future.

    There is also one `422` that schema validation cannot catch: **`INVALID_PAYMENT_LINK_AMOUNT`**. `toAmount` is quantised to the settlement token's decimals, and rejected if that changes the value — so `49.9999999` fails against a 6-decimal USDC wallet while `49.999999` succeeds. Trailing zeros are not significant, so `100.0000000` is fine.

    The limit belongs to the destination wallet's token, not to your request. See [`toAmount`](/api-reference/moove-receive/moove-payment-links#create-a-payment-link).
  </Accordion>

  <Accordion title="429 — rate limited">
    Limits apply per API key **and** per source IP, whichever is reached first. Back off exponentially with jitter.

    [Rate Limits →](/api-reference/rate-limits)
  </Accordion>
</AccordionGroup>

## Handling errors

```python theme={null}
import time, httpx

def create_payment_link(client, payload, attempts=4):
    for attempt in range(attempts):
        r = client.post("/v1/payment-link", json=payload)
        if r.is_success:
            return r.json()
        if r.status_code not in (429, 500, 502, 503, 504):
            # Terminal. Retrying cannot help.
            raise RuntimeError(r.json()["errors"][0]["code"])
        time.sleep(2 ** attempt)
    raise RuntimeError("exhausted retries")
```

## Next

<CardGroup cols={3}>
  <Card title="Rate Limits" icon="gauge" href="/api-reference/rate-limits">
    What triggers a 429.
  </Card>

  <Card title="Authentication" icon="lock" href="/api-reference/authentication">
    401 and 403 in detail.
  </Card>

  <Card title="Moove Payment Links" icon="link" href="/api-reference/moove-receive/moove-payment-links">
    The endpoints.
  </Card>
</CardGroup>
