Forz API v2 — Error Code Catalog

Every 4xx and 5xx response from /api/v2/* returns an application/problem+json response body conforming to RFC 9457. The code field is a stable, machine-readable string that you can switch on in integration code. code strings follow this format: <domain>.<verb_or_state>, lowercase, dot-separated, with snake_case within segments (regex: ^[a-z_]+(\.[a-z_]+)+$).

Error codes are SemVer-stable across API versions. Renames and removals are breaking changes (we don't make them); additions are non-breaking (new codes appear in this catalog without warning).

Envelope

{
  "type": "https://forz.io/api/errors/auth.missing_token",
  "title": "Missing API key in Authorization header",
  "status": 401,
  "code": "auth.missing_token",
  "detail": "Optional longer description with context (null when no extra context)",
  "instance": "/api/v2/customers",
  "request_id": "req_018f2a4c4d8e7c5e6b9d1f3a8c0e5d7f",
  "doc_url": "https://forz.io/api/errors/auth-missing-token"
}

The type field is a stable URN-shaped identifier (dotted form). The doc_url field uses the dash-form anchor that resolves to a markdown heading in this document — both work; integrators typically grep on code and present doc_url as a clickable link to humans.

Some codes carry additional top-level fields, next to the eight above, documented per code below.


Code Index


auth.missing_token

HTTP status: 401 Unauthorized Title: "Missing API key in Authorization header"

The request did not include an Authorization: Bearer fz_<token> header. Every V2 endpoint requires authentication.

Resolution: Include the API key in the request header:

Authorization: Bearer fz_018f2a4c-4d8e-7c5e-6b9d-1f3a8c0e5d7f

auth.invalid_token

HTTP status: 401 Unauthorized Title: "Invalid or expired API key"

The presented Bearer token did not match any active, unexpired API key on the account. Possible causes: typo, a key that was never issued, or key expired (expires_at < now). A revoked key gets auth.token_revoked instead.

Resolution: Generate a fresh key from /settings/api_keys or check expiry status.


auth.failed

HTTP status: 401 Unauthorized Title: "Authentication failed"

A non-recoverable error occurred while attempting to verify the presented Bearer token. The error has been logged on the server with the request_id. Re-issue the request after a brief delay; if the error persists, contact support with the request_id.


auth.token_revoked

HTTP status: 401 Unauthorized Title: "API key has been revoked"

The presented Bearer token was issued by Forz but has since been revoked or rotated from /settings/api_keys (rotation revokes the previous key). Subsequent requests with the same token will continue to receive this code until a fresh key is issued.

Resolution: Generate a new key from /settings/api_keys and re-issue the request.


auth.scope_missing

HTTP status: 403 Forbidden Title: "Authorization scope missing"

The API key authenticated successfully but lacks the scope required for this endpoint. The response body's required_scope field names the missing scope (e.g. customers:write); granted_scopes lists the scopes the key DOES carry.

Note: <resource>:write implicitly grants <resource>:read for the same resource — you do not need to grant both. Cross-resource scope grants do NOT imply each other (e.g. jobs:write does NOT grant customers:read). See the scope registry for the full catalog of scopes.

Resolution: Re-issue the API key from /settings/api_keys with the additional scope checked.


auth.permission_denied

HTTP status: 403 Forbidden Title: "Permission denied"

The API key authenticated and carries the required scope, but the user the key belongs to is not allowed to perform this action. An API key never grants more than its user has in the web app: the resource policy checks the user's role permissions (Settings → Custom Roles) and the account's enabled modules. Typical causes: a Staff-role user's key calling /leads or /deals (the default Staff role has no Lead/Deal permissions), or the Leads/Deals module being disabled for the account.

Resolution: Grant the key's user the missing permission (or issue the key from an Admin user), and make sure the module is enabled for the account. Re-issuing the key with more scopes will not help.


auth.plan_required

HTTP status: 403 Forbidden Title: "API V2 access not enabled for this plan"

The Forz account is on a plan tier that does not include API access. Sandbox tenants are exempt (sandbox API is free indefinitely).

Resolution: Contact support to upgrade to a plan that includes API access.


account.deactivated

HTTP status: 403 Forbidden Title: "Account deactivated"

The Forz account associated with this API key has been deactivated. Response body's reason field carries an optional reason string from internal deactivation tooling (e.g. admin_decision, payment_failure).

Resolution: Contact support to reactivate the account.

Code rename note (2026-04): This code was previously emitted as account_deactivated in early Phase 1.0. The dotted form account.deactivated is the canonical D-07 form going forward; partners should switch on the new code.


account.inactive

HTTP status: 403 Forbidden Title: "User is inactive"

The Forz user that owns this API key is marked inactive (typically: removed from the account by an admin, or self-deactivated).

Resolution: A different user with an active account must issue a new key.

Code rename note (2026-04): This code was previously emitted as account_inactive in early Phase 1.0. The dotted form account.inactive is the canonical D-07 form going forward.


resource.not_found

HTTP status: 404 Not Found Title: "Resource not found"

The requested resource does not exist OR exists in a different tenant (the response is uniform — we do not distinguish "doesn't exist" from "not yours" to avoid information disclosure across tenants).

Resolution: Verify the resource ID, the URL path, and that the API key's tenant matches the resource's tenant.

Code rename note (2026-04): This code was previously emitted as not_found in early Phase 1.0. The dotted form resource.not_found is the canonical D-07 form going forward.


precondition.required

HTTP status: 428 Precondition Required Title: "If-Match header required for this update"

A PATCH, PUT or DELETE was attempted on a mutable resource (currently: Jobs, Estimates, Invoices, Sales Orders, Inventory Transfers, plus all 14 V2 mutables per Phase 1) without an If-Match header. We require optimistic concurrency control on these endpoints to prevent silent overwrites under concurrent edits.

Resolution: First GET the resource, copy the ETag response header, then re-issue the request with If-Match: <etag-value>.


precondition.failed

HTTP status: 412 Precondition Failed Title: "Resource was modified by another request"

The If-Match header was present, but its value didn't match the current ETag of the resource. Another request modified the resource between your GET and your PATCH/DELETE.

Resolution: GET the resource again, merge your changes with whatever changed, and retry the PATCH/DELETE with the new If-Match value.


idempotency_key.required

HTTP status: 400 Bad Request Title: "Idempotency-Key header is required for this financial endpoint"

A POST to /v2/invoices, /v2/payments, /v2/sales_orders, or /v2/inventory_transfers was issued without an Idempotency-Key header. Financial endpoints require this header to safely deduplicate retries.

Resolution: Generate a unique key per logical operation (UUID is fine) and include it: Idempotency-Key: 018f2a4c-4d8e-7c5e-6b9d-1f3a8c0e5d7f. Reuse the same key when retrying the same operation; use a new key for a new operation.


idempotency_key.in_use

HTTP status: 409 Conflict Title: "Idempotency key in use"

Either: (a) a request with the same Idempotency-Key is currently in-flight on the server (you sent two near-simultaneous requests with the same key). This response carries Retry-After: 2. OR (b) you sent a request with an Idempotency-Key whose earlier request succeeded within the last 24h, BUT with a different request body (same key, same method and path, different body — we treat this as an error to prevent accidental double-charges with subtly different payloads). This response has no Retry-After and its detail says the key was used with a different request body.

A request that failed (4xx or 5xx) is not cached, so its key can be reused with a corrected body.

Resolution: For (a): wait briefly and retry with the same key — once the in-flight request completes, the next request with that key will replay the cached response. For (b): generate a new Idempotency-Key for the new request body.


cursor.invalid

HTTP status: 400 Bad Request Title: "Invalid pagination cursor"

The cursor query parameter could not be decoded — the value was tampered with, truncated, or generated by a different Forz API instance with a since-rotated signing secret.

Resolution: Restart pagination from the first page (omit the cursor parameter entirely on the first request).


cursor.invalid_filters

HTTP status: 400 Bad Request Title: "Pagination cursor was issued for a different filter set"

The cursor was issued for one filter combination (e.g. ?status=open&customer_id=123) but you've supplied different filter parameters on the follow-up page request. To prevent inconsistent multi-page result sets, we reject the request.

Resolution: Either restart pagination from the first page OR re-issue the request with the original filters. Filter changes mid-traversal require a new pagination session.


cursor.expired

HTTP status: 400 Bad Request Title: "Pagination cursor has expired"

Cursors are valid for 24 hours from issuance. Long-running syncs that take longer than 24 hours per page must restart pagination once a cursor expires.

Resolution: Restart pagination from the first page. For very large datasets, consider using ?modified_since=<timestamp> filtering (Phase 3c export endpoint) to bound the working set.


pagination.limit_too_large

HTTP status: 400 Bad Request Title: "limit exceeds maximum of 100"

The ?limit= query parameter exceeded the per-page maximum (100). Response body's max_limit field confirms the cap.

Resolution: Set ?limit= to a value between 1 and 100 (default: 25).


sort.invalid

HTTP status: 400 Bad Request Title: "Invalid sort parameter"

The ?sort= query parameter named a field the endpoint does not allow sorting by, or requested more than one sort field (only a single field is supported for now). Syntax is ?sort=field (ascending) or ?sort=-field (descending). The detail lists the endpoint's sortable fields.

Resolution: Sort by one of the listed fields, optionally prefixed with - for descending order. Note: when continuing pagination with a ?cursor=, the cursor's original ordering is used and the sort param is ignored.


filter.invalid

HTTP status: 400 Bad Request Title: "Invalid filter parameter"

A query-parameter filter was malformed: an unknown/non-allowlisted field, an operator not permitted for that field, or a value that couldn't be parsed into the column's type. Syntax is ?field=value (equality), ?field[in]=a,b (membership), or ?field[gte]=… (comparison: gt, gte, lt, lte). Any list endpoint also returns this for a query parameter it doesn't support (e.g. a typo like ?stauts=, or ?sort= on a list that isn't sortable) instead of ignoring it. The detail names the offending field/operator/value, or the unknown parameters and the allowed set.

Resolution: Use a documented filter field + operator and a well-formed value (e.g. an ISO-8601 date for date columns). Note: changing filters mid-pagination instead returns cursor.invalid_filters — restart from the first page.


rate_limit.exceeded

HTTP status: 429 Too Many Requests Title: "Rate limit exceeded"

The API key (or, for unauthenticated requests, the source IP) exhausted its bucket for this period. The bucket and window are reflected in the RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset response headers; the Retry-After header gives a recommended back-off time in seconds (with random jitter to avoid thundering-herd effects).

Buckets are: read (1000/min on GET), write (300/min on POST/PATCH/PUT/DELETE), webhook_ingest (60/min — reserved for Phase 2a webhook management).

Resolution: Wait the duration in Retry-After and retry. For chronic rate-limit pressure, contact support to discuss per-tenant overrides or Enterprise plan multipliers.


validation.failed

HTTP status: 422 Unprocessable Content Title: "Validation failed"

The request body was syntactically valid JSON but failed business-rule validation (e.g. required field missing, value out of range, foreign-key mismatch). The top-level errors object maps each field to an array of messages, e.g. {"site_id": ["was not found in this account"]}.

Resolution: Fix the highlighted fields and retry.


validation.no_topics

HTTP status: 422 Unprocessable Content Title: "At least one topic subscription is required"

You tried to create a webhook endpoint with an empty (or all-blank) topics array. An endpoint with zero subscriptions would never receive an event, so the API rejects the request before persisting anything.

Resolution: Include at least one valid topic in the topics array.


status.transition_invalid

HTTP status: 422 Unprocessable Content Title: "Status transition not allowed"

The status you sent is not a live status for this resource type: no status with that exact (case-sensitive) display_name exists for it in your account — never configured, misspelled, or deleted. Forz does not enforce a transition order: any configured status can be set from any other, and re-sending the record's current status always succeeds. detail names the rejected value. (Estimate Sent/Accepted/Declined additionally require the matching user permission; lacking it returns 403 auth.permission_denied.)

Resolution: Use a display_name from GET /api/v2/statuses?related_name=<Resource>.


number.immutable

HTTP status: 422 Unprocessable Content Title: "Number cannot be changed"

A resource's number is assigned at creation — from the value you supply, or auto-generated when you omit it — and is immutable thereafter. A PATCH that submits a different number is refused; re-sending the current value is a no-op and succeeds. Applies to the numbered resources: customers, jobs, invoices, estimates, sales orders, leads, deals, projects, items, tasks.

Resolution: Drop number from the update payload (or send the existing value unchanged). To use a specific number, set it at creation time.


system.duplicate

HTTP status: 409 Conflict Title: "A system of this type already exists on this site"

A system of the same type already exists on the target site. This mirrors the web UI's confirmation dialog (SystemsController#create confirm_duplicate): rather than silently creating a near-identical record, the API treats it as a soft conflict and refuses unless the caller explicitly opts in.

Resolution: Resend the request with "confirmed": true in the body to create the system anyway.


contact.has_linkages

HTTP status: 409 Conflict Title: "Contact is still linked"

The contact is still linked to one or more customers, leads or sites. The web UI refuses the same delete (ContactPolicy#destroy?).

Resolution: Unlink it from every record first (DELETE /api/v2/contacts/{id}/linkages/{linkage_id}), then delete it.


contact.primary_linkage

HTTP status: 409 Conflict Title: "Contact is the record's primary contact"

The linkage you tried to remove makes this contact the primary contact of its record. Each record keeps a primary; the web UI refuses the same unlink (ContactPolicy#unlink?).

Resolution: Make another contact primary on that record (PATCH /api/v2/contacts/{other_id}/linkages/{linkage_id} with "primary": true), then unlink.


internal.error

HTTP status: 500 Internal Server Error Title: "Something went wrong"

An unexpected server error occurred. The error has been logged with request_id and our team has been notified.

Resolution: Retry after a brief delay. If the error persists, contact support and include the request_id from the response body.


topic.not_available

HTTP status: 422 Unprocessable Content Title: "Subscription topic is not available"

The topic you tried to subscribe to is registered in the catalog but is not currently available: true. Pre-Phase-3 partner topics ship with available: false so the registry knows about them at all times; each phase flips its topics on as the emitting models adopt Webhooks::Eventful.

Resolution: Subscribe to one of the topics returned by GET /api/v2/webhooks/available_topics, or wait for the topic to be flipped on in a future API version.


endpoint.disabled

HTTP status: 409 Conflict Title: "Endpoint is disabled"

A delivery replay (or other lifecycle action) was requested against an endpoint whose status is disabled. Disabled endpoints do not receive new deliveries until explicitly re-enabled.

Resolution: Re-enable the endpoint via PATCH /api/v2/webhooks/endpoints/:id (status: "verified" or "unverified") and retry.


endpoint.paused

HTTP status: 409 Conflict Title: "Endpoint is paused"

The endpoint is currently in circuit-breaker cooldown (paused_until is in the future). Forz auto-pauses endpoints after consecutive delivery failures (D-09/D-10) to protect partner infrastructure.

Resolution: Wait for paused_until to elapse, fix the underlying delivery failure, and trigger a test event to clear the cooldown counter.


verification.echo_mismatch

HTTP status: 422 Unprocessable Content Title: "Endpoint did not echo the verification challenge"

During endpoint verification, Forz POSTs a challenge token; the endpoint must echo it back verbatim in the response body. The endpoint responded without echoing the expected token (or echoed a stale value).

Resolution: Implement the verification handshake as documented in the "Webhook endpoint verification" guide and re-trigger verification.


verification.timeout

HTTP status: 422 Unprocessable Content Title: "Endpoint did not respond within the timeout window"

The endpoint took longer than the verification timeout to respond to the challenge POST. Common causes: the endpoint is cold-starting, sitting behind a slow proxy, or running a long synchronous handler.

Resolution: Move verification to a fast path (acknowledge first, work later) and re-trigger verification.


verification.ssl_error

HTTP status: 422 Unprocessable Content Title: "TLS handshake with the endpoint failed"

Forz could not negotiate a valid TLS connection to the endpoint URL. Common causes: an expired or self-signed certificate, missing intermediate chain, or hostname mismatch.

Resolution: Fix the TLS chain on the endpoint (use a public CA, include intermediates) and re-trigger verification.


verification.network_unreachable

HTTP status: 422 Unprocessable Content Title: "Could not reach the endpoint"

DNS resolved but no TCP connection could be established to the endpoint URL — the host refused the connection, the route was unreachable, or a firewall dropped the SYN.

Resolution: Verify the endpoint is publicly reachable on its declared port and re-trigger verification.


verification.unknown_error

HTTP status: 422 Unprocessable Content Title: "Endpoint verification failed"

A non-network, non-protocol error surfaced while attempting to verify the endpoint. Forz logs the request with request_id; the detail field carries a truncated error message.

Resolution: Inspect detail, fix the underlying issue, and re-trigger verification. Contact support with the request_id if the cause is not obvious.


verification.failed

HTTP status: 422 Unprocessable Content Title: "Endpoint verification failed"

Catch-all for verification failures that the verifier could not classify into a more specific code (timeout / ssl_error / network_unreachable / echo_mismatch / unknown_error). Treated as a soft failure — the endpoint remains in unverified status until a successful retry.

Resolution: Re-trigger verification once the underlying issue is resolved.


Adding a new code

  1. Append the code to the frozen Set in app/lib/error_code_catalog.rb.
  2. Add a section here in dictionary order under "Code Index" + a ## heading.
  3. The dev/test guard (ErrorCodeCatalog.assert_known!) catches usage of unknown codes during local runs.
  4. Spec spec/lib/error_code_catalog_spec.rb enforces this catalog↔docs consistency in CI.