Every Danipa Pay error response uses the same JSON envelope. This page walks through the shape, the codes you'll see most often, and how to retry safely.
The error envelope
All non-2xx responses return:
{
"timeStamp": 1730649600000,
"correlationId": "8f2a3b4c-…",
"traceId": "00-1a2b3c4d…",
"statusCode": 422,
"domain": "fintech",
"severity": "ERROR",
"errors": [
{
"code": "WALLET_INSUFFICIENT_FUNDS",
"description": "Wallet wlt_01HG… has 0.00 USD; transfer requested 10.00 USD."
}
]
}
Field guide:
| Field | What it carries |
|---|---|
timeStamp | Server epoch millis when the response was built |
correlationId | Stable id for this request's audit trail — surface in your logs |
traceId | W3C trace context — paste into your APM to see the full request span |
statusCode | Same as the HTTP status |
domain | Which Danipa service produced the error (fintech, identity, notification) |
severity | ERROR, WARN, or INFO (most non-2xx are ERROR) |
errors[] | One or more {code, description} pairs. Codes are stable; descriptions are human-readable and may change |
HTTP status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Successful GET request |
201 | Created | Resource successfully created |
202 | Accepted | Async operation accepted (e.g. remittance) |
400 | Bad Request | Invalid parameters or malformed JSON |
401 | Unauthorized | Missing or invalid API key |
403 | Forbidden | Valid API key but insufficient permissions |
404 | Not Found | Resource does not exist |
409 | Conflict | Duplicate Idempotency-Key with different body |
422 | Unprocessable | Valid JSON but business rule violation |
429 | Too Many Requests | Rate limit exceeded |
500 | Server Error | Unexpected server error |
503 | Service Unavailable | Maintenance or provider outage |
400 — Bad request
The request was malformed before any business logic ran (invalid JSON, missing required field, wrong content-type). Fix the client; do not retry the same payload.
401 — Unauthorized
The Authorization header is missing, malformed, or the key is
revoked. See Authentication.
403 — Forbidden
The key is valid but missing the required scope. Don't retry — either re-issue the key with broader scopes or call a different endpoint.
404 — Not found
The resource doesn't exist or your account can't see it. Treat the same as a public 404; don't retry.
409 — Conflict (idempotency)
You sent an Idempotency-Key you've used before, but with a
different request body. Either re-use the original key with the
original payload, or pick a fresh key.
422 — Unprocessable entity
The request was syntactically valid but failed a business rule
(insufficient funds, KYC tier too low, recipient ineligible). The
errors[].code is the actionable bit. Don't retry blindly.
429 — Rate-limited
You hit a rate limit. The response includes:
Retry-After: 12
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1730649612
Sleep for Retry-After seconds and retry. Use exponential backoff
if you keep hitting it.
5xx — Server error
Treat as transient. Retry with exponential backoff (e.g. capped at
60s, doubling each attempt) up to roughly 5 attempts. If you used
an Idempotency-Key, the retry is safe — the server returns the
original response if it already processed.
Common error codes
The errors[].code is the stable identifier you should branch on.
Below are the codes you'll see most often.
Validation errors (400)
| Code | Description |
|---|---|
INVALID_AMOUNT | Amount is zero, negative, or exceeds limits |
INVALID_CURRENCY | Unsupported currency code |
INVALID_MSISDN | Phone number is not valid E.164 format |
INVALID_PROVIDER | Unsupported payout provider |
MISSING_FIELD | Required field not provided |
Business errors (422)
| Code | Description |
|---|---|
WALLET_INSUFFICIENT_FUNDS | Wallet balance too low for this transaction |
DAILY_LIMIT_EXCEEDED | Transaction would exceed daily sending limit |
RECIPIENT_NOT_FOUND | MoMo account not registered for this number |
CORRIDOR_UNAVAILABLE | Currency pair not currently supported |
KYC_REQUIRED | Higher KYC tier needed for this amount |
Provider errors (502/503)
| Code | Description |
|---|---|
PROVIDER_TIMEOUT | MoMo or bank API did not respond in time |
PROVIDER_UNAVAILABLE | Payout provider is temporarily down |
PROVIDER_REJECTED | Provider rejected the transaction |
Retry strategy
| Error Type | Retry? | Strategy |
|---|---|---|
| 400 (validation) | No | Fix the request and resubmit |
| 401/403 (auth) | No | Check API key and permissions |
| 404 (not found) | No | Verify the resource ID |
| 409 (conflict) | No | Use a different Idempotency-Key (or replay the original body) |
| 429 (rate limit) | Yes | Wait for Retry-After header |
| 500 (server) | Yes | Retry with exponential backoff |
| 502/503 (provider) | Yes | Retry after 30-60 seconds |
Idempotency keys
For mutating endpoints (POST, PUT, PATCH, DELETE on most
resources), pass an Idempotency-Key header — a UUID you generate.
The server stores the (key, request hash, response) triple for 24 hours. During that window:
- Same key + same body → returns the original response (200 if it succeeded, the original error if it failed). Safe to retry.
- Same key + different body → 409 Conflict. Use a fresh key.
- New key → request is processed normally.
cURL
curl https://api.sandbox.danipa.com/ms/v1/transfers \
-H "Authorization: Bearer $DANIPA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"amount":"10.00","currency":"USD","recipient":"test_recipient_ghana"}'
Node.js retry helper
import axios from 'axios';
import { randomUUID } from 'node:crypto';
const idempotencyKey = randomUUID();
async function withRetry<T>(fn: () => Promise<T>, attempts = 5): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err: unknown) {
const e = err as { response?: { status?: number; headers?: Record<string, string> } };
const status = e.response?.status ?? 0;
if (status < 500 && status !== 429) throw err;
const retryAfter = Number(e.response?.headers?.['retry-after'] ?? 0);
const backoff = retryAfter > 0 ? retryAfter * 1000 : Math.min(60000, 2 ** i * 100);
await new Promise((r) => setTimeout(r, backoff));
}
}
throw new Error('exhausted retries');
}
const res = await withRetry(() =>
axios.post(
'https://api.sandbox.danipa.com/ms/v1/transfers',
{ amount: '10.00', currency: 'USD', recipient: 'test_recipient_ghana' },
{
headers: {
Authorization: 'Bearer ' + process.env.DANIPA_API_KEY,
'Idempotency-Key': idempotencyKey,
},
},
),
);
Python retry helper
import os, time, uuid, requests
idempotency_key = str(uuid.uuid4())
def with_retry(call, attempts=5):
for i in range(attempts):
resp = call()
if resp.status_code < 500 and resp.status_code != 429:
return resp
retry_after = int(resp.headers.get('Retry-After', 0))
backoff = retry_after if retry_after > 0 else min(60, 2 ** i * 0.1)
time.sleep(backoff)
return resp
def post():
return requests.post(
'https://api.sandbox.danipa.com/ms/v1/transfers',
json={'amount': '10.00', 'currency': 'USD', 'recipient': 'test_recipient_ghana'},
headers={
'Authorization': f"Bearer {os.environ['DANIPA_API_KEY']}",
'Idempotency-Key': idempotency_key,
},
)
resp = with_retry(post)
resp.raise_for_status()
Rate limits at a glance
| Surface | Limit |
|---|---|
| Per API key | 200 requests / minute by default; uplift on request |
| Per IP (unauthenticated) | 60 requests / minute |
| Webhook deliveries to your endpoint | We back off if you 5xx; details in the webhooks guide |
The actual limits in effect for your key are returned on every
response in the X-RateLimit-* headers — read those instead of
hard-coding values.
Related
- Authentication —
401and scope-related403responses. - Get started — uses an idempotency key in the first transfer example.