# Errors

How the API reports a problem, what to do for each HTTP status, and a ready-made handler you can copy. Read this before you send money: the most important question after an error is **did the request take effect or not?**

## What to do for each status

Find the HTTP status in the first column. "Took effect" says whether anything was created, changed or debited.

| Status | Took effect? | Retry automatically? | What to do |
|---|---|---|---|
| `200` | Yes | — | Use the response. Every endpoint answers `200` on success, including the ones that create something. For payouts, refunds and conversions, the final result can still arrive later (see the product guide). |
| `400` | No, except the cases below | No | Read `errors`, fix the request, then send it again. |
| `401` | No | No | The API key is missing, mistyped, revoked or from the other environment, or the request sent both `mp-api-key` and `Authorization`, or sent the key as `Authorization: Bearer` instead of in `mp-api-key`. With an IP allow-list, every service except the user service also answers `401` to a key used from an unlisted address: if a key that works from one server gets 401 from another, check that server's address is on your allow-list. Fix the key, then resend the **same** request (same `uniqueRequestId`). See [When a request returns 401](../01-get-started/06-authentication.md#when-a-request-returns-401). |
| `402` | No (one rare exception) | No | [Lock a rate](api:lockFxRate) only, production only. "Insufficient balance": fund the account, then lock again. "The settlement has failed…": check the source balance before you retry. See [FX conversions](../04-products/09-fx-conversions.md). |
| `403` | No | No | Your API user lacks the permission (payment and FX endpoints), your request came from an IP address that isn't on your allow-list (user service only; other services answer `401`), or the action isn't allowed for an API user (managing keys and credentials). See [the FAQ](../06-reference/03-faq-and-troubleshooting.md#errors-on-any-request). |
| `404` | No | No | The ID doesn't exist, belongs to someone else, or is from the other environment. On the client service (customers, accounts, beneficiaries, receivables, linking, client configurations), `404` "Resource not found" means your API user **lacks the permission**. |
| `405` | No | No | The path doesn't exist (`{"message": "API method not found"}`). An unknown path on the payment service answers `404` with an HTML body instead. Check the URL and method against the API reference. |
| `409` | No new object: an earlier one exists | No | The request conflicts with something that already exists: a refund already requested, a quote already locked, a reused FX `reference`, a beneficiary already saved, a customer approval request already open. Look up the existing object. (One exception: [Submit a customer for review](api:submitSubClientForApproval) can still reset the customer to `processing`.) |
| `413` | No | No | The body is larger than 1 MB. Send less data per request. |
| `417` | **Yes: money is still debited** | No | [Lock a rate](api:lockFxRate) only: "Reversal failed". Contact support with the quote ID. |
| `422` | No | No | The request can't be served as asked, for example a list that would skip too many rows. Narrow the filters. |
| `500`, `502`, `503`, `504` | **Unknown** | Only after a lookup, with the same key | Something failed on our side, or the gateway gave up after 60 seconds. Follow [Unknown outcomes](#unknown-outcomes). |
| No response (timeout, dropped connection) | **Unknown** | Only after a lookup, with the same key | Same as `500`. |

The `400` cases where something **did** happen:

| Message | What it means | What to do |
|---|---|---|
| "Unique request ID already exist" (transfers, remittances, refunds) | An earlier request with this `uniqueRequestId` exists, possibly one you thought had failed. | Look it up with [Find transfers](api:listTransfers) `?uniqueRequestId=…`. See [Idempotency](03-idempotency.md). |
| "uniqueRequestId has already been used" (accounts), "A receivable with this uniqueRequestId already exists" | Same, for accounts and receivables. | Look it up with [List accounts](api:listAccounts) or [List receivables](api:listReceivables). |
| "Invalid transaction (general response for invalid transactions)" on [Commit a bulk transfer](api:commitBulkTransfer) | The preview is unknown, expired or **already committed**. | Check [Find transfers](api:listTransfers) `?batchId=…` before you preview again. |
| `{"message": "account failed", …}` on [Create an account](api:createAccount) | The account was recorded as `failed`; it keeps its `uniqueRequestId`. | Open a new account with a new `uniqueRequestId`. |

:::info[An idempotency key protects your retries]
An **idempotency key** is a unique ID you send with a money-moving request so that, if you send the request again, Multigate recognises it and doesn't act twice. Multigate's is the `uniqueRequestId` field. See [Idempotency](03-idempotency.md).
:::

## Error body shapes

Errors come in four JSON shapes, depending on where the request was stopped. The [handler below](#a-complete-error-handler) understands all of them.

<Tabs>
<Tab title="Validation error">

Returned with `400` when the request doesn't match the endpoint's schema. Every failing field is listed, with its path in `fieldName`. The top-level `message` is always the fixed text `failed_validation`, so read `errors`.

```json
{
  "statusCode": 400,
  "error": "Bad request",
  "message": "failed_validation",
  "errors": [
    { "message": "amount is required", "fieldName": "amount" },
    { "message": "sourceCurrencyIso3Code length must be 3 characters long", "fieldName": "sourceCurrencyIso3Code" }
  ]
}
```

</Tab>
<Tab title="Error list">

Returned when the API key is missing or invalid (`401`), when a payment or FX endpoint refuses a permission (`403`), when the body isn't valid JSON (`400`) or is too large (`413`), and for server errors (`5xx`). The `401` message is "Authentication failed" or "Unauthorized", depending on the service.

```json
{
  "errors": [
    { "message": "Authentication failed", "context": {} }
  ]
}
```

</Tab>
<Tab title="Business rule error">

Returned when a well-formed request can't be carried out: a business rule failed, the object wasn't found, it conflicts with an existing one. The body is an **array** with one element. A three-digit `code` is included on some errors only.

```json
[
  {
    "errors": {
      "message": "Insufficient Balance"
    }
  }
]
```

</Tab>
<Tab title="Operation failed">

Returned by a few endpoints when the request was processed but failed, for example when every partner bank refuses a new account. `data` holds the object in its failed state.

```json
{
  "message": "account failed",
  "data": {
    "account": { "accountId": "2c3d4e5f-…", "uniqueRequestId": "acct_20261010_0005", "status": "failed" }
  }
}
```

</Tab>
</Tabs>

A `502` or `504` from the gateway, and an unknown path on the payment service, can return an **HTML** page instead of JSON.

:::info[Unknown fields are dropped, not rejected]
A field the endpoint doesn't define is removed before processing rather than rejected. A typo in an optional field name therefore causes no error, but the value isn't used either. Send only the documented fields, and compare your payloads with the API reference.
:::

:::note[Angle brackets are escaped]
In text fields, `<` and `>` are stored as `&lt;` and `&gt;`. For example a narration `Invoice <42>` is saved and returned as `Invoice &lt;42&gt;`. Avoid them in names, narrations and references.
:::

## A complete error handler

Wrap every API call in one function. It returns:

- `outcome`: what to do next. One of `success`, `refused` (a `4xx` other than `401`: see the tables above), `check_api_key` (`401`) or `unknown` (a `5xx`, a timeout or a dropped connection).
- `errors`: a list of `{ message, field, code }`, whatever shape the body had.
- `status` and `body`: the raw HTTP status (`null` when there was no response) and the parsed body (`null` when it wasn't JSON).

<Tabs>
<Tab title="Node.js">

Needs Node.js 18 or later (built-in `fetch`).

```javascript
const TIMEOUT_MS = 65_000; // a little above the gateway's 60-second limit

export const Outcome = Object.freeze({
  SUCCESS: 'success', // 2xx: use the body
  REFUSED: 'refused', // 4xx except 401: not carried out (check the exceptions in the docs)
  CHECK_API_KEY: 'check_api_key', // 401: the API key was refused; nothing happened, don't retry until it's fixed
  UNKNOWN: 'unknown', // 5xx, timeout or no response: look it up before retrying
});

// The API key comes from your secrets manager. Never log it.
export async function callMultigate(url, { method = 'GET', apiKey = process.env.MG_API_KEY, body } = {}) {
  let status = null;
  let text = '';
  try {
    const response = await fetch(url, {
      method,
      headers: {
        'mp-api-key': apiKey,
        'Content-Type': 'application/json',
        'Accept-Language': 'en',
      },
      body: body === undefined ? undefined : JSON.stringify(body),
      signal: AbortSignal.timeout(TIMEOUT_MS),
    });
    status = response.status;
    text = await response.text();
  } catch (error) {
    // Timeout, DNS failure, reset connection or a body cut off midway:
    // the request may have reached Multigate.
    return { outcome: Outcome.UNKNOWN, status, body: null, errors: [{ message: error.message }] };
  }

  const parsed = parseJson(text);
  if (status >= 200 && status < 300) {
    return { outcome: Outcome.SUCCESS, status, body: parsed, errors: [] };
  }
  return { outcome: classify(status), status, body: parsed, errors: readApiErrors(status, parsed) };
}

function parseJson(text) {
  try {
    return JSON.parse(text);
  } catch {
    return null; // empty, or an HTML page from the gateway
  }
}

function classify(status) {
  if (status === 401) return Outcome.CHECK_API_KEY;
  if (status >= 400 && status < 500) return Outcome.REFUSED;
  return Outcome.UNKNOWN; // 5xx and anything unexpected
}

export function readApiErrors(status, body) {
  const fallback = `HTTP ${status}`;
  if (Array.isArray(body)) {
    // Business rule error: [{ "errors": { "message", "code" } }]
    return body.map((item) => ({ message: item?.errors?.message ?? fallback, code: item?.errors?.code }));
  }
  if (body && Array.isArray(body.errors)) {
    // Validation error, error list or server error: { "errors": [{ "message", "fieldName" }] }
    return body.errors.map((item) => ({ message: item?.message ?? fallback, field: item?.fieldName }));
  }
  if (body && typeof body.message === 'string') {
    // Operation failed: { "message", "data", "code" }
    return [{ message: body.message, code: body.code }];
  }
  return [{ message: fallback }];
}
```

</Tab>
<Tab title="Python">

Needs the `requests` package (2.27 or later).

```python
import os

import requests

TIMEOUT_SECONDS = 65  # a little above the gateway's 60-second limit

SUCCESS = "success"              # 2xx: use the body
REFUSED = "refused"              # 4xx except 401: not carried out (check the exceptions in the docs)
CHECK_API_KEY = "check_api_key"  # 401: the API key was refused; nothing happened, don't retry until it's fixed
UNKNOWN = "unknown"              # 5xx, timeout or no response: look it up before retrying


def call_multigate(method, url, body=None, api_key=None):
    api_key = api_key or os.environ["MG_API_KEY"]  # from your secrets manager; never log it
    try:
        response = requests.request(
            method,
            url,
            json=body,
            timeout=TIMEOUT_SECONDS,
            headers={"mp-api-key": api_key, "Accept-Language": "en"},
        )
    except requests.RequestException as error:
        # Timeout, DNS failure, reset connection or a body cut off midway:
        # the request may have reached Multigate.
        return {"outcome": UNKNOWN, "status": None, "body": None, "errors": [{"message": str(error)}]}

    status = response.status_code
    try:
        parsed = response.json()
    except ValueError:
        parsed = None  # empty, or an HTML page from the gateway

    if 200 <= status < 300:
        return {"outcome": SUCCESS, "status": status, "body": parsed, "errors": []}
    return {"outcome": classify(status), "status": status, "body": parsed, "errors": read_api_errors(status, parsed)}


def classify(status):
    if status == 401:
        return CHECK_API_KEY
    if 400 <= status < 500:
        return REFUSED
    return UNKNOWN  # 5xx and anything unexpected


def read_api_errors(status, body):
    fallback = f"HTTP {status}"
    if isinstance(body, list):
        # Business rule error: [{"errors": {"message", "code"}}]
        details = [item.get("errors") or {} if isinstance(item, dict) else {} for item in body]
        return [{"message": d.get("message", fallback), "code": d.get("code")} for d in details]
    if isinstance(body, dict) and isinstance(body.get("errors"), list):
        # Validation error, error list or server error: {"errors": [{"message", "fieldName"}]}
        items = [item if isinstance(item, dict) else {} for item in body["errors"]]
        return [{"message": i.get("message", fallback), "field": i.get("fieldName")} for i in items]
    if isinstance(body, dict) and isinstance(body.get("message"), str):
        # Operation failed: {"message", "data", "code"}
        return [{"message": body["message"], "code": body.get("code")}]
    return [{"message": fallback}]
```

</Tab>
</Tabs>

Use it like this for a payout. The same pattern works for remittances and refunds; only the lookup changes (see [Idempotency](03-idempotency.md#per-operation)).

```javascript
const result = await callMultigate(`${MG_BASE_URL}/payment-service/api/v1/transfer/single-transfer`, {
  method: 'POST',
  body: payout, // includes the uniqueRequestId you stored beforehand
});

switch (result.outcome) {
  case Outcome.SUCCESS:
    await markSent(payout.uniqueRequestId, result.body.transactionId); // final status comes by webhook
    break;
  case Outcome.CHECK_API_KEY:
    await alertOperations('Multigate refused the API key'); // nothing happened: fix the key, then send the same payout
    break;
  case Outcome.REFUSED:
    if (result.errors.some((e) => e.message === 'Unique request ID already exist')) {
      await reconcile(payout.uniqueRequestId); // an earlier attempt exists: look it up
    } else {
      await markRefused(payout.uniqueRequestId, result.errors); // nothing happened: show or fix
    }
    break;
  case Outcome.UNKNOWN:
    await reconcile(payout.uniqueRequestId); // never mark it failed, never use a new ID
    break;
}
```

`MG_BASE_URL` is the base URL from the [Quickstart](../01-get-started/03-quickstart.md). `markSent`, `markRefused`, `alertOperations` and `reconcile` are your own functions; [Idempotency](03-idempotency.md#a-complete-example) shows `reconcile`.

Branch on the **outcome** (the HTTP status), not on message text or `code`: messages are written for people and can change, and `code` is present on only some errors. The one exception is the duplicate-key messages in the table above, which you need to match to tell "already exists" from other refusals.

Messages are in English. If your HTTP client sends `Accept-Language: fr`, some messages come back in French, so send `Accept-Language: en` (as the handler does) or leave the header out.

## Unknown outcomes

A `4xx` on a transfer, remittance or refund means it did **not** happen (apart from the exceptions above). Fix the request and send it again; you can keep the same `uniqueRequestId`.

A `5xx`, a timeout or a dropped connection is different: the request may have been processed. Never treat it as failed, and never resend it with a new `uniqueRequestId`, or you may pay twice.

```mermaid
flowchart TD
    send[Send request with uniqueRequestId] --> result{Outcome?}
    result -->|success| done[Store the result]
    result -->|refused| fix[Not processed: fix the request, then send it again]
    result -->|unknown| lookup[Look it up by uniqueRequestId]
    lookup --> found{Found?}
    found -->|Yes| track[Track it: wait for the webhook]
    found -->|No| wait[Wait at least a minute, look again]
    wait --> again{Found now?}
    again -->|Yes| track
    again -->|No| retry[Retry with the SAME uniqueRequestId]
```

[Idempotency](03-idempotency.md#retrying-safely) gives the steps and a code sample.

Conversions have no `uniqueRequestId`: the quote ID is the retry key. Two `4xx` errors on [Lock a rate](api:lockFxRate) are exceptions to "nothing happened": `402` "The settlement has failed…" can, rarely, hide a debit that completed later, and `417` "Reversal failed" means the money is still debited. Check the source balance before retrying. See [FX conversions](../04-products/09-fx-conversions.md).

Some `503` responses say nothing was done, for example "Unable to determine the fee for this transfer right now" on [Create a transfer](api:createSingleTransfer). Treating them as unknown is still safe: the lookup finds nothing and you retry with the same key.

## Server errors and reference IDs

A `500` includes a reference ID:

```json
{
  "errors": [
    { "message": "Something went wrong. Please try again later. If the problem persists, contact support with this reference ID: 3f1c2e8a-9b0d-4f7e-8a21-5c6d7e8f9a0b" }
  ]
}
```

Include that reference ID when you contact support. It lets us find the exact request in our logs.

## Trade finance

Trade finance endpoints (`/trade-finance-service/...`) use the same three families of errors, with small differences your handler should allow for:

- **Validation errors** have the same body as on every other service (`"error": "Bad request"`, `"message": "failed_validation"`); read `errors`. Validation runs before the permission check, so an invalid request gets `400` even without the permission.
- **`401` and `403`** have the fixed messages `unauthorized` and `forbidden`: `{ "errors": [{ "message": "forbidden", "context": {} }] }`. A `403` on an approval can also mean you're approving your own submission: another person must approve it.
- **Business rule errors** are the usual one-element array, for example `[{ "errors": { "message": "Cannot approve a rejected Letter of Credit" } }]`. Most are `400`; a record that doesn't exist is `404`, a duplicate internal reference or a request that's already pending is `409`, and an upload with an invalid chosen status is `422`.
- **`502`** on an approval or amendment review means the call to the bank failed or timed out. The record didn't change, but the instruction may have reached the bank: treat it like a timeout, read the record, and contact Multigate before trying again (see [Trade finance](../04-products/12-trade-finance.md#working-with-the-api)).
- A path ID that isn't a number returns `500`, not `404`.
