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.
1. Base URL and versioning
Section titled “1. Base URL and versioning”- 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
/v1from1.0.0on — not a path, an operation, a response field, an enum member you already receive, or an errortypeURN. Also tolerate what you do not recognize: an unfamiliar enum member or errortype(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).
2. Get a token
Section titled “2. Get a token”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:
-
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. -
Always include this scope, exactly:
urn:zitadel:iam:user:resourceownerIt 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. -
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.
3. The error shape
Section titled “3. The error shape”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.
4. Pagination
Section titled “4. Pagination”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.
4a. Rendering a vertical
Section titled “4a. Rendering a vertical”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.
5. Idempotency and convergence
Section titled “5. Idempotency and convergence”Two independent mechanisms make retries safe:
Idempotency-Keyheader 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 is409 urn:ug:error:idempotency_conflict. Keys expire after 24h.- Convergent operations need no key because the resource’s identity is the key.
POST /v1/tenantsis convergent onslug: the first call creates the tenant and returns201; an identical re-run returns the existing tenant unchanged with200. So provisioning is safe to run repeatedly (this is exactly what the operatorseedtool 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.
6. A worked example
Section titled “6. A worked example”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:
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:
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):
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:
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:
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.