Errors & rate limits
Every error carries a machine-readable code, and the HTTP status is always real.
The response envelope
Every response — success or failure — uses the same envelope, so you can write one unwrapping helper and reuse it everywhere.
{
"success": true,
"data": { ... },
"meta": {
"request_id": "req_01HX...",
"timestamp": "2026-05-07T14:22:00Z"
}
}{
"success": false,
"error": {
"code": "ACCOUNT_NOT_FOUND",
"message": "No linked account found for the provided wishbone_user_id.",
"status": 404
},
"meta": { "request_id": "req_01HX..." }
}The HTTP status is always the real status. A 404 is sent as an HTTP 404, a 422 as an HTTP 422. The error.status field mirrors the HTTP status for clients that only log the body — it never replaces it. Wishbone does not return 200 OK with a failure inside. Branch on res.ok, then read error.code for the specific reason.
Always log meta.request_id. It is the fastest way for us to find a specific call.
Status codes
| Status | Meaning | What to do |
|---|---|---|
| 400 | Malformed request — e.g. an unparseable pagination cursor. | Fix the request; do not retry as-is. |
| 401 | Missing, malformed, or revoked API key. | Check the header name and key format. |
| 403 | Authenticated, but not authorized for this school. | You may only post donations to schools linked to your bank. |
| 404 | Resource does not exist, or is not visible to you. | Verify the identifier. |
| 409 | Idempotency conflict. | You already submitted this bank_transaction_ref. Treat as success. |
| 422 | Well-formed JSON that fails validation. | Read error.details — it names the offending field. |
| 429 | Rate limited. | Back off and honor Retry-After. |
| 5xx | Server-side failure. | Retry with backoff. Safe when you reuse the same bank_transaction_ref. |
400 vs 422: 400 means we could not parse or interpret the request at all. 422 means we understood it and a field was invalid — a negative dollar_amount, a fund_designation the school does not offer, an amount under the fund minimum. Only 422 carries error.details.
Error codes
| Code | Status | Cause |
|---|---|---|
| ACCOUNT_NOT_FOUND | 404 | No linked account for the given wishbone_user_id. |
| ACCOUNT_ALREADY_LINKED | 409 | This cardholder is already linked. |
| DONATION_DUPLICATE | 409 | A donation with this bank_transaction_ref already exists. |
| SCHOOL_NOT_ACTIVE | 404 | School not found, or not currently accepting donations. |
| FUND_INVALID | 422 | fund_designation is not recognized for this school. |
| AMOUNT_BELOW_MINIMUM | 422 | Below the school or fund minimum, whichever is higher. |
| TOKEN_EXPIRED | 401 | Portal access or embed token has expired. |
| INVALID_WEBHOOK_SIG | 401 | Webhook signature did not verify. |
| VALIDATION_FAILED | 422 | Request body failed schema validation. See error.details. |
| FORBIDDEN | 403 | Not authorized for this resource. |
| RATE_LIMITED | 429 | Too many requests. |
| SERVER_ERROR | 500 | Unhandled server-side failure. |
Validation failures
A 422 names every field that failed, so you rarely need to guess:
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed. See error.details.",
"status": 422,
"details": [
{
"type": "greater_than",
"loc": ["body", "dollar_amount"],
"msg": "Input should be greater than 0",
"input": -5
}
]
},
"meta": { "request_id": "req_01HX..." }
}Rate limits
| Surface | Limit |
|---|---|
| Bank / Issuer API | 1,000 requests per minute per API key |
| Cardholder Portal API | 120 requests per minute per cardholder token |
Rate limiting is applied at the edge, ahead of the application. Treat the numbers above as the contract and always handle 429 with Retry-After. Per-response X-RateLimit-* headers are not currently emitted — do not build a client that depends on reading your remaining quota from a response.