Errors

Every failure on /api/v1 answers with the same two fields. Switch on code; show message to a human.

json · the shape
{ "error": { "code": "<stable identifier>", "message": "<one sentence: cause and remedy>" } }
The contract
code is stable and safe to branch on. message is written for a person and may be reworded — never parse it. Every message names a cause and a remedy, and every one ends with a period.

The nineteen codes

CodeStatusWhat happened, and what to do
invalid_limit400The limit parameter was not a positive integer. A limit above 200 is not refused — it is clamped to 200, and the applied value comes back in the response.
invalid_cursor400The cursor was not a token we issued. Use a next_cursor verbatim, or omit it to start from the first page.
idempotency_key_required400A write arrived with no Idempotency-Key header. Required on webhook create, ticket create, ticket status, notice and move-in. Each verb has its own sentence (create a ticket / change a ticket's status / record a notice / record a move-in / create a webhook subscription).
invalid_request400 · 422Same code, branch on status and sentence. Webhooks: the Idempotency-Key was longer than 64 characters, or the body is not a valid webhook subscription payload (both 400). List filters: an unknown query key or a bad value is 400; a status outside its Title-Case enum is 422 — not an empty page. Ticket PATCH: cancellation_notes without see_notes is 400; any extra body key is one 422; a cancellation_reason_code outside its seven-value enum is 422; the photo-required-to-close refusal is 422. Occupancy: an external_id or a move-in party field over its length cap is 400 — see field caps below. Per-op sentences are on the reference.
unauthorized401No key, or a header we could not read as Bearer <key>. Carries WWW-Authenticate: Bearer.
key_revoked401The key was real and has been revoked. Stop retrying — ask the administrator for a new one.
insufficient_scope403A valid key whose scope does not cover this route. Retrying will not help; the scope is fixed at creation.
company_inactive403The company's FixGrid account is inactive. Nothing you can fix from your side — they contact FixGrid support.
not_found404Three situations, one code. The path does not exist; a record is not visible to this key — including records belonging to another company; or no webhook subscription with that id is visible to this key. Not a signal that anything was deleted.
method_not_allowed405The path exists but not with that verb. The Allow header lists the ones that do; the message's “Try ….” hint is composed from that list. /tickets is GET and POST; /tickets/{ticket_id} is GET and PATCH; occupancy is POST on the unit; /webhooks is GET and POST; /webhooks/{sub_id} is DELETE.
idempotency_in_progress409An identical request with this Idempotency-Key is still in flight. Wait, then retry the same key — do not mint a new one. Same sentence on webhook create and every record write.
idempotency_mismatch409The key was already used with a different request. Webhook create fingerprints destination_url plus the sorted event set. Ticket create's document sentence says ticket payload; PATCH status, notice and move-in say ticket request. Read the row on the reference.
created_by_missing422The key has no minting administrator on record, so it cannot create a ticket, change a ticket's status, or record occupancy. Mint a new key on the Integrations page. Occupancy uses a sibling sentence: “This key has no minting administrator on record and cannot record occupancy. Mint a new key on the Integrations page.”
invalid_destination_url422The destination failed the URL rule set — https on port 443, to a publicly resolvable host. Nothing was created.
events_empty422The body named no events. events is effectively required — an empty list, or omitting the key entirely, lands here.
events_unknown_key422One or more event keys are not on the allow-list. The live keys are on webhooks.
rate_limited429Too many requests on this key. Wait the seconds in Retry-After, then retry — see rate limits.
internal_error500Our fault. Safe to retry once with backoff; if it persists, contact support with the time and the path.
vault_unavailable503We could not reach the vault that encrypts a signing secret, so we refused to store one. Nothing was created; retry with backoff. Checked before the body is validated, so it can answer a request that is also malformed.
Occupancy field caps are 400, before any write
POST /units/{unit_id}/move-in caps each party's first_name at 80, last_name 80, email 150, phone 20 and external_id 120 characters, and the top-level external_id at 120; POST /units/{unit_id}/notice caps external_id at 120. The numbers are the document's maxLength values. An over-cap value is refused as 400 invalid_request before the Idempotency-Key is claimed — nothing is written and no idempotency row is held. The top-level sentence is “The `external_id` field must be 120 characters or fewer.” The per-party sentence names the field, the index and the cap, e.g. “The `email` field on parties[0] must be 150 characters or fewer.” — read code and status, not the words.
Two sentences in that service dict never appear here
The 422s and the 503 are pulled from webhook_subscription_service.ERROR_SENTENCES at build time, never retyped. It carries six entries and only four reach this API. The other two must not be drawn: subscription_paused refuses a rotation, which exists only on the Integrations page, and the dict's own not_found sentence is the service's — the route answers with its own, which is the one in the table above.

One example per status

400 · invalid_cursor
json
{ "error": { "code": "invalid_cursor", "message": "The `cursor` parameter is not a valid page token. Use the `next_cursor` value from a previous response, or omit it to start from the first page." } }
400 · invalid_request
json
{ "error": { "code": "invalid_request", "message": "The `ticket_number` parameter requires `property_id` -- a ticket number is unique only within one property." } }
401 · key_revoked
json
{ "error": { "code": "key_revoked", "message": "This key was revoked. Create a new key on the Integrations page." } }
403 · company_inactive
json
{ "error": { "code": "company_inactive", "message": "This company's account is inactive. Contact FixGrid support." } }
404 · not_found · the record variant
json
{ "error": { "code": "not_found", "message": "No ticket with that id is visible to this key." } }
405 · method_not_allowed
json
{ "error": { "code": "method_not_allowed", "message": "That method is not allowed on this endpoint. Try GET." } }
409 · idempotency_mismatch
json
{ "error": { "code": "idempotency_mismatch", "message": "This Idempotency-Key was already used with a different destination_url or events. Use a new Idempotency-Key for a different request." } }
422 · created_by_missing
json
{ "error": { "code": "created_by_missing", "message": "This key has no minting administrator on record and cannot create a ticket. Mint a new key on the Integrations page." } }
422 · events_unknown_key
json
{ "error": { "code": "events_unknown_key", "message": "One or more selected events are not on the allow-list." } }
429 · rate_limited
json
{ "error": { "code": "rate_limited", "message": "This key has exceeded its rate limit. Wait the number of seconds in the Retry-After header, then retry." } }
500 · internal_error
json
{ "error": { "code": "internal_error", "message": "The server could not complete that request. Try again or contact FixGrid support." } }
503 · vault_unavailable
json
{ "error": { "code": "vault_unavailable", "message": "FixGrid will not store a signing secret it cannot encrypt." } }

What to retry

Do retryDo not retry
429 · 500 · 503400 · 401 · 403 · 404 · 405 · 422 will answer identically forever. The two 409s split: retry idempotency_in_progress with the same key once the in-flight request finishes; idempotency_mismatch needs a new key, not a retry. Retrying a key_revoked in a loop is how an integration gets noticed for the wrong reason.