Skip to content

Quickstart

This is the shortest correct path from a credential to a running underwrite, for a third party integrating against the HTTP API without ever opening the web app. The authoritative contract is the OpenAPI document; this page is the orientation that makes it navigable. Everything here is industry-neutral — the underwriting domain (what a case is, which documents it needs) is carried by data you supply and by the vertical parameter, never by the API’s shape.


  • Every route lives under /v1 (e.g. https://api.<deployment-host>/v1/scenarios).
  • Additive changes (new fields, new endpoints, new output enum values) ship without a version bump; clients MUST ignore unknown response fields. A breaking change would be a whole-surface /v2.
  • Nothing is removed or renamed inside /v1 from 1.0.0 on — not a path, an operation, a response field, an enum member you already receive, or an error type URN. Also tolerate what you do not recognize: an unfamiliar enum member or error type (branch on the HTTP status when the URN is unknown), and never depend on a field being absent. The whole promise, including the small operator-only surface it does not cover, is the compatibility promise.
  • All bodies are JSON. Money and exact decimals are strings ("123.45") or integer micro-units where a field says so (*_micro); never JSON floats. Timestamps are RFC 3339 UTC (2026-08-27T14:03:07Z).

Authentication is a bearer token in the Authorization header. There are two kinds of credential; an integration usually wants the second.

Credential Header Who mints it
User JWT Authorization: Bearer <jwt> the sign-in service this deployment runs against (OAuth 2.0)
API key Authorization: Bearer ug_live_… (or ug_test_…) a tenant admin, on Admin → Developers in the app (which mints ug_live_ keys only), or POST /v1/api-keys (which also accepts environment: test)

For a machine integration acting as a user, obtain an OAuth 2.0 access token by the client-credentials flow from the deployment’s configured sign-in service, and send it verbatim. Three rules the API enforces and integrators trip on:

  1. Send the ACCESS token, not the ID token. The API refuses tokens carrying ID-token markers (at_hash, nonce). It reads exactly three claims: sub (who you are), iss (must be this deployment’s issuer), and — once your tenant is bound to an identity organization — the organization the subject belongs to.

  2. Always include this scope, exactly:

    urn:zitadel:iam:user:resourceowner

    It is a constant — the same literal string for every caller of every deployment. There is nothing to substitute into it and nothing issued to you. It asks the sign-in service to state which organization your subject already belongs to, which the API cross-checks against your tenant. Send it from your first request; it is required once your tenant is bound to an organization and harmless before that. Do not confuse it with urn:zitadel:iam:org:id:{organization_id}, which only pins the login screen branding and is not the scope the API checks.

  3. API keys are refused on human-only surfaces. Membership operations (invitations, the user roster) and the decision-review / override surface are never reachable by an API key at any scope — those are acts of a person, recorded as such.

Every authentication failure is one indistinguishable 401 (urn:ug:error:unauthorized) with a WWW-Authenticate: Bearer header and the same body — an expired token, a bad signature, an unknown subject, and a wrong organization are deliberately not told apart, so the API cannot be used to probe them. The request_id in the body is what support correlates against the server log that does say. If previously-working tokens start failing and nothing else changed, check the scope in rule 2 first.

Every error is application/problem+json (RFC 9457). The machine-readable contract is type, a stable urn:ug:error:<code> URN; branch on it, not on the human detail.

{
"type": "urn:ug:error:validation",
"title": "Request validation failed",
"status": 422,
"detail": "limit number must be at most 200",
"instance": "/v1/scenarios",
"request_id": "0198c5f2-1c2e-7c9b-9f10-2a3b4c5d6e7f",
"errors": [ { "pointer": "/limit", "message": "number must be at most 200" } ]
}

errors[] is present when the request broke the declared SCHEMA — an unknown enum member, a value of the wrong type, a missing required property, a limit above its maximum — one entry per violation, each with a JSON Pointer (/limit for a parameter, /questions/3/criticality into a body). It is absent on a 422 that a domain rule rejected rather than the schema: those carry the same type and a detail naming the field, with no pointer list. So read errors[] when it is there and fall back to detail when it is not; never require it.

The codes you will meet most: unauthorized (401), forbidden (403, valid credential but not allowed — re-authenticating will not help), not_found (404, absent or another tenant’s — indistinguishable by design), validation (422, well-formed but invalid; carries errors[] with JSON Pointers), conflict (409), idempotency_conflict (409, see §5), rate_limited (429) and unavailable (503) — both carry Retry-After. The full taxonomy is in Conventions. request_id is always present and matches the X-Request-Id response header.

List endpoints are cursor-paginated: send ?limit= (default 50, max 200) and ?cursor=, and read next_cursor from the response — null (or absent) means the last page. Feed next_cursor back as cursor.

{ "items": [ ], "next_cursor": "b3B..." }

A few operator/reference lists are bounded instead — they return every row in one response and carry no cursor (e.g. a tenant’s billing contracts, the file-type registry, the vertical catalog, open billing reconciliation findings). Each such endpoint says so in its description; a limit there is a safety cap, not a page size.

Several resources carry a vertical — a case, a question set, a workspace’s default binding. It is a machine value, and it is absent when the thing is on the shipped default. Read GET /v1/verticals once and join against it for a label: exactly one catalog entry omits its key, and that is the entry an absent vertical means, so the comparison works in both directions with no special case. Never print the raw value — that is the one thing the catalog exists to prevent.

Two independent mechanisms make retries safe:

  • Idempotency-Key header on work-creating POSTs (scenario create, evaluate, document finalize, override, …): a client-generated string, ≤255 chars, unique per operation. A replay with the same key and the same body returns the stored response and status; the same key with a different body is 409 urn:ug:error:idempotency_conflict. Keys expire after 24h.
  • Convergent operations need no key because the resource’s identity is the key. POST /v1/tenants is convergent on slug: the first call creates the tenant and returns 201; an identical re-run returns the existing tenant unchanged with 200. So provisioning is safe to run repeatedly (this is exactly what the operator seed tool relies on). POST /v1/tenants/{tenantId}/invitations — the step that authorizes the new workspace’s first administrator — converges the same way: at most one pending invitation per address exists however many times you post it, so an onboarding script is re-runnable end to end.

Four calls: authenticate, create, read back, and see an error. Replace $HOST and $TOKEN.

(a) Authenticate — get an access token with the constant scope, then use it as $TOKEN. Confirm it is accepted with a cheap read of your own tenant:

Terminal window
curl -s https://$HOST/v1/tenants/me \
-H "Authorization: Bearer $TOKEN"
# 200 → your tenant + your identity context.
# 401 with WWW-Authenticate: Bearer → re-check the token and the §2 scope.

(b) Create a scenario — the container an underwrite runs against. name is the only required field; the Idempotency-Key makes the create safe to retry:

Terminal window
curl -s -X POST https://$HOST/v1/scenarios \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f9619ff-8b86-d011-b42d-00c04fc964ff" \
-d '{ "name": "Q3 diligence — Riverbend", "external_ref": "CASE-2026-0417" }'
# 201 → { "id": "…", "status": "created", … }. Keep the id as $SCENARIO.

(c) Read it back — every resource carries id, and list items are the same shape as the single-get object (no abbreviated variants):

Terminal window
curl -s https://$HOST/v1/scenarios/$SCENARIO \
-H "Authorization: Bearer $TOKEN"

(d) Provoke — and read — an error — send an invalid body and inspect the problem:

Terminal window
curl -s -X POST https://$HOST/v1/scenarios \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": " " }'
# 422 application/problem+json, type urn:ug:error:validation, detail
# "name must contain a non-whitespace character" — and NO `errors[]`, because a
# whitespace-only name is a domain rule, not a schema violation (§3). Branch on
# `type`, show `detail`, keep `request_id`.

For the other half of §3 — a 422 that DOES carry errors[] — break the schema instead:

Terminal window
curl -s "https://$HOST/v1/scenarios?limit=999" \
-H "Authorization: Bearer $TOKEN"
# 422, errors[0] = { "pointer": "/limit", "message": "number must be at most 200" }.
# ?limit=lots is a different code: 400 urn:ug:error:malformed — it never parsed.

From here to a decision. Attach documents to the scenario with the two-phase upload (POST /v1/scenarios/$SCENARIO/documents returns a presigned URL; you PUT the bytes to it, then POST …/documents/{id}/complete), start the underwrite with POST /v1/scenarios/$SCENARIO/evaluate (which pins a question-set version and returns 202), and follow progress by polling GET /v1/scenarios/$SCENARIO or streaming GET /v1/scenarios/$SCENARIO/events (SSE). The answers, decision, evidence and an optional PDF findings report all hang off the scenario. Their shapes and the full route list are in the OpenAPI document.


Do not put a token or API key in a URL, a log line, or a shared example — credentials belong only in the Authorization header.