Errors & rate limits
Error envelope
Every 4xx/5xx response from the Partner API has one shape:
{
"success": false,
"error": {
"code": "SIGNATURE_INVALID",
"message": "human-readable explanation"
}
}
- Branch on
error.code, never onerror.message— messages are for humans and may be reworded. - A few codes add extra fields inside
error(detailsonVALIDATION_FAILED,caseIdonDUPLICATE_CONFIRMATION,retryAfteronRATE_LIMITED); they are listed with the code below. - Every response from a Partner API route — success or error — carries an
X-Request-Idheader. Log it with the call, and quote it when you write to [email protected]; it lets us trace the exact request. - The envelope and
X-Request-Idguarantee covers the defined routes. A path that matches no route (a typo, a wrong prefix) gets the web framework's plain404body —{ "message", "error", "statusCode" }— with no envelope and noX-Request-Id. If you see that shape, check the URL. - Success responses do not share a common envelope: most endpoints return
{ "success": true, ... }with endpoint-specific fields andGET /healthreturns a flat object. Only errors share the shape above.
:::note Row failures in POST /cases are not errors
A batch never fails because one row is bad. Row-level problems come back
inside a 200 response as errors: [{ index, sourceLoanNumber, reason }]
(capped at 50 entries — summary.errored is the true count) while the good
rows are committed. Rows that were imported but need your attention (for
example coordinates far from the pincode) come back in the same response as
warnings: [{ index, sourceLoanNumber, reason }] — a row can have several
entries; the list is capped at 50, and summary.warned counts the rows
that imported with at least one warning. The envelope above is used only when the
whole request is rejected: authentication, rate limits, envelope-level
schema failure (422), too many rows (413), and so on. See the
push API.
:::
Order of checks
Each request is checked in this order, and the first failure is returned:
Authorization, timestamp and signature headers present → otherwise401 UNAUTHORIZED.- A body sent with a
Content-Typeother thanapplication/json→415 UNSUPPORTED_MEDIA_TYPE. - Body parsed → malformed JSON is
422 VALIDATION_FAILED(messagemalformed request body (send valid JSON), nodetails), an oversized body413 PAYLOAD_TOO_LARGE. - Key, environment, allowlist, lockout, timestamp and signature →
401/403/429. - Per-key request rate (60/min) →
429 RATE_LIMITED. - Request schema →
422 VALIDATION_FAILEDwithdetails. (The 10-batches/hour limit onPOST /casesis checked after this.)
So a 413, 415 or malformed-JSON 422 can reach you before your signature
is checked, but a schema 422 never precedes authentication. If your first
calls return 401, fix the signature before you look at the body.
Complete code list
| HTTP | Code | When it occurs | What to do |
|---|---|---|---|
| 401 | UNAUTHORIZED | Missing auth headers; unknown, revoked or expired key; test key on production or live key on sandbox; caller IP not on your allowlist | Check key id + host pair (environments); check the three headers are present; check your egress IP against the allowlist you registered |
| 401 | SIGNATURE_INVALID | HMAC does not match the canonical string | Use POST /debug/echo-signature on the sandbox — debugging signatures. Stop retrying: 10 failures in 5 minutes lock the key |
| 401 | TIMESTAMP_SKEW | X-Fieldproof-Timestamp outside ±5 minutes of server time (unix milliseconds) | Sync clocks (NTP); sign at send time, not at build time; send ms, not seconds |
| 403 | LENDER_INACTIVE | Your organisation is suspended | Contact [email protected] |
| 403 | SANDBOX_ONLY | POST /debug/echo-signature with a live key, or against the production host | Use a test key on https://alpha-gig.fluxusforge.in |
| 403 | UNKNOWN_LENDER | Your key authenticated but is not attached to a configured organisation record (a provisioning problem, not a client bug) | Contact [email protected] with the X-Request-Id |
| 403 | PAYMENT_MODE_MISMATCH | POST /cases/{loan}/payments/confirm while your organisation is not in org_gateway mode | Confirm your configured payment mode; Mode A (static QR) organisations never call confirm |
| 404 | CASE_NOT_FOUND | No case for that sourceLoanNumber in your organisation. Also returned for another organisation's loan — there is no existence oracle | Check the sourceLoanNumber exactly as you pushed it, and that the batch was committed (not a dryRun) |
| 404 | DELIVERY_NOT_FOUND | Replay of an eventId that does not exist for your organisation (wrong id, or another organisation's event) | Check the id against GET /cases/updates or the delivery log. Events that were never queued for a callback CAN be replayed — the delivery is created on demand (needs an active callback, else 422 CALLBACK_NOT_CONFIGURED) |
| 404 | STATEMENT_NOT_FOUND | Billing: no finalized statement for that month (not billed yet, or no such month) | Not a fault — list GET /billing/statements to see which months exist |
| 409 | DUPLICATE_CONFIRMATION | Same (sourceLoanNumber, reference) already confirmed. error.caseId is included | Treat as success — the money is already booked. Only caseId is returned, not the original booking; read GET /cases/{loan} if you need outstandingAfter |
| 409 | CASE_ALREADY_COLLECTED | Case is already fully collected — this reference is a second payment for money not owed | Refund the borrower from your gateway; do not retry |
| 409 | AMOUNT_EXCEEDS_OUTSTANDING | amount is more than 101% of the remaining outstanding | Refund the excess / adjust on your side; do not retry as-is |
| 409 | CASE_CLOSED | Case was recalled or written off before this payment arrived | Refund the borrower; the case is terminal |
| 409 | NO_INVOICE | Billing: the month is finalized but no invoice number was allocated when it was finalized (invoice is null on that statement). Not transient | Do not retry on a loop — contact [email protected] |
| 413 | PAYLOAD_TOO_LARGE | Request body exceeds the server's body-size limit | Split the batch; keep batches ≤5,000 rows |
| 413 | TOO_MANY_ROWS | rows has more than 20,000 entries | Split the batch (do not split a fullSnapshot: true batch — see below) |
| 415 | UNSUPPORTED_MEDIA_TYPE | A request with a body whose Content-Type is not application/json (bodies are signed as raw JSON bytes only). Returned before the signature is checked, and does not count toward the lockout | Send Content-Type: application/json |
| 422 | VALIDATION_FAILED | Body fails the schema (error.details[]), malformed JSON, a POST …/recall with no body and no Content-Type header (send {}), invalid cursor/since/status query on the poll or delivery-log endpoints, a month that is not YYYY-MM or a limit outside 1–60 on billing, mode: "cash" or a bad amount/reference/paidAt on confirm | Fix the request; see the details shape below |
| 422 | CALLBACK_NOT_CONFIGURED | POST /webhooks/test when no active callback URL is configured for this environment | Configure the webhook URL for this environment (sandbox and production are configured separately) |
| 429 | AUTH_LOCKED | 10 signature failures within 300 s → the key is locked for 900 s. Retry-After = seconds remaining | Stop all traffic on the key, debug the signature, wait out Retry-After |
| 429 | RATE_LIMITED | 60 requests/min per key exceeded, or POST /cases called more than 10 times per hour per key. The Retry-After header and error.retryAfter both give the seconds to wait | Honour Retry-After; batch more rows per push instead of pushing more often |
| 500 | INTERNAL_ERROR | Our fault (also: the billing invoice PDF failed to render, or the sandbox signature debugger could not read your key's secret) | Retry with backoff. payments/confirm is safe to retry (a retry after a 5xx resumes the booking); a re-sent batch updates existing cases in place, and an identical committed batch is a no-op while every loan in it still has a live case (reliability) |
| 503 | FEATURE_DISABLED | External-organisation ingestion is switched off platform-wide (POST /cases) | Retry later; if it persists, contact [email protected] |
422 VALIDATION_FAILED details
When the body fails schema validation, error.details lists the violation.
field is a dotted path into your request body — for a missing required
property it is the path of the object that lacks it (or the property name when
it is missing at the top level); reason is the validator's message.
The schema covers only the envelope — for POST /cases that is rows
(a non-empty array of objects) and the optional fullSnapshot boolean. For
example, a body without rows:
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "request body failed validation",
"details": [
{ "field": "rows", "reason": "must have required property 'rows'" }
]
}
}
Malformed JSON returns the same code with the message
malformed request body (send valid JSON) and no details. The message never
quotes your body or the parser's error text.
Everything inside a row — missing or malformed fields, an unparseable
loan.dpd or date, a phone that does not normalise to 10 digits, a missing
address line, state or coordinates — never produces a 422 (only a row that is
not a JSON object at all fails the envelope schema, with field rows.N).
Per-row problems come back in a 200 response: rejected rows in
errors[], and, for an organisation on a coordinate grace period, missing
address fields or coordinates in warnings[] (the row still imports). A
?dryRun=1 call shows exactly the same lists without writing anything.
The 409 family on payments/confirm
The four 409 codes are business outcomes, not transport failures — do not
put them on a retry loop:
| Code | Meaning | Money |
|---|---|---|
DUPLICATE_CONFIRMATION | Already booked under this reference | Nothing to do — success |
CASE_ALREADY_COLLECTED | Case was already fully collected | Refund the borrower |
AMOUNT_EXCEEDS_OUTSTANDING | Amount more than 101% of what remained | Refund/adjust the excess |
CASE_CLOSED | Case was recalled / written off first | Refund the borrower |
Details and the full confirm flow: your gateway and reconciliation.
Retry guidance
| Response | Retry? |
|---|---|
Network error, 500 INTERNAL_ERROR | Yes, with backoff (confirm resumes; batches are idempotent) |
503 FEATURE_DISABLED | Later, not on a tight loop — it is a platform switch, not a transient fault |
429 RATE_LIMITED, 429 AUTH_LOCKED | Only after Retry-After — and for AUTH_LOCKED, only after the signature is fixed |
401, 403, 404, 413, 415, 422 | No — the same request will fail the same way. Fix it first |
409 (confirm) | No — apply the business outcome above |
409 NO_INVOICE (billing) | No — contact [email protected] |
Rate limits
| Limit | Value | On breach |
|---|---|---|
| Requests | 60 / minute per API key | 429 RATE_LIMITED + Retry-After (and error.retryAfter) |
POST /cases batches | 10 / hour per API key — ?dryRun=1 calls and Idempotency-Key replays count toward the 10 | 429 RATE_LIMITED + Retry-After (and error.retryAfter) |
| Batch size | 20,000 rows hard cap; keep ≤5,000 for fast responses | 413 TOO_MANY_ROWS |
| Signature failures | 10 in 300 s | key locked 900 s → 429 AUTH_LOCKED + Retry-After |
:::warning fullSnapshot and the row cap
fullSnapshot: true means "this batch is my complete open book" — active
cases absent from it are flagged for ops review. Chunking a snapshot into
several batches would flag everything missing from each chunk. Send the
snapshot as one batch (≤20,000 rows); if your open book is larger than
that, talk to [email protected] before using fullSnapshot.
:::
:::tip Budget the batch limit
Ten batches an hour is plenty when each batch carries thousands of rows. Test
your row shape with one ?dryRun=1 per change, not one per row — dry-runs
spend the same budget.
:::