Errors
Every non-2xx response uses the same envelope:
{
"error": {
"type": "invalid_request",
"message": "amount must be positive"
}
}| Field | Type | Notes |
|---|---|---|
type | string | Stable machine-readable identifier. Branch on this |
message | string | Human-readable; safe to surface to your end users for invalid_request only |
Status codes
| Code | When |
|---|---|
400 | invalid_request — bad body / missing field / wrong type |
401 | unauthorized — missing or wrong API key |
403 | forbidden — merchant suspended, role insufficient |
404 | not_found — id doesn't exist or isn't yours |
409 | conflict — uniqueness violation (e.g. duplicate slug) |
422 | precondition_failed — request well-formed but resource state forbids it (refund non-succeeded payment, promote with no rotation in flight) |
429 | rate_limited — over the per-merchant or per-route bucket |
500 | internal — our fault; opaque message |
502 | bad_gateway — downstream channel (Daraja, Stripe) rejected synchronously |
Type catalog
invalid_request
Generic shape error. Usually fixable by changing the request body.
Common causes:
- Missing required field (
amount,reference, ...) - Wrong currency for the channel (e.g.
currency: "USD"withchannel: "mpesa_stk_push") - Malformed phone number (must be E.164 with leading
+) - Non-positive
amount
unauthorized
API key missing, malformed, or doesn't exist. Check Authorization header.
For /v1/auth/login and /v1/auth/register: bad credentials. We don't distinguish "user doesn't exist" from "wrong password" — both return unauthorized.
forbidden
API key is valid but the merchant is suspended.
not_found
The id you passed doesn't exist OR belongs to a different merchant (we don't differentiate to prevent enumeration).
conflict
A uniqueness constraint failed:
- Slug already taken (on
POST /v1/me/slug) - Email already registered (on
POST /v1/auth/register) - Reference already used by this merchant (on
POST /v1/paymentswithout idempotency key — returns the existing payment, not an error, but only for exact duplicate)
precondition_failed
Request is well-formed but the resource state doesn't allow the operation:
- Refunding a payment in
failed/processingstatus - Refunding more than the remaining amount
- Promoting a webhook secret when no rotation is in flight
- Requesting a payout for more than the available balance
rate_limited
You've exceeded the per-merchant or per-route rate limit. Honor the Retry-After header (seconds) and back off.
internal
Our fault. The message is opaque. Email dev@teronapay.com with the request id (from the X-Request-Id response header) for help.
bad_gateway
A downstream channel rejected synchronously:
- Daraja credentials wrong / expired
- Daraja sandbox down
- Stripe declined with a non-card-decline error (e.g. network)
Retry with backoff. If persistent, check the dashboard for channel-specific status.
Recovery patterns
- 400 invalid_request: do not retry. Fix the request shape.
- 401 unauthorized: do not retry. Rotate / re-issue the API key.
- 422 precondition_failed: do not retry. The resource state is the problem.
- 429 rate_limited: retry after
Retry-Afterseconds. - 500 internal / 502 bad_gateway: retry with exponential backoff. After 3 attempts, surface an error to your end user.
The X-Request-Id header
Every response includes X-Request-Id. Include it when reporting bugs to dev@teronapay.com — we use it to find the request in our logs.
