Error Responses
All API errors return a consistent JSON envelope so your code can handle them uniformly regardless of which endpoint produced the error.
Table of Contents
- Error Shape
- Idempotent Replay Header
- Error Code Reference
- Validation Error Details
- Handling Errors in Code
Error Shape
Every error response uses the same structure:
{
"error": {
"code": "error_code_here",
"message": "Human-readable description of what went wrong.",
"request_id": "01JAXBKM3N4P5Q6R7S8T9UVWXY"
}
}
| Field | Type | Description |
|---|---|---|
code | string | Machine-readable error code (see table below) |
message | string | Human-readable description, suitable for logging but not for end users |
request_id | string | Unique ULID for tracing this request |
The request_id is generated for every /flare/v1/* request (see Authentication). Include it when contacting support.
Idempotent Replay Header
When a mutating request (POST, PUT, PATCH, DELETE) is submitted with an Idempotency-Key header that matches a previously completed request, the API replays the original response exactly and sets:
Idempotent-Replay: true
The response body and HTTP status code are identical to the first delivery. This header is the only signal that the response is a replay — not a new execution. If the original request failed with a non-2xx response, the error is not cached and the request will be executed again.
Example: 201 Created replay
For a POST /flare/v1/jobs call replayed with the same Idempotency-Key, the second response repeats the original 201 Created (not 200 OK) and adds the replay header:
HTTP/1.1 201 Created
Content-Type: application/json
Idempotent-Replay: true
{
"id": "job_01J...",
"status": "pending",
"created_at": "2026-04-15T12:34:56Z"
}
The status code (201, 204, etc.) and body are byte-identical to the original delivery — only Idempotent-Replay: true tells you this is a replay.
Error Code Reference
:::tip Error code stability
Error code strings are part of the public API contract. We will not remove
or rename an existing code without a deprecation cycle — see the
API stability and versioning policy for the full deprecation
contract (12-month sunset on the API major, RFC 9745 / RFC 8594 headers,
410 Gone after sunset). Your application code is safe to branch on
error.code values and on the typed exceptions in the SDKs.
HTTP status codes are also stable. Error message strings are NOT
stable — they're human-readable and may be reworded at any time. Never branch
on message content.
:::
Authentication & Authorization (400 / 401 / 403 / 418)
| HTTP Status | Code | When thrown | Fix |
|---|---|---|---|
| 400 | missing_signature | Stripe webhook request has no Stripe-Signature header | Ensure your webhook client sends the Stripe-Signature header |
| 400 | invalid_signature | Stripe/SendGrid webhook signature verification failed, or timestamp is outside the 5-minute replay window | Verify your signing secret matches the dashboard value; check clock skew |
| 400 | invalid_idempotency_key | Idempotency-Key header is not 1–200 characters of [A-Za-z0-9_-] | Use a UUID or random alphanumeric string as your idempotency key |
| 401 | unauthorized | API key missing, malformed, or invalid; or JWT token missing | Include a valid Authorization: Bearer <key> header |
| 401 | invalid_credentials | Login attempt with wrong email or password | Check credentials and retry; use forgot-password if needed |
| 403 | forbidden | Authenticated user is not the project owner/admin | Use a credential that belongs to the project owner |
| 403 | email_not_verified | Login attempted before the account email was verified | Check your inbox and click the verification link |
| 403 | account_suspended | Account marked SuspendedAt by ops (returned by login before BCrypt for timing safety) | Contact support to lift the suspension |
| 403 | cannot_modify_owner | Attempt to PATCH or remove the project owner via the team-management endpoints | Transfer ownership to another member first, then modify |
| 403 | cannot_remove_owner | Attempt to DELETE the project owner from the membership list | Transfer ownership first, then delete the user from the team |
| 403 | role_exceeds_caller | Tried to assign a role higher than the caller's own (e.g. an admin attempting to make someone owner) | Have the project owner perform the role change, or pick a role at or below the caller's rank |
| 418 | security_notices_required | Sign-up was attempted without the required terms-and-security checkbox checked | Re-submit signup with terms_accepted_at set to the current timestamp |
Not Found (404)
| HTTP Status | Code | When thrown | Fix |
|---|---|---|---|
| 404 | job_not_found | Job ID does not exist or belongs to a different project | Verify the job ID and that your API key belongs to the same project |
| 404 | recurring_job_not_found | Recurring job ID does not exist for this project | Verify the recurring job ID |
| 404 | project_not_found | Project ID does not exist or belongs to a different account | Verify the project ID |
| 404 | account_not_found | Authenticated user has no associated account | Contact support if you believe this is incorrect |
| 404 | alert_not_found | Alert ID does not exist for this project | Verify the alert ID |
| 404 | member_not_found | Project member ID does not exist for this project | Verify the member ID and project context |
| 404 | user_not_found | User ID does not resolve to an account (typically on GET /platform/v1/auth/me after a deletion race) | Re-authenticate; if persistent, contact support |
Conflict (409)
| HTTP Status | Code | When thrown | Fix |
|---|---|---|---|
| 409 | idempotency_key_reuse | An idempotency key (Idempotency-Key header or the legacy body-field idempotency_key on POST /jobs) was reused with a different request body. Same-body replays return the original status code instead of 409 — this code only fires on body mismatch. (The older idempotency_conflict code was unified into this one and is no longer emitted; see Idempotency guide.) | Use a new unique key for each logically distinct request |
| 409 | last_project | Attempted to delete a project that's the user's only one | Create another project first, or use DELETE /platform/v1/account to delete the entire account |
| 409 | invalid_state | Cancel or retry called on a job whose current state does not allow it | Check the job's status before attempting a state transition |
| 409 | parent_terminal | Continuation job submitted but the parent is cancelled or dead_letter | Do not enqueue continuations after a parent reaches a terminal state |
| 409 | recurring_job_type_conflict | A recurring job with the same job_type already exists under a different ID | Fetch the existing recurring job by job_type before upserting |
| 409 | key_revoked | API key rotate called on a project whose key is permanently revoked | Create a new project; revoked keys cannot be reinstated |
| 409 | already_revoked | Revoke called on a project whose API key is already revoked | No action needed — the key is already permanently revoked |
| 409 | email_already_registered | Sign-up attempted with an email address that already has an account | Use the login endpoint or forgot-password flow |
Quota & Rate Limiting (402 / 429)
| HTTP Status | Code | When thrown | Fix |
|---|---|---|---|
| 402 | billing_state_blocked | Project is in past_due, unpaid, or cancelled billing state | Resolve the outstanding invoice or reactivate the subscription via the dashboard billing portal |
| — | quota_exceeded | Not currently emitted. Export endpoints (POST /flare/v1/jobs/export, GET /platform/v1/projects/{id}/audit-log/export) return 429 rate_limit_exceeded instead when their 1-per-minute-per-tenant cooldown is in effect; honour the accompanying Retry-After header | n/a — branch on rate_limit_exceeded from the Quota & Rate Limiting row below |
| 429 | monthly_allowance_exceeded | Monthly job allowance is exhausted and opt-in overage is disabled | Wait for the billing period to reset, enable overage, or upgrade your plan. Check X-Quota-Reset for the period-end epoch |
| 429 | spend_cap_reached | Monthly allowance is exhausted, overage is enabled, but the customer-set spend cap has been reached | Raise the spend cap, wait for the billing period to reset, or upgrade your plan |
| 429 | rate_limit_exceeded | Per-project hourly request rate limit exceeded; OR per-IP auth-endpoint limit (20 failed login attempts per IP within a 15-minute sliding window, checked before password verification) exceeded for /platform/v1/auth/* POSTs; OR per-tenant export cooldown (1-per-minute on POST /flare/v1/jobs/export and GET /platform/v1/projects/{id}/audit-log/export) | Back off until the Retry-After header value (seconds) elapses |
| 429 | account_locked | Per-account failed-login limit (10 attempts / 15 min) was hit; checked before BCrypt for timing safety | Wait for Retry-After seconds before retrying; reset password if you've forgotten it |
Quota response headers (present on every POST /flare/v1/jobs response):
| Header | Description |
|---|---|
X-Quota-Limit | Monthly job allowance for this project |
X-Quota-Used | Jobs created this billing period (pre-check; does not count replays) |
X-Quota-Overage | Jobs created beyond the allowance this period |
X-Quota-Overage-Cost | Accrued overage charge this period, in cents |
X-Quota-Spend-Cap | Overage spend cap in cents (omitted when no cap is set) |
X-Quota-Reset | Unix epoch (seconds) of the billing-period end |
Validation (400 / 422)
| HTTP Status | Code | When thrown | Fix |
|---|---|---|---|
| 400 | invalid_request | Request body failed validation — missing required fields or out-of-range values | See Validation Error Details below |
| 400 | invalid_cron_expression | Cron expression could not be parsed | Use a standard 5- or 6-part cron expression |
| 400 | invalid_timezone | Timezone string is not a valid IANA identifier | Use a tz database name such as America/New_York |
| 400 | invalid_locale | Locale string is not a recognised BCP-47 tag | Use a tag like en-US, de-DE, fr-CA |
| 400 | invalid_limit | limit query param was outside the endpoint's allowed range (e.g. invoices: 1–100). No silent clamping. | Pass an explicit value within the documented range |
| 400 | invalid_format | format query param on an export endpoint was not jsonl or csv | Use format=jsonl (default) or format=csv |
| 400 | invalid_from/invalid_until | Export window param was missing or not a valid ISO-8601 timestamp | Pass UTC timestamps, e.g. 2026-04-01T00:00:00Z |
| 400 | invalid_range | Export from was not strictly before until | Reorder the params |
| 400 | range_too_large | Export window exceeded 90 days | Split into multiple smaller exports |
| 400 | invalid_verification_token | Email verification attempted with a bad or expired token | Request a new verification email |
| 400 | invalid_reset_token | Password reset attempted with a bad, expired, or already-used token | Start the forgot-password flow again to get a fresh token |
| 400 | confirm_required | DELETE /platform/v1/account was missing the ?confirm=<email> param, or it didn't match the authenticated email | Pass ?confirm=<your-account-email> exactly |
| 422 | parent_not_found | parent_job_id does not reference an existing job | Verify the parent job ID before submitting a continuation |
| 422 | invalid_role | Team-invite or role-update body specified a role outside owner/admin/member/viewer | Pick one of the four supported roles |
| 422 | invalid_email | Email address was malformed (signup, invite) | Use an RFC-5322-valid email |
| 422 | invalid_token | A generic single-use token (invite acceptance, etc.) was bad, expired, or already consumed | Request a fresh token from whoever issued it |
Server & Integration (500 / 503)
| HTTP Status | Code | When thrown | Fix |
|---|---|---|---|
| 500 | internal_error | Unhandled server error | Safe to retry with exponential backoff; include request_id when contacting support |
| 503 | billing_disabled | Billing endpoint called but Stripe is not configured for this environment | Contact your account manager or use the production environment |
| 503 | stripe_unavailable | Stripe API call failed with StripeException (Stripe-side outage). The response distinguishes a Stripe-side outage from a Zeridion-side server error so you can retry against Stripe's status page. | Retry after a short backoff; check status.stripe.com if the error persists |
Validation Error Details
When validation fails, the API returns 400 with code: "invalid_request" and the message contains the first validation failure.
Create Job Validation
| Field | Rule | Error message |
|---|---|---|
job_type | Required, non-empty | "job_type is required." |
job_type | Max 500 characters | "job_type must not exceed 500 characters." |
payload | Required, non-null JSON | "payload is required." |
max_attempts | 1–100 (when provided) | "max_attempts must be between 1 and 100." |
timeout_seconds | 1–86,400 (when provided) | "timeout_seconds must be between 1 and 86400." |
queue | Max 100 characters (when provided) | "queue must not exceed 100 characters." |
idempotency_key | Max 200 characters (when provided) | "idempotency_key must not exceed 200 characters." |
parent_job_id | Max 36 characters (when provided) | "parent_job_id must not exceed 36 characters." |
Example validation error
{
"error": {
"code": "invalid_request",
"message": "job_type is required.",
"request_id": "01JAXBKM3N4P5Q6R7S8T9UVWXY"
}
}
Handling Errors in Code
The error envelope is identical across all three SDKs. Each maps the response
to a typed exception class that carries code, message, request_id, and
the HTTP status code, so you can branch on code for programmatic handling
and surface message + request_id in your logs.
.NET / C#
try
{
await jobClient.EnqueueAsync<SendWelcomeEmail>(new { Email = "user@example.com" });
}
catch (FlareAuthenticationException)
{
// 401 — invalid or expired API key
}
catch (FlareRateLimitException ex)
{
// 429 — back off; ex.ResetAt has the window reset epoch
if (ex.ResetAt.HasValue)
{
var delay = ex.ResetAt.Value - DateTimeOffset.UtcNow;
if (delay > TimeSpan.Zero) await Task.Delay(delay);
}
}
catch (FlareConflictException ex) when (ex.ErrorCode == "idempotency_key_reuse")
{
// Same Idempotency-Key reused with a different request body — pick a new key
}
catch (FlareApiException ex)
{
Console.WriteLine($"Error {ex.StatusCode}: {ex.ErrorCode} — {ex.Message}");
}
TypeScript / JavaScript
import {
FlareError,
AuthError,
RateLimitError,
ConflictError,
} from "@zeridion/flare";
try {
await flare.createJob({ job_type: "...", payload: { /* ... */ } });
} catch (err) {
if (err instanceof AuthError) {
// 401 — invalid API key
} else if (err instanceof RateLimitError) {
// 429 — back off; err.retryAfter has the window reset epoch
} else if (err instanceof ConflictError && err.code === "idempotency_key_reuse") {
// Same Idempotency-Key reused with a different request body
} else if (err instanceof FlareError) {
console.error(err.statusCode, err.code, err.requestId, err.message);
}
}
Python
from zeridion_flare import (
FlareError,
AuthError,
RateLimitError,
ConflictError,
)
try:
client.create_job({"job_type": "...", "payload": {}})
except AuthError:
... # 401
except RateLimitError as e:
... # 429 — e.retry_after has the window reset epoch
except ConflictError as e:
if e.code == "idempotency_key_reuse":
... # Same Idempotency-Key reused with a different request body
except FlareError as e:
print(e.status_code, e.code, e.request_id, e.message)
See the SDK Exceptions reference for the full .NET exception hierarchy.
See also
- Rate Limits — how
429is computed and whatRetry-Aftercarries - Error Handling guide — practical patterns for retry, fallback, and dead-letter
- Stability Policy — which error codes are guaranteed not to change