Skip to content
MultigateAPI
Support Staff

Concepts

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.
402 No (one rare exception) No Lock a rate 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.
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.
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 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 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.
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 ?uniqueRequestId=…. See Idempotency.
"uniqueRequestId has already been used" (accounts), "A receivable with this uniqueRequestId already exists" Same, for accounts and receivables. Look it up with List accounts or List receivables.
"Invalid transaction (general response for invalid transactions)" on Commit a bulk transfer The preview is unknown, expired or already committed. Check Find transfers ?batchId=… before you preview again.
{"message": "account failed", …} on Create an account The account was recorded as failed; it keeps its uniqueRequestId. Open a new account with a new uniqueRequestId.
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.

Error body shapes

Errors come in four JSON shapes, depending on where the request was stopped. The handler below understands all of them.

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.

{
  "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" }
  ]
}

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.

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

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.

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

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.

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

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

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.

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).

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

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 }];
}

Needs the requests package (2.27 or later).

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}]

Use it like this for a payout. The same pattern works for remittances and refunds; only the lookup changes (see Idempotency).

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. markSent, markRefused, alertOperations and reconcile are your own functions; Idempotency 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.

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

Some 503 responses say nothing was done, for example "Unable to determine the fee for this transfer right now" on Create a transfer. 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:

{
  "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).
  • A path ID that isn't a number returns 500, not 404.