Error Envelope

v2 error envelope (DEFAULT since quickfix-B) - full schema, error codes, request IDs, and v1 legacy migration.

7 min read
errorserror-envelopeerror-codes

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 the ErrorCode union. Never translated, never renamed.
  • message - human-readable description in English. Safe to display; safe to localize on the client using code as the i18n key.
  • field - optional. Present on VALIDATION errors to pinpoint the offending request field.
  • requestId - server-generated UUID. Echoed in the X-Request-ID response 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 to true for 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. field pinpoints the offending key.

401 - Authentication

  • AUTH_MISSING - no Authorization header 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 - same Idempotency-Key reused 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. Quote requestId in your support ticket. Marked retryable: 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:

  1. Drop the X-API-Version header so the server emits v2 by default.
  2. Replace string parsing with structural parsing on error.code.
  3. Surface error.requestId (or the X-Request-ID response header) in your logs and error UI.
  4. Honor error.retryable in retry policies. Treat absence as false for 4xx and true for 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.

Related