Error Envelope
v2 error envelope (DEFAULT since quickfix-B) - full schema, error codes, request IDs, and v1 legacy migration.
Every non-2xx response from the CodeCourier API uses a structured JSON envelope. Clients can switch on error.code for reliable programmatic handling and surface error.message to humans.
v2 is the default as of quickfix-B. Callers receive the structured envelope automatically - no header opt-in is required. The legacy v1 string-only shape remains available for the deprecation window via the X-API-Version: 1 request header and is scheduled for removal on 2026-10-24.
v2 Envelope (default)
Every error response carries the following shape. Source of truth: convex/lib/apiResponse.ts (respondError).
{
"error": {
"code": "VALIDATION",
"message": "Invalid request arguments",
"field": "workflowId",
"requestId": "5b2c1f0a-8e7d-4a4f-bb6d-f0a3c8a1e7e2",
"retryable": false
}
}code- stable machine-readable identifier from theErrorCodeunion. Never translated, never renamed.message- human-readable description in English. Safe to display; safe to localize on the client usingcodeas the i18n key.field- optional. Present onVALIDATIONerrors to pinpoint the offending request field.requestId- server-generated UUID. Echoed in theX-Request-IDresponse header on every response (success and error). Always log it; quote it in support tickets.retryable- optional. Server-side hint that the caller should retry with backoff. Defaults totruefor 5xx and absent otherwise.
Error Code Taxonomy
The ErrorCode union is exported from convex/lib/apiResponse.ts. Codes are SCREAMING_SNAKE_CASE and mapped to HTTP status codes by classifyError().
400 - Client Validation
VALIDATION- request body / query / path failed schema validation.fieldpinpoints the offending key.
401 - Authentication
AUTH_MISSING- noAuthorizationheader was supplied.AUTH_INVALID- bearer token is malformed, unknown, or revoked.
403 - Authorization
FORBIDDEN- caller is authenticated but cannot access the resource (project mismatch, membership missing, etc.).SCOPE_DENIED- API key lacks the required scope. Add the scope in the dashboard, then retry.
404 - Not Found
NOT_FOUND- resource does not exist or has been deleted.
409 - Conflict
CONFLICT- generic optimistic-concurrency / state conflict. Re-fetch and retry.IDEMPOTENCY_MISMATCH- sameIdempotency-Keyreused with a different body. See Idempotency.IDEMPOTENCY_IN_PROGRESS- same key, original request is still in flight. Retry after a short delay.
500 / 501 - Server
INTERNAL- unexpected failure. QuoterequestIdin your support ticket. Markedretryable: true.NOT_IMPLEMENTED- endpoint is reserved but not yet implemented.
v1 Legacy Envelope (opt-out)
Clients on the deprecation track can request the old shape by sending the X-API-Version: 1 header. The body is a plain string:
// Request:
// GET /api/v1/workflows/get?id=missing
// Authorization: Bearer cc_live_...
// X-API-Version: 1
//
// Response:
// HTTP/2 404
// X-Request-ID: 5b2c1f0a-8e7d-4a4f-bb6d-f0a3c8a1e7e2
{
"error": "Workflow 'missing' does not exist."
}v1 responses still carry the X-Request-ID header (added in quickfix-A) so operators can correlate failures even when clients have not migrated.
Sunset: 2026-10-24. After that date the X-API-Version: 1 header becomes a no-op and all callers receive the v2 envelope.
Migration
If your client expects v1, send the X-API-Version: 1 request header until you can switch to v2. To migrate:
- Drop the
X-API-Versionheader so the server emits v2 by default. - Replace string parsing with structural parsing on
error.code. - Surface
error.requestId(or theX-Request-IDresponse header) in your logs and error UI. - Honor
error.retryablein retry policies. Treat absence asfalsefor 4xx andtruefor 5xx.
Handling Errors
curl (v2 default)
curl -i https://<your-deployment>.convex.site/api/v1/runs/start \
-H "Authorization: Bearer cc_live_..." \
-H "Content-Type: application/json" \
-d '{"workflowId": "missing"}'
# HTTP/2 404
# X-Request-ID: 5b2c1f0a-8e7d-4a4f-bb6d-f0a3c8a1e7e2
# {
# "error": {
# "code": "NOT_FOUND",
# "message": "Workflow 'missing' does not exist.",
# "requestId": "5b2c1f0a-8e7d-4a4f-bb6d-f0a3c8a1e7e2"
# }
# }TypeScript
type ErrorCode =
| "AUTH_INVALID"
| "AUTH_MISSING"
| "FORBIDDEN"
| "SCOPE_DENIED"
| "NOT_FOUND"
| "VALIDATION"
| "CONFLICT"
| "INTERNAL"
| "NOT_IMPLEMENTED"
| "IDEMPOTENCY_MISMATCH"
| "IDEMPOTENCY_IN_PROGRESS";
type ApiError = {
error: {
code: ErrorCode;
message: string;
field?: string;
requestId: string;
retryable?: boolean;
};
};
async function call<T>(url: string, init: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (res.ok) return (await res.json()) as T;
const body = (await res.json()) as ApiError;
switch (body.error.code) {
case "SCOPE_DENIED":
throw new Error(`Missing scope. requestId=${body.error.requestId}`);
case "VALIDATION":
throw new Error(`Bad field: ${body.error.field ?? "<unknown>"}`);
case "IDEMPOTENCY_MISMATCH":
throw new Error("Use a fresh Idempotency-Key.");
case "INTERNAL":
// body.error.retryable === true - retry with backoff.
throw new Error(`Server error. requestId=${body.error.requestId}`);
default:
throw new Error(`${body.error.code}: ${body.error.message}`);
}
}Python
import requests
class ApiError(Exception):
def __init__(self, code, message, request_id, field=None, retryable=None):
super().__init__(f"{code}: {message} [requestId={request_id}]")
self.code = code
self.request_id = request_id
self.field = field
self.retryable = retryable
def call(method, url, **kwargs):
r = requests.request(method, url, timeout=30, **kwargs)
if r.ok:
return r.json()
body = r.json().get("error", {})
raise ApiError(
code=body.get("code", "UNKNOWN"),
message=body.get("message", r.text),
request_id=body.get("requestId") or r.headers.get("X-Request-ID", "<none>"),
field=body.get("field"),
retryable=body.get("retryable"),
)Localization
Because error.code is stable, client apps can look up translated messages keyed by code:
const messages = {
de: { SCOPE_DENIED: "Dem API-Schlüssel fehlt ein erforderlicher Scope." },
en: { SCOPE_DENIED: "API key is missing a required scope." },
};
const display = messages[userLocale][err.code] ?? err.message;Request IDs
The X-Request-ID header is emitted on every response (success and error, v1 and v2). The v2 body additionally surfaces the same value as error.requestId (or data.requestId on successful responses) so client SDKs can correlate without parsing headers. Always log the ID; always include it in support tickets.