Error handling

Error handling best practices

How to parse, handle, and recover from errors across the Mercury API. Build integrations that are resilient by default.

HTTP status codes

The Mercury API uses a small, consistent set of status codes across all endpoints:

Code Meaning Your action Retryable?
200 Success Process the response N/A
400 Validation failed Parse the errors array, fix input, retry Yes, after fixing input
401 Authentication failed Check API key, nonce, and signature Yes, after fixing auth
500 Server error Wait, then retry with exponential backoff Yes, with backoff

Error response format

All error responses follow the same structure, making it simple to build a single error handler for your integration.

{
  "status": "Error",
  "message": "Request validation failed",
  "errors": [
    {
      "propertyName": "externalReference",
      "errorDescription": "External reference is required"
    },
    {
      "propertyName": "associates",
      "errorDescription": "At least one associate is required"
    }
  ]
}
"Internal server error"
400 errors

Always return a structured JSON object with a status, message, and an errors array. Each entry tells you the field name and what went wrong. Multiple validation failures are returned in a single response.

500 errors

Return a plain string. These are transient server-side issues. Do not parse for details. Log the response, wait, and retry.

Parsing validation errors

Build your error handler to iterate over the errors array. Each object contains exactly two fields:

import requests

response = requests.post(f"{BASE_URL}/api/v1/client", headers=headers, data=body)

if response.status_code == 200:
    client = response.json()
    print(f"Client created: {client['Response']['Identifier']}")

elif response.status_code == 400:
    error_data = response.json()
    print(f"Validation failed: {error_data['message']}")
    for err in error_data.get("errors", []):
        print(f"  - {err['propertyName']}: {err['errorDescription']}")

elif response.status_code == 401:
    print("Authentication failed. Check API key, nonce, and signature.")

elif response.status_code >= 500:
    print(f"Server error ({response.status_code}). Retry with backoff.")
const response = await fetch(`${BASE_URL}/api/v1/client`, {
  method: "POST",
  headers,
  body,
});

if (response.ok) {
  const { Response: client } = await response.json();
  console.log(`Client created: ${client.Identifier}`);

} else if (response.status === 400) {
  const { message, errors } = await response.json();
  console.error(`Validation failed: ${message}`);
  errors.forEach((e) =>
    console.error(`  - ${e.propertyName}: ${e.errorDescription}`)
  );

} else if (response.status === 401) {
  console.error("Authentication failed. Check API key, nonce, and signature.");

} else if (response.status >= 500) {
  console.error(`Server error (${response.status}). Retry with backoff.`);
}
var response = await client.PostAsync($"{baseUrl}/api/v1/client", content);

