Error handling¶
Invora APIs use standard gRPC status codes, transcoded to HTTP status codes on the REST surface. The body is normally a google.rpc.Status: a numeric code, a human-readable message, and an optional details array carrying typed, @type-tagged payloads (most commonly google.rpc.BadRequest for field-level validation errors).
Two failures do not follow that shape — handle them before you parse
- A
503with an empty body — nocode, nomessage, nodetails. Always check the status before parsing. - A
403whosemessageis Arabic-only with an emptydetails. Branch oncode, never onmessage.
Status codes¶
| gRPC code | HTTP | When |
|---|---|---|
OK (0) |
200 | Success |
INVALID_ARGUMENT (3) |
400 | Missing required fields, invalid enum values, malformed input, business-rule validation failure |
UNAUTHENTICATED (16) |
401 | An access token was presented but is invalid or expired |
PERMISSION_DENIED (7) |
403 | Missing scope, wrong tenant, missing plan entitlement — or no Authorization header at all (see below) |
NOT_FOUND (5) |
404 | Document key, party, plan, etc. does not exist |
ALREADY_EXISTS (6) |
409 | Key collision on create |
ABORTED (10) |
409 | concurrencyStamp mismatch — another write happened |
FAILED_PRECONDITION (9) |
400 | Operation invalid for the current state (e.g. editing a frozen document) |
RESOURCE_EXHAUSTED (8) |
429 | Rate or plan limit reached |
UNAVAILABLE (14) |
503 | Upstream/temporary failure (e.g. a tax authority) — retry with backoff |
INTERNAL (13) |
500 | Unexpected server error |
Error body shape¶
A validation failure (INVALID_ARGUMENT) returns field-level detail in a google.rpc.BadRequest:
curl -X POST https://gateway.invora.app/api/v2/documents \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "changes": { "content": { "invoice": {} } } }'
{
"code": 3,
"message": "invalid argument",
"details": [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{ "field": "changes.content", "description": "invoice content is required" }
]
}
]
}
codeis the numeric gRPC status (3=INVALID_ARGUMENT).messageis a short, human-readable summary.details[].fieldViolations[]lists each offendingfield(a path into your request) and adescriptionof what went wrong.
Other detail types may appear (e.g. google.rpc.ErrorInfo for machine-readable reason/domain), all tagged by their @type. Read details by their @type; do not parse the free-text message.
Authentication & authorization errors¶
A missing token and an invalid token are different failures with different statuses. This is the opposite of what most APIs do, and it is the single most common source of a misdiagnosed integration:
| What you sent | Status | Body |
|---|---|---|
No Authorization header at all |
403 PERMISSION_DENIED (7) |
{"code": 7, "message": "المستخدم غير مصرح له", "details": []} |
Authorization: Bearer <garbage / expired> |
401 UNAUTHENTICATED (16) |
{"code": 16, "message": "The presented access token is invalid or expired. Refresh the token or re-authenticate.", "details": []} |
| A valid token whose principal lacks the required permission | 403 PERMISSION_DENIED (7) |
same Arabic body as row 1 |
So a 403 does not prove your credential is under-privileged — first confirm the header was
actually sent (a proxy or SDK that strips it produces row 1, which is indistinguishable from
row 3 by body alone).
PERMISSION_DENIED messages are Arabic-only
The 403 body carries the Arabic string المستخدم غير مصرح له ("User is not authorized")
in every environment and regardless of Accept-Language or x-invora-culture, and its
details array is always empty. There is nothing machine-readable in it beyond code: 7,
and nothing that identifies which permission was missing (deliberately — the failing scope
is logged server-side, not returned). Branch on the numeric code, never on message.
See Authentication for tokens and scopes.
503 with an empty body¶
Not every failure is a google.rpc.Status. A request whose organization cannot be resolved to a
tenant is rejected before the gRPC layer produces a status, and the response has no body at
all:
HTTP/1.1 503 Service Unavailable
Content-Length: 0
server: Kestrel
retry-after: 1
x-correlation-id: 6be3a8a7933b4fa5a505f2160e37ab34
Every documented field — code, message, details — is absent, so a client that parses the
body before checking the status throws on the parse instead of surfacing a 503. Two rules:
- Check the status before parsing the body, on every response.
- Log
x-correlation-id. With no body, it is the only identifier the request has, and it is what support@invora.app needs to trace it.
Retry with backoff (retry-after is advisory and usually optimistic — treat it as a floor).
This class of 503 never partially applies a write, so retrying an idempotent call is safe.
Concurrency¶
A stale concurrencyStamp on a write returns ABORTED (409). Re-read the resource, take the fresh stamp, and retry once. See Entity versioning.
Retry strategy¶
| Error | Retry? | Strategy |
|---|---|---|
503 with an empty body |
Yes | Exponential backoff; keep x-correlation-id. If it persists past a few minutes, contact support with that id — see 503 with an empty body |
UNAVAILABLE (503) |
Yes | Exponential backoff (e.g. 1s, 2s, 4s, 8s) |
RESOURCE_EXHAUSTED (429) |
Yes | Back off and retry after a delay |
ABORTED (409, concurrency) |
Yes | Re-read, take the fresh stamp, retry once |
INTERNAL (500) |
Maybe | Retry once; if it persists, contact support |
INVALID_ARGUMENT, NOT_FOUND, PERMISSION_DENIED, … |
No | Fix the request and resubmit |
Related¶
- Authentication —
UNAUTHENTICATEDvsPERMISSION_DENIED. - Entity versioning —
ABORTEDon concurrency conflicts. - gRPC & JSON transcoding — how gRPC status maps to HTTP and JSON.
- List & filtering and Field masks — common sources of
INVALID_ARGUMENT.