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 unique). 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 unique exists, possibly one you thought had failed. |
Look it up with Find transfers ?unique. 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 unique. |
Open a new account with a new unique. |
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 unique 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_, 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.
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.
In text fields, < and > are stored as < and >. For example a narration Invoice <42> is saved and returned as Invoice <42>. 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 ofsuccess,refused(a4xxother than401: see the tables above),check_api_key(401) orunknown(a5xx, a timeout or a dropped connection).errors: a list of{ message, field, code }, whatever shape the body had.statusandbody: the raw HTTP status (nullwhen there was no response) and the parsed body (nullwhen 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, alert 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 unique.
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 unique, 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 unique: 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"); readerrors. Validation runs before the permission check, so an invalid request gets400even without the permission. 401and403have the fixed messagesunauthorizedandforbidden:{ "errors": [{ "message": "forbidden", "context": {} }] }. A403on 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 are400; a record that doesn't exist is404, a duplicate internal reference or a request that's already pending is409, and an upload with an invalid chosen status is422. 502on 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, not404.