Errors
Lipafy errors are JSON responses with three fields:
1 { 2 "error": "<stable_code>", 3 "message": "Human readable description", 4 "details": { "..." } 5 }
The error string is the API surface — it’s stable across versions and safe to switch on. The message is for humans and may evolve. details is optional context.
Auth
| Code | Status | When |
|---|---|---|
unauthenticated | 401 | No credential, or credential failed validation |
forbidden | 403 | Authenticated but lacks permission for this action |
invalid_api_key | 401 | API key not found |
api_key_revoked | 401 | Was valid, now revoked |
api_key_expired | 401 | Past expires_at |
account_suspended | 403 | Account is status='suspended' |
invalid_token | 401 | Session token / magic-link token / OAuth refresh token didn’t validate |
expired_token | 401 | Valid shape but past TTL |
OAuth
| Code | Status | When |
|---|---|---|
oauth_invalid_client | 401 | client_id not registered |
oauth_invalid_grant | 400 | code / refresh_token unknown, used, or expired |
oauth_invalid_redirect_uri | 400 | redirect_uri doesn’t exactly match a registered one |
oauth_pkce_required | 400 | code_challenge_method != S256 or missing |
oauth_refresh_token_reused | 401 | Old refresh token presented — whole grant has been revoked |
oauth_state_invalid | 400 | state mismatch on Google callback (CSRF defense) |
oauth_provider_error | 502 | Google / external IDP returned an error |
oauth_not_configured | 501 | Server has no Google client credentials |
Wallet / SIWE
| Code | Status | When |
|---|---|---|
wallet_address_invalid | 400 | Not a valid 0x… EVM address |
wallet_nonce_invalid | 401 | Nonce unknown, consumed, or doesn’t match this address |
wallet_signature_invalid | 401 | Signature didn’t recover to the expected address |
Gateway / capabilities
| Code | Status | When |
|---|---|---|
capability_not_found | 404 | Slug not registered, or disabled |
capability_disabled | 422 | Found but status != 'active' |
payment_required | 402 | Caller didn’t include payment credentials at all |
insufficient_balance | 402 | Wallet doesn’t have enough available KES |
currency_mismatch | 400 | Topup/transfer/call currency doesn’t match wallet currency |
upstream_error | 502 | Capability provider’s upstream returned an error or unreachable |
Spending controls (rejection from the gateway)
All return 402 with X-LAP-Rejection-Reason: <code>. These trip pre-flight, before any wallet hold.
| Code | When |
|---|---|
daily_limit_exceeded | Today’s spend plus this call would exceed the daily cap |
transaction_limit_exceeded | Single-call price exceeds per-transaction cap |
capability_blocked | Slug is on the block-list |
capability_not_allowed | Slug is not on the allow-list (which is set + non-empty) |
escalation_required | Price exceeds the escalation threshold; agent must request approval first |
Approvals
| Code | Status | When |
|---|---|---|
approval_required | 402 | Capability call needs an approval; one is being requested |
approval_denied | 403 | Approval was decided deny by the user |
approval_expired | 410 | Approval TTL elapsed before use |
approval_used | 409 | Approval already consumed by a prior execute |
approval_mismatch | 400 | Approval slug/amount/currency doesn’t match this call |
approval_signature_invalid | 401 | Wallet signature didn’t verify |
approval_wallet_not_linked | 403 | Signature recovered to an address not linked to the account |
Intent mandates
| Code | Status | When |
|---|---|---|
mandate_not_open | 409 | Mandate is fulfilled / cancelled / exhausted / expired |
mandate_expired | 410 | Past expires_at (lazy-flipped on read) |
mandate_exhausted | 409 | Either fulfillment count maxed or total budget consumed |
mandate_amount_exceeded | 402 | This fulfillment exceeds max_amount_per_fulfillment |
mandate_slug_not_allowed | 402 | Slug doesn’t match any capability_slug_patterns |
mandate_merchant_not_allowed | 402 | First slug segment isn’t in allowed_merchants |
mandate_currency_mismatch | 400 | Fulfillment currency ≠ mandate currency |
mandate_signature_invalid | 401 | Signature didn’t verify against any linked wallet |
mandate_wallet_not_linked | 403 | Signer address isn’t linked to the account |
Provider / settlement
| Code | Status | When |
|---|---|---|
provider_error | 502 | Generic payment rail or upstream provider error |
provider_auth_failed | 502 | Provider rejected Lipafy’s API key |
provider_service_balance_low | 502 | Provider’s service wallet can’t pay fees |
provider_unavailable | 502 | Network / provider downtime |
provider_rate_limited | 429 | Provider has thrown 429 back at us |
provider_temporarily_blocked | 502 | M-Pesa STK temp ban etc. |
provider_kyc_required | 502 | Provider says KYC is incomplete |
invalid_phone | 400 | Phone number didn’t normalize |
webhook_signature_invalid | 401 | Internal payment callback verification failed |
webhook_replay | 200 | Idempotent re-delivery — no further action |
Generic
| Code | Status | When |
|---|---|---|
invalid_request | 400 | Request shape didn’t validate (Zod errors land here) |
not_found | 404 | The referenced resource doesn’t exist or isn’t visible to you |
conflict | 409 | State conflict (duplicate email, double-consume, etc.) |
rate_limited | 429 | You’ve hit our per-key rate limit |
internal_error | 500 | Something we didn’t anticipate |