if (response.IsSuccessStatusCode)
{
    var result = await response.Content.ReadFromJsonAsync<ClientResponse>();
    Console.WriteLine($"Client created: {result.Response.Identifier}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
    var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
    Console.WriteLine($"Validation failed: {error.Message}");
    foreach (var e in error.Errors)
        Console.WriteLine($"  - {e.PropertyName}: {e.ErrorDescription}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
    Console.WriteLine("Authentication failed. Check API key, nonce, and signature.");
}
else
{
    Console.WriteLine($"Server error ({(int)response.StatusCode}). Retry with backoff.");
}

Common validation errors

These are the most frequent 400 errors and how to resolve them:

Client Onboarding
externalReference missing or already in use
associates empty (at least one required)
dateOfBirth under 18 years old
countryCode not a valid ISO 3166-1 alpha-2 code
industryCode not in lookup table
Accounts
clientReference does not exist or is not Active
Client already has a virtual IBAN for the currency
GBP Payments (FPS)
amount exceeds GBP 1,000,000 limit
endToEndIdentifier already submitted
sortCode not 6 digits
Creditor needs IBAN or account number + sort code (not both empty)
Insufficient funds
EUR Payments (SEPA)
iban fails mod-97 checksum
swiftCode invalid format
Submitted outside operating hours (Mon-Fri 06:00-14:25 UK)
Submitted on a TARGET2 holiday (1 Jan, 1 May, 25-26 Dec)

Authentication errors (401)

A 401 means your request signature did not match what the server computed. Walk through these checks in order:

1

Check payload order

Must be apiKey, method, path, body (not for GET), nonce, joined with \n. No trailing newline. The Authorization header must start with HMACSHA256, not Bearer.

2

Check method case

HTTP method must be uppercase in the payload: GET, POST, PUT.

3

Check body match

For POST/PUT, the body in your signature must be byte-for-byte identical to what you send. Watch for libraries that re-serialise JSON.

4

Check encoding

HMAC output must be Base64-encoded, not hex. Many libraries default to hex.

5

Use the helper tool

Enter identical inputs in the Helper Tool and compare the canonical string and signature character-by-character.

Async errors via webhooks

Some errors only surface after the initial 200 response. Payments are processed asynchronously, so you must listen for error webhooks to catch failures:

Webhook Event What it means
TransactionRejected Payment rejected by the payment scheme or compliance checks
PaymentValidationFailed Failed scheme-level validation after submission (e.g. invalid beneficiary details)
PaymentAssesmentFailed Failed compliance or sanctions screening
OutboundTransactionHeld Payment held for manual review. May be released or rejected later.
MccyOutboundPaymentFailed EUR SEPA payment rejected or failed after submission
AccountCreationFailed Virtual account provisioning failed after initial acceptance

A 200 on a payment request does not mean the payment succeeded. It means the request was accepted for processing. Always implement webhook listeners for the settlement and error events above.

Retry strategy

For 500 errors, use exponential backoff with jitter. Here is a recommended pattern:

import time
import random

def call_with_retry(fn, max_retries=3):
    for attempt in range(max_retries + 1):
        response = fn()

        if response.status_code == 200:
            return response.json()

        if response.status_code == 400:
            raise ValidationError(response.json())

        if response.status_code == 401:
            raise AuthError("Check API key, nonce, and signature")

        if response.status_code >= 500 and attempt < max_retries:
            wait = (2 ** attempt) + random.uniform(0, 1)
            print(f"Server error. Retrying in {wait:.1f}s (attempt {attempt + 1}/{max_retries})")
            time.sleep(wait)
            continue

        raise ServerError(f"Failed after {max_retries + 1} attempts")
async function callWithRetry(fn, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fn();

    if (response.ok) return response.json();

    if (response.status === 400) {
      const error = await response.json();
      throw new ValidationError(error);
    }

    if (response.status === 401) {
      throw new AuthError("Check API key, nonce, and signature");
    }

    if (response.status >= 500 && attempt < maxRetries) {
      const wait = 2 ** attempt + Math.random();
      console.log(`Server error. Retrying in ${wait.toFixed(1)}s (attempt ${attempt + 1}/${maxRetries})`);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }

    throw new ServerError(`Failed after ${maxRetries + 1} attempts`);
  }
}
3
Max retries
2n
Exponential backoff
+jitter
Prevents thundering herd

Best practices checklist

Always check the status code first. Do not assume 200. Parse the response differently for each code.
Log every error response. Include the full body, status code, endpoint, and timestamp. This makes debugging much faster.
Never retry 400 errors without changing the input. The same request will always produce the same validation failure.
Use a fresh nonce on every retry. Even for 500 retries, generate a new nonce and re-sign the request.
Handle async errors via webhooks. A 200 on a payment does not mean success. Listen for settlement and failure events.
Validate before sending. Check required fields, data types, and formats client-side to avoid unnecessary 400s.
Check SEPA operating hours before submitting. EUR payments are rejected outside Mon-Fri 06:00-14:25 UK time and on TARGET2 holidays. Queue and send during the next window.
Set up alerting for repeated 500s. Transient errors are normal. Sustained 500s may indicate a platform issue. Contact API support if errors persist beyond 5 minutes.

Need help debugging?

Use the Helper Tool to verify your auth signatures, check the Example Requests for working reference calls, or reach out to API support.


Did this page help you?