Conventions
Everything here holds for every endpoint. Learn it once and the rest of the API is predictable.
Base URL and versioning
Section titled “Base URL and versioning”Every route lives under /v1:
https://api.underwriting.guru/v1/scenariosAll bodies are JSON. Money and exact decimals are strings ("123.45"), or integer
micro-units where a field name says so (*_micro) — never JSON floats, whose rounding you
do not want anywhere near a monetary figure. Timestamps are RFC 3339 in UTC
(2026-08-27T14:03:07Z).
The compatibility promise
Section titled “The compatibility promise”From 1.0.0 on, nothing inside /v1 is removed or renamed — no path, no operation, no
response field, no enum member you already receive, no error type. A build-time check
keeps that true, rather than a policy nobody re-reads.
It will still grow. Expect new endpoints, new optional request fields, new response fields, and new enum members on values sent to you. So write your client to:
- ignore response fields it does not recognise;
- tolerate an unfamiliar enum member, and an unfamiliar error
type— branch on the HTTP status when the identifier means nothing to you; - never depend on a field being absent.
Something we regret gets a replacement beside it and a deprecated marker — visible in your
generated client and in the release notes — not a deletion. A genuinely breaking change
would mint a /v2 alongside, with at least 90 days’ notice.
Two surfaces version on their own clocks and say so on their own pages: the embed
protocol’s envelope version, and the webhook envelope’s schema_version. A /v2 would move
neither.
A small operator-only surface — platform cost and cache figures, and workspace evaluation policy — is outside this promise. It is never reachable by a workspace’s own credentials, so it cannot be part of your integration.
Pagination
Section titled “Pagination”List endpoints are cursor-paginated. Send ?limit= (default 50, maximum 200) and
?cursor=, and read next_cursor back:
{ "items": [], "next_cursor": "b3B..." }next_cursor is null, or absent, on the last page. Feed it back as cursor to get the
next one. A cursor is opaque: do not parse it, and do not hold one across a change of
filters — changing a filter invalidates it, and the server says so with
urn:ug:error:invalid_cursor.
A few lists are bounded instead. Their size is small and fixed by construction — a
workspace’s contracts, the file-type registry, the vertical catalog, the decisions on one
case — so they return every row in one response and carry no cursor at all. Each one says so
in its own description, and where such an endpoint takes a limit, that limit is a safety
cap rather than a page size.
The error taxonomy
Section titled “The error taxonomy”Every error is application/problem+json (RFC 9457):
{ "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" }]}type is the contract. It is a stable identifier; branch on it, never on the
human-readable detail, which can change. title is constant for a given type.
request_id is always present and matches the X-Request-Id response header — quote it to
support and the answer is one log query away.
type |
HTTP | Means |
|---|---|---|
urn:ug:error:malformed |
400 | The request could not be parsed at all |
urn:ug:error:unauthorized |
401 | Missing, expired or invalid credential |
urn:ug:error:forbidden |
403 | Valid credential, insufficient role or scope. Re-authenticating will not help |
urn:ug:error:tenant_suspended |
403 | The workspace is not active |
urn:ug:error:not_admitted |
403 | A real person, in an organization we know, whom nobody has authorised yet |
urn:ug:error:not_found |
404 | Absent, or belonging to another workspace — indistinguishable by design |
urn:ug:error:conflict |
409 | The resource is not in a state that allows this |
urn:ug:error:idempotency_conflict |
409 | Same Idempotency-Key, different body |
urn:ug:error:precondition_failed |
412 | A version precondition was not met |
urn:ug:error:payload_too_large |
413 | Over the upload or body size limit |
urn:ug:error:unsupported_media_type |
415 | Content type not accepted here |
urn:ug:error:validation |
422 | Well formed, but invalid |
urn:ug:error:invalid_cursor |
422 | The cursor is stale, or its filters no longer match |
urn:ug:error:budget_exceeded |
402 | A hard cost budget was reached; the run is paused |
urn:ug:error:rate_limited |
429 | Too many requests. Carries Retry-After |
urn:ug:error:internal |
500 | Unhandled. detail never leaks internals; quote request_id |
urn:ug:error:unavailable |
503 | A dependency is down or draining. Carries Retry-After |
When errors[] is there, and when it is not
Section titled “When errors[] is there, and when it is not”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 over 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. A whitespace-only
name is the classic example: perfectly valid JSON, and still not a name.
So read errors[] when it is there and fall back to detail when it is not. Never require
it.
Two rejections are always malformed (400) rather than validation (422), because nothing
ever parsed: a path or query parameter that will not convert to its declared type
(?limit=lots, a non-UUID id), and a body that is not JSON.
Idempotency
Section titled “Idempotency”Two independent mechanisms make a retry safe.
The Idempotency-Key header on work-creating requests — creating a case, starting a
run, finalising a document, recording an override. Generate a string of at most 255
characters, unique to that 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 24 hours.
Send one whenever the work costs something. A retried run that starts twice spends twice.
Convergent operations need no key, because the resource’s own identity is the key.
Creating a workspace converges on its slug: the first call returns 201, and an identical
re-run returns the existing workspace unchanged with 200. Inviting a workspace’s first
administrator converges the same way — at most one pending invitation per address exists,
however many times you post it. Provisioning scripts are therefore safe to run end to end,
repeatedly.
Rate limits
Section titled “Rate limits”Requests that reach a model on your behalf are metered per workspace, in two classes:
| Class | Shipped default |
|---|---|
| Starts a whole run — evaluating a case, resuming a run, starting a generation or diff run | burst of 10, one more every 30 seconds |
| One unit of work — finalising or re-reading a document, publishing a version, structuring a question | burst of 120, one more every 2 seconds |
Two classes rather than one, because a ceiling sized so that dropping a hundred documents onto a case completes in a single burst is no limit at all on generation.
A refused call is 429 urn:ug:error:rate_limited, carrying both a Retry-After header and
a retry_after_seconds member. Wait that long and retry; do not spin. The limits are
tunable per deployment, so treat the numbers above as the shipped defaults rather than as
constants.
Headers
Section titled “Headers”| Header | Direction | Rule |
|---|---|---|
Authorization |
request | Bearer <credential> on every call |
Idempotency-Key |
request | On work-creating requests. See above |
Content-Type |
request | application/json unless an endpoint says otherwise |
Last-Event-ID |
request | Resumes a server-sent event stream where you left it |
X-Request-Id |
both | Generated if you do not send one, echoed, and in every problem body |
Retry-After |
response | On 429 and 503, in seconds |
WWW-Authenticate |
response | On every 401, and only 401. The constant value Bearer |
UG-Signature |
webhook | The signature over a webhook delivery |
Streaming
Section titled “Streaming”Long-running work reports progress two ways: poll the resource, or stream
GET /v1/scenarios/{scenarioId}/events as server-sent events. The stream is resumable —
reconnect with Last-Event-ID and you pick up where you stopped, without replaying
everything.
Related
Section titled “Related”- Quickstart — the same rules, applied in four calls.
- Authentication — credentials, scopes and roles.
- API reference — every route, request and response.