Error catalog

Errors are problem-style JSON with a stable type URI. Match on type and status, never on title — titles are human-facing and may be reworded.

Shape

JSON
{
  "type": "https://evonia-gate.annatarhe.com/problems/validation",
  "title": "Request validation failed",
  "status": 400,
  "errors": [{ "path": ["body", "scopes"], "message": "Array must contain at least 1 element(s)" }]
}
FieldPresent
typeAlways
titleAlways
statusAlways
errorsValidation failures only
requestIdErrors raised after the request id is assigned

Problem types

TypeStatusMeaning
.../problems/validation400The request failed schema validation. errors lists the Zod issues
.../problems/http-401401No usable credential
.../problems/http-403403Authenticated, but not permitted
.../problems/http-404404Not found, or not visible to this caller
.../problems/conflict409A unique constraint was violated
.../problems/http-4xx4xxAny other client error
.../problems/internal500Unhandled server error

All are prefixed https://evonia-gate.annatarhe.com.

Symptom to cause

401 Authentication required

  • No X-API-Key header and no session cookie.
  • The API key is malformed. It must match gate_sk_<12>_<43>.
  • The key was revoked, has passed expiresAt, or its service account is disabled.
  • The session expired.

403 A user session is required

You sent a service API key to an endpoint that mints credentials or manages organizations. These require a real user session; there is no scope that unlocks them. See Choose an integration mode.

403 Permission denied

  • A user whose organization role lacks the permission. Check the role matrix.
  • A service account whose key scopes lack the permission string.
  • A service account addressing a project other than its own.

A frequent case: a developer reading a secret by key. That needs config:reveal, which developer does not have.

403 Service account is scoped to another environment

The service account has environmentId set and you addressed a different environment. This is checked before the permission check, so widening scopes will not help — create an unpinned service account or use the right environment.

404 Not found

  • The id is wrong or belongs to another organization.
  • The record is archived and you did not pass ?state=archived or ?state=all.
  • A user with no membership in the owning organization. Gate returns 404 rather than 403 so it does not leak which ids exist.

409 Conflict

A unique constraint was violated, surfaced from Postgres error 23505. Usually:

  • An organization slug or project slug already in use.
  • A duplicate project role slug within the project.
  • A duplicate role binding for the same (roleId, principalType, principalId).

400 on checkout

POST /projects/{projectId}/billing/checkout requires an idempotency-key header of 16–255 characters. It is a header, not a body field.

successUrl and cancelUrl must be on an allow-listed origin — Gate's own, or one of the product origins in src/server/origins.ts.

Things that are not errors

Duplicate usage events. A replay returns 200:

JSON
{ "data": { "accepted": false, "duplicate": true, "id": "..." } }

Not a 409. Stop retrying.

Masked secrets in a list. value: null, masked: true is correct behaviour, not a permission failure. Read the key individually to decrypt it.

A missing social sign-in button. GitHub and Google appear only when the operator configured their client id and secret.

Diagnosing with request ids

Send x-request-id on every call and log it. Gate echoes it on the response and records it on audit rows, so one identifier connects your logs to Gate's.

curl
curl -sSi "https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID" \
  -H "x-api-key: $GATE_API_KEY" \
  -H "x-request-id: $(uuidgen)"

If you did not send one, Gate generated a UUID and returned it in the same header.

Client-side handling

TypeScript
type Problem = {
  type: string;
  title: string;
  status: number;
  requestId?: string;
  errors?: { path: string[]; message: string }[];
};

export async function gate<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`https://evonia-gate.annatarhe.com/api/v1${path}`, {
    ...init,
    headers: {
      "content-type": "application/json",
      "x-api-key": process.env.GATE_API_KEY!,
      "x-request-id": crypto.randomUUID(),
      ...init?.headers,
    },
  });

  const payload = await response.json();
  if (!response.ok) {
    const problem = payload as Problem;
    throw new GateError(
      problem.errors?.[0]?.message ?? problem.title,
      problem.status,
      problem.type,
      problem.requestId,
    );
  }
  return payload.data as T;
}

Retry 500s and network failures with backoff. Never retry a 400, 401, 403, or 409 — the request will fail identically every time.