Skip to content

Conventions

Everything here holds for every endpoint. Learn it once and the rest of the API is predictable.

Every route lives under /v1:

https://api.underwriting.guru/v1/scenarios

All 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).

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.

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.

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.

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.

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.

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

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.