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.

HTTP/1.1 200 OK
{
  "success": true,
  "data": { ... },
  "meta": {
    "request_id": "req_01HX...",
    "timestamp": "2026-05-07T14:22:00Z"
  }
}
HTTP/1.1 404 Not Found
{
  "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

StatusMeaningWhat to do
400Malformed request — e.g. an unparseable pagination cursor.Fix the request; do not retry as-is.
401Missing, malformed, or revoked API key.Check the header name and key format.
403Authenticated, but not authorized for this school.You may only post donations to schools linked to your bank.
404Resource does not exist, or is not visible to you.Verify the identifier.
409Idempotency conflict.You already submitted this bank_transaction_ref. Treat as success.
422Well-formed JSON that fails validation.Read error.details — it names the offending field.
429Rate limited.Back off and honor Retry-After.
5xxServer-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

CodeStatusCause
ACCOUNT_NOT_FOUND404No linked account for the given wishbone_user_id.
ACCOUNT_ALREADY_LINKED409This cardholder is already linked.
DONATION_DUPLICATE409A donation with this bank_transaction_ref already exists.
SCHOOL_NOT_ACTIVE404School not found, or not currently accepting donations.
FUND_INVALID422fund_designation is not recognized for this school.
AMOUNT_BELOW_MINIMUM422Below the school or fund minimum, whichever is higher.
TOKEN_EXPIRED401Portal access or embed token has expired.
INVALID_WEBHOOK_SIG401Webhook signature did not verify.
VALIDATION_FAILED422Request body failed schema validation. See error.details.
FORBIDDEN403Not authorized for this resource.
RATE_LIMITED429Too many requests.
SERVER_ERROR500Unhandled server-side failure.

Validation failures

A 422 names every field that failed, so you rarely need to guess:

HTTP/1.1 422 Unprocessable Entity
{
  "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

SurfaceLimit
Bank / Issuer API1,000 requests per minute per API key
Cardholder Portal API120 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.