Webhooks
This guide is for integrators who want to be told when a background task finishes — instead of polling its resource — and, in particular, who want that notification delivered to an HTTPS endpoint they control.
All endpoints referenced here live under /v1 (e.g.
https://api.example.com/v1/task-notification-subscriptions). Every example below uses
https://api.example.com and https://app.example.com as stand-ins for your tenant’s actual API and
application hosts, and synthetic IDs (0198c5f2-...) in place of real UUIDs. Requests are
authenticated the same way as the rest of the API: Authorization: Bearer <credential>, where
<credential> is either a user JWT or an API key (ug_live_... / ug_test_...). Creating or
modifying a subscription or signing secret requires either an admin/reviewer user or an API key
carrying the notifications:write scope; reading (listing, getting) is open to any authenticated
member of your tenant.
1. Concepts
Section titled “1. Concepts”Tasks. A task is a long-running background operation with exactly two terminal outcomes —
succeeded or failed. (One task kind, generation_run, can also emit a non-terminal task.stalled
notification before it reaches either one — see “Event types” below.) Every task belongs to a
task_kind, and every task kind has its own REST resource you can GET directly:
task_kind |
What finished | task_id is |
Resource path |
|---|---|---|---|
generation_run |
A question-set generation run | the run | /v1/question-sets/{id}/generation-runs/{runId} |
diff_run |
A guideline re-upload diff run | the run | /v1/question-sets/{id}/diff-runs/{runId} |
document_ingestion |
A document’s parse/ingestion | the document | /v1/scenarios/{id}/documents/{documentId} |
guideline_ingestion |
A guideline source’s parse/ingestion | the guideline source | /v1/question-groups/{id}/guidelines/{guidelineId} |
structure_task |
A question-authoring structuring assist | the task | /v1/question-sets/{id}/versions/{v}/structure-tasks/{taskId} |
question_set_publish |
Publishing a question-set version (a consistency review over every changed question — real model-minutes on a large set) | the version | /v1/question-sets/{id}/versions/{versionId} |
evaluation_run |
An evaluation of a case | the evaluation | /v1/scenarios/{id}/evaluations/{evaluationId} |
report_generated |
A findings report’s generation | the report | /v1/scenarios/{id}/reports/{reportId} |
directive_review |
A supersession derived from a directive is waiting for a human decision | the supersession | /v1/scenarios/{id}/supersessions (the collection — filter it for the id) |
Two of those repay a second look. question_set_publish’s task_id is the version id, not the
question set’s, and directive_review’s is the supersession id while its links.resource points
at the whole supersessions collection for that case — it is the one kind whose resource path does not
end in its own task_id.
directive_review is also the one kind that is not a “your job finished” event: it fires when
something needs a person, and it therefore always arrives as task.completed with no failure branch
at all (see §2.1’s per-kind table).
This list grows over time as new long-running task kinds are added; an unrecognized task_kind on the
standalone subscribe endpoint (§6) is rejected with 422, since there is no way to confirm the kind is
real without an existing task to anchor it against. Treat the list as extensible in your own receiver
too: branch on the kinds you care about and ignore the rest rather than failing on an unknown one.
Event types. Each task fires at least one notification per subscription — one per terminal
transition: task.completed (succeeded) or task.failed. A generation_run whose vendor retries were
given up on additionally fires a non-terminal task.stalled notification first: the run is paused and
resumable, not finished, and its terminal event still follows on the same subscription once it is
resumed and either completes or fails (§2.1). Other than that one case, there are no progress/partial
events on this channel — for in-app progress, poll the task’s own resource, or, for a case, stream
GET /v1/scenarios/{scenarioId}/events. That per-case stream is the only SSE endpoint there is:
there is no tenant-wide event stream, and a client that opens one gets a 404.
A subtlety on document_ingestion and guideline_ingestion: unsupported_pending. An uploaded
document or guideline whose content type is recognized but doesn’t yet have a processing adapter is
accepted and stored rather than rejected — it sits in a distinct unsupported_pending state until a
later backfill can process it. This is not an application error internally, but from a subscriber’s point
of view nothing was processed, so it is delivered as event_type: "task.failed" (never
task.completed) with outcome.error_code: "unsupported_pending" — a value distinct from the “something
actually went wrong while processing” codes (document_ingestion_failed, guideline_ingestion_failed).
Branch on error_code, not just event_type, if you want to tell “not yet supported” apart from “tried
and failed.”
Two ways to subscribe. Every task-creating request (starting a generation run, a diff run, a
structure-assist task, or finalizing a document or guideline upload) accepts an optional notifications
array inline in its body:
{ "guideline_ids": ["0198c5aa-1111-7000-8000-000000000001"], "notifications": [ { "channel": "webhook", "webhook": { "url": "https://hooks.example.com/ug" } } ]}Alternatively — to add a subscriber to a task that is already running, or one you didn’t create
yourself — call POST /v1/task-notification-subscriptions with the task’s task_kind and task_id
(§6.3). Both paths produce the identical subscription resource and go through the identical delivery
pipeline; the inline array is pure convenience for the common “subscribe at creation time” case.
Channels. Two channels exist today: email (delivered to a mailbox) and webhook (delivered to an
HTTPS endpoint, signed — the subject of this guide). Both channels carry the same envelope (§2) and get
the same at-least-once delivery, retry, and dead-letter tracking (§5); a future channel (SMS, push) would
slot in the same way.
Webhook targets are ad hoc, always. A webhook subscription’s target is an HTTPS URL supplied
directly, inline, on the subscription — {"channel": "webhook", "webhook": {"url": "https://..."}}
(§6.2). There is no reusable/registered endpoint resource to create first: every webhook subscription
names its destination url on the spot, and every one of them signs with your tenant’s single default
secret (§4). If your integration wants to fan a task out to several downstream systems, create one
subscription per url on that task — there is no separate “endpoint” object to reuse across
subscriptions.
2. The delivery payload
Section titled “2. The delivery payload”Every delivery — regardless of channel — carries the same versioned JSON envelope. For webhooks, this is the exact request body posted to your endpoint (raw bytes, no re-serialization, ever — see §3).
{ "schema_version": 1, "delivery_id": "0198c9c2-6e2b-7a11-9c3d-2b6f9a0e1234", "event_type": "task.completed", "task_kind": "generation_run", "task_id": "0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T18:03:07Z", "sent_at": "2026-07-17T18:03:07Z", "outcome": { "status": "succeeded", "summary": "Question generation completed. Come take a look at the drafted questions.", "error_code": null }, "links": { "resource": "https://api.example.com/v1/question-sets/0198c111-.../generation-runs/0198c5f2-...", "ui": "https://app.example.com/question-sets/0198c111-...?run=0198c5f2-..." }}| Field | Type | Meaning |
|---|---|---|
schema_version |
integer | Envelope shape version, currently always 1. Bumped only on a breaking change to this shape; new fields are added without a bump, so treat unknown top-level keys as forward-compatible and ignore them. |
delivery_id |
UUID | Identifies this delivery attempt-set — stable across every retry of the same logical delivery. Use this to de-duplicate (§5.4). Also sent as the UG-Delivery-Id header. |
event_type |
string | task.completed, task.failed, or (non-terminal, generation_run only) task.stalled — see §1’s “Event types” and §2.1. Also sent as the UG-Event-Type header. |
task_kind |
string | One of §1’s task-kind values (extensible over time). |
task_id |
UUID | The task’s own ID — the same ID that appears in its resource path. |
tenant_id |
UUID | Your tenant’s ID, included so a receiver fanning in multiple tenants’ webhooks to one endpoint can dispatch without a lookup. |
occurred_at |
timestamp (RFC 3339, UTC) | When the task reached its terminal state. |
sent_at |
timestamp (RFC 3339, UTC) | When this delivery’s payload was rendered. In the current implementation this is always identical to occurred_at (both are stamped once, at the moment the delivery row is created) and is not updated on each retry attempt — it reflects “when this notification was generated,” not “when this specific HTTP attempt went out.” For the actual per-attempt send time, use the signature header’s t value (§3), which is recomputed fresh on every attempt. |
outcome.status |
string | succeeded or failed — the task’s own outcome (matches event_type, spelled differently for readability: task.completed events always carry succeeded, task.failed events always carry failed). |
outcome.summary |
string | A short, human-readable, task-kind-specific description (e.g. a question count, a changed-item count, a parse outcome). Never raw extracted document text and never a document excerpt — see the guarantee below. |
outcome.error_code |
string or null | Set only when outcome.status is failed; a stable short code (e.g. generation_failed, generation_vendor_outage, document_ingestion_failed, guideline_ingestion_failed, unsupported_pending — see §1’s note on that last one) you can branch on without parsing summary. |
links.resource |
string (URL) | The full REST resource for this task — GET it for complete, current state. May be an empty string if your deployment has no public API base URL configured. |
links.ui |
string (URL) | A deep link into the web application, scoped to this task, for a human to click through to. May be an empty string if your deployment has no public web base URL configured. |
No document content, ever. A webhook payload never contains extracted or uploaded document
content — only IDs, a short summary string, and links. This is deliberate: payloads are not an
alternate data contract. If you need the underlying content, fetch it through the ordinary REST API
after following links.resource.
2.1 One example per task_kind
Section titled “2.1 One example per task_kind”Every outcome.status, outcome.summary and outcome.error_code value below is the literal string
this system emits — they are copied from the code that sends them, not paraphrased.
The IDs, timestamps and hosts are synthetic. Where a summary interpolates a reason from the task’s own
failure detail it is shown as <reason>; the real string has that text spliced in.
The two-value rule. outcome.status is succeeded on task.completed and failed on
task.failed — and also failed on the non-terminal task.stalled event (§2.1’s generation_run
row), since a stall is meant to get the same attention-grabbing rendering as a real failure even
though the task itself has not reached a terminal state. outcome.error_code is null on success and
a stable short code on any failure-shaped delivery, terminal or not — branch on error_code, never on
summary.
The full outcome matrix
Section titled “The full outcome matrix”task_kind |
event_type |
outcome.status |
outcome.error_code |
outcome.summary |
|---|---|---|---|---|
generation_run |
task.completed |
succeeded |
null |
Question generation completed. Come take a look at the drafted questions. |
task.failed |
failed |
generation_failed |
Question generation failed: <reason> |
|
task.failed |
failed |
generation_canceled |
Question generation was canceled. |
|
task.stalled (non-terminal) |
failed |
generation_vendor_outage |
Question generation is on hold: the AI service did not recover after <attempts> automatic checks, so we stopped retrying on our own. Nothing has been lost — open the question set and choose Resume to pick up exactly where it left off. |
|
diff_run |
task.completed |
succeeded |
null |
Guideline re-upload diff ready for review. Come take a look at the proposed changes. |
task.failed |
failed |
diff_failed |
Guideline re-upload diff failed: <reason> |
|
document_ingestion |
task.completed |
succeeded |
null |
Document ingestion completed. — or, on a degraded parse, Document ingestion completed with a degraded parse — some content may be incomplete. |
task.failed |
failed |
document_ingestion_failed |
Document ingestion failed: <reason> |
|
task.failed |
failed |
unsupported_pending |
This document's format is not yet supported for automatic ingestion. |
|
guideline_ingestion |
task.completed |
succeeded |
null |
Guideline processing completed. — or Guideline processing completed with a degraded parse — some content may be incomplete. |
task.failed |
failed |
guideline_ingestion_failed |
Guideline processing failed: <reason> |
|
task.failed |
failed |
unsupported_pending |
This guideline's format is not yet supported for automatic processing. |
|
structure_task |
task.completed |
succeeded |
null |
Question structuring completed. Come take a look at the proposal. |
task.failed |
failed |
structure_task_failed |
Question structuring failed: <reason> |
|
question_set_publish |
task.completed |
succeeded |
null |
Your question set version is published. |
task.failed |
failed |
question_set_publish_failed |
The question set version could not be published. |
|
evaluation_run |
task.completed |
succeeded |
null |
Evaluation completed. Review the answers and decision. |
task.failed |
failed |
evaluation_failed |
Evaluation failed. |
|
task.failed |
failed |
evaluation_canceled |
Evaluation was canceled. |
|
report_generated |
task.completed |
succeeded |
null |
Your findings report is ready to download. |
task.failed |
failed |
report_failed |
The findings report could not be generated. |
|
directive_review |
task.completed |
succeeded |
null |
A supersession from a directive needs review. — or, for a confirmed supersession whose source statement has vanished from a re-derive, A confirmed supersession (<id>) has no analog in the latest directive re-derive — its source statement may have been removed. Review needed. |
A cancel is a failure on this channel. generation_run and evaluation_run both have a
canceled terminal state, and both deliver it as task.failed with a distinct *_canceled code —
“stopped without a result” is the honest reading for a receiver, and the code is there so you can tell
it apart from “tried and broke”. directive_review has no failure branch at all.
A stall is not a failure, and not terminal. generation_run is the one task kind with a third
event_type: task.stalled, fired when the run’s automatic vendor-outage retries are given up on
(see the row above). It carries outcome.status: "failed" and outcome.error_code: "generation_vendor_outage" on purpose — a receiver should render it the same attention-grabbing way
as a real failure — but the run itself is paused, not finished: nothing is lost, its stage
checkpoints and drafted questions are intact, and it stays resumable from
POST /v1/question-sets/{id}/generation-runs/{runId}/resume. The subscription is not consumed by this
delivery; once the run is resumed, its real terminal event (task.completed or another
task.failed) still arrives on the same subscription. Treat task.stalled as “come look, but don’t
tear anything down.”
unsupported_pending is neither of the obvious two. An uploaded document or guideline whose
content type is recognized but has no processing adapter yet is accepted and stored rather than
rejected (see §1). Internally that is not an error; from a subscriber’s point of view nothing was
processed, so it is delivered as task.failed with error_code: "unsupported_pending". Branch on the
code if “not yet supported” and “tried and failed” mean different things to you.
Worked payloads
Section titled “Worked payloads”generation_run, succeeded — the shape §2 already showed, repeated here as the baseline:
{ "schema_version": 1, "delivery_id": "0198c9c2-6e2b-7a11-9c3d-2b6f9a0e1234", "event_type": "task.completed", "task_kind": "generation_run", "task_id": "0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T18:03:07Z", "sent_at": "2026-07-17T18:03:07Z", "outcome": { "status": "succeeded", "summary": "Question generation completed. Come take a look at the drafted questions.", "error_code": null }, "links": { "resource": "https://api.example.com/v1/question-sets/0198c111-2222-7000-8000-000000000001/generation-runs/0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c", "ui": "https://app.example.com/question-sets/0198c111-2222-7000-8000-000000000001?run=0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c" }}diff_run, failed:
{ "schema_version": 1, "delivery_id": "0198c9c3-1a44-7b02-8d55-3c7e0b1f2345", "event_type": "task.failed", "task_kind": "diff_run", "task_id": "0198c5f3-6b21-7e33-9c2a-8d3f4e5a6b7c", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T18:11:42Z", "sent_at": "2026-07-17T18:11:42Z", "outcome": { "status": "failed", "summary": "Guideline re-upload diff failed: the previous published version could not be read", "error_code": "diff_failed" }, "links": { "resource": "https://api.example.com/v1/question-sets/0198c111-2222-7000-8000-000000000001/diff-runs/0198c5f3-6b21-7e33-9c2a-8d3f4e5a6b7c", "ui": "https://app.example.com/question-sets/0198c111-2222-7000-8000-000000000001?diff=0198c5f3-6b21-7e33-9c2a-8d3f4e5a6b7c" }}document_ingestion, succeeded with a degraded parse — the case most receivers forget exists, because
it is a success whose content is incomplete:
{ "schema_version": 1, "delivery_id": "0198c9c4-2b55-7c13-8e66-4d8f1c2a3456", "event_type": "task.completed", "task_kind": "document_ingestion", "task_id": "0198c6a1-7c32-7f44-8b1b-9e4a5f6b7c8d", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T18:20:03Z", "sent_at": "2026-07-17T18:20:03Z", "outcome": { "status": "succeeded", "summary": "Document ingestion completed with a degraded parse — some content may be incomplete.", "error_code": null }, "links": { "resource": "https://api.example.com/v1/scenarios/0198c000-1111-7000-8000-000000000009/documents/0198c6a1-7c32-7f44-8b1b-9e4a5f6b7c8d", "ui": "https://app.example.com/scenarios/0198c000-1111-7000-8000-000000000009?document=0198c6a1-7c32-7f44-8b1b-9e4a5f6b7c8d" }}guideline_ingestion, unsupported_pending:
{ "schema_version": 1, "delivery_id": "0198c9c5-3c66-7d24-8f77-5e9a2d3b4567", "event_type": "task.failed", "task_kind": "guideline_ingestion", "task_id": "0198c7b2-8d43-7a55-9c2c-af5b6a7c8d9e", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T18:31:10Z", "sent_at": "2026-07-17T18:31:10Z", "outcome": { "status": "failed", "summary": "This guideline's format is not yet supported for automatic processing.", "error_code": "unsupported_pending" }, "links": { "resource": "https://api.example.com/v1/question-groups/0198c222-3333-7000-8000-000000000002/guidelines/0198c7b2-8d43-7a55-9c2c-af5b6a7c8d9e", "ui": "https://app.example.com/question-groups/0198c222-3333-7000-8000-000000000002?guideline=0198c7b2-8d43-7a55-9c2c-af5b6a7c8d9e" }}structure_task, succeeded:
{ "schema_version": 1, "delivery_id": "0198c9c6-4d77-7e35-8a88-6fab3e4c5678", "event_type": "task.completed", "task_kind": "structure_task", "task_id": "0198c8c3-9e54-7b66-8d3d-b06c7b8d9eaf", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T18:40:55Z", "sent_at": "2026-07-17T18:40:55Z", "outcome": { "status": "succeeded", "summary": "Question structuring completed. Come take a look at the proposal.", "error_code": null }, "links": { "resource": "https://api.example.com/v1/question-sets/0198c111-2222-7000-8000-000000000001/versions/0198c333-4444-7000-8000-000000000003/structure-tasks/0198c8c3-9e54-7b66-8d3d-b06c7b8d9eaf", "ui": "https://app.example.com/question-sets/0198c111-2222-7000-8000-000000000001?structureTask=0198c8c3-9e54-7b66-8d3d-b06c7b8d9eaf" }}question_set_publish, succeeded — note task_id is the version id, and it appears in both the
resource path and the deep link’s version query:
{ "schema_version": 1, "delivery_id": "0198c9c7-5e88-7f46-8b99-70bc4f5d6789", "event_type": "task.completed", "task_kind": "question_set_publish", "task_id": "0198c333-4444-7000-8000-000000000003", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T18:52:19Z", "sent_at": "2026-07-17T18:52:19Z", "outcome": { "status": "succeeded", "summary": "Your question set version is published.", "error_code": null }, "links": { "resource": "https://api.example.com/v1/question-sets/0198c111-2222-7000-8000-000000000001/versions/0198c333-4444-7000-8000-000000000003", "ui": "https://app.example.com/question-sets/0198c111-2222-7000-8000-000000000001?version=0198c333-4444-7000-8000-000000000003" }}evaluation_run, canceled — a task.failed whose code says nobody broke anything:
{ "schema_version": 1, "delivery_id": "0198c9c8-6f99-7a57-8caa-81cd5a6e789a", "event_type": "task.failed", "task_kind": "evaluation_run", "task_id": "0198c444-5555-7000-8000-000000000004", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T19:04:00Z", "sent_at": "2026-07-17T19:04:00Z", "outcome": { "status": "failed", "summary": "Evaluation was canceled.", "error_code": "evaluation_canceled" }, "links": { "resource": "https://api.example.com/v1/scenarios/0198c000-1111-7000-8000-000000000009/evaluations/0198c444-5555-7000-8000-000000000004", "ui": "https://app.example.com/scenarios/0198c000-1111-7000-8000-000000000009?evaluation=0198c444-5555-7000-8000-000000000004" }}report_generated, succeeded:
{ "schema_version": 1, "delivery_id": "0198c9c9-70aa-7b68-8dbb-92de6b7f89ab", "event_type": "task.completed", "task_kind": "report_generated", "task_id": "0198c555-6666-7000-8000-000000000005", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T19:15:33Z", "sent_at": "2026-07-17T19:15:33Z", "outcome": { "status": "succeeded", "summary": "Your findings report is ready to download.", "error_code": null }, "links": { "resource": "https://api.example.com/v1/scenarios/0198c000-1111-7000-8000-000000000009/reports/0198c555-6666-7000-8000-000000000005", "ui": "https://app.example.com/scenarios/0198c000-1111-7000-8000-000000000009?report=0198c555-6666-7000-8000-000000000005" }}directive_review — the odd one: task.completed meaning “a person is needed”, and a
links.resource that names the collection rather than the row:
{ "schema_version": 1, "delivery_id": "0198c9ca-81bb-7c79-8ecc-a3ef7c8a9abc", "event_type": "task.completed", "task_kind": "directive_review", "task_id": "0198c666-7777-7000-8000-000000000006", "tenant_id": "0198a1e0-9d3c-7f01-9a2b-1c4d5e6f7089", "occurred_at": "2026-07-17T19:27:48Z", "sent_at": "2026-07-17T19:27:48Z", "outcome": { "status": "succeeded", "summary": "A supersession from a directive needs review.", "error_code": null }, "links": { "resource": "https://api.example.com/v1/scenarios/0198c000-1111-7000-8000-000000000009/supersessions", "ui": "https://app.example.com/scenarios/0198c000-1111-7000-8000-000000000009?supersession=0198c666-7777-7000-8000-000000000006" }}3. Signature verification
Section titled “3. Signature verification”Every webhook delivery carries three headers:
| Header | Contents |
|---|---|
UG-Signature |
t=<unix_ts>,v1=<hex hmac-sha256 signature> — see below. |
UG-Delivery-Id |
The same value as the body’s delivery_id — lets you short-circuit idempotency checks without parsing the body first. |
UG-Event-Type |
The same value as the body’s event_type. |
HTTP header names are case-insensitive (RFC 7230 §3.2) — on the wire, the sending stack’s standard MIME
canonicalization renders these as Ug-Signature, Ug-Delivery-Id, Ug-Event-Type. Look them up with a
case-insensitive header accessor (which every mainstream HTTP framework, including the ones in §3.6/§3.7,
uses by default) and this is never something you need to think about.
3.1 How the signature is computed
Section titled “3.1 How the signature is computed”UG-Signature: t=<unix_ts>,v1=<hex(hmac_sha256(secret, "<unix_ts>.<raw_body>"))>tis the Unix timestamp, in seconds, at the moment this specific attempt was sent.- The signed message is the literal string
"{t}.{raw_body}"— the ASCII decimal timestamp, a literal., then the exact raw request body bytes, concatenated. Not a JSON re-encoding of the body: the signature is computed over, and must be verified against, the bytes on the wire, before any parsing. v1ishex(HMAC-SHA256(secret, message))— lowercase hex.
This is the single most common real-world webhook-verification bug: re-serializing the body (e.g.
json.dumps(json.loads(raw_body))) before checking the signature. JSON re-encoding can reorder keys,
change whitespace, or normalize number formatting, silently producing a different byte string than the
one that was signed — and your verification will fail even though the delivery is completely
legitimate. Always read the raw bytes from the request body (before any JSON parsing) and sign/compare
those.
3.2 Replay window
Section titled “3.2 Replay window”Reject a delivery if abs(current_unix_time - t) > 300 (a 5-minute tolerance in either direction) —
this bounds how long a captured request stays replayable even if the transport were somehow compromised,
though the primary defense against tampering/replay is TLS itself; the signature defeats replay given
a secure channel, not in place of one.
3.3 Constant-time comparison
Section titled “3.3 Constant-time comparison”Compare the computed signature against the received one with a constant-time comparison
(crypto.timingSafeEqual in Node, hmac.compare_digest in Python, crypto/subtle.ConstantTimeCompare
in Go) — never ==/===. A naive byte-by-byte comparison that returns as soon as it finds a mismatch
leaks timing information an attacker can use to forge a valid signature one byte at a time.
3.4 Idempotency
Section titled “3.4 Idempotency”After verifying, check delivery_id (the body’s field, or the UG-Delivery-Id header — they’re always
identical) against your own store of already-processed delivery IDs before acting on the event.
Deliveries are at-least-once, not exactly-once (§5.1): you may legitimately receive the same
delivery_id more than once. Treat a repeat as a no-op success — verify it, return 2xx, and skip your
side effects.
3.5 Worked example
Section titled “3.5 Worked example”This is a real, verifiable test vector — the exact inputs and output the signing implementation produces:
secret: s3crettimestamp (t): 1737072188raw body: {"hello":"world"}
signed message: "1737072188.{\"hello\":\"world\"}"UG-Signature: t=1737072188,v1=2c08ba3b9a7164e130d805020b3b3c961b7b18c02cfd5e1c314c00be41aeb561Reproduce it from a shell, in one line each. Both commands print the v1 value above; anything
else means your idea of the signed message differs from ours. Note printf, not echo — a trailing
newline is a different byte string and would produce a different signature, which is the same class
of mistake as re-serializing the body.
printf '1737072188.{"hello":"world"}' | openssl dgst -sha256 -hmac 's3cret' -hex# SHA2-256(stdin)= 2c08ba3b9a7164e130d805020b3b3c961b7b18c02cfd5e1c314c00be41aeb561# ^ the label before "=" varies by OpenSSL version; the hex does notpython3 -c 'import hmac,hashlib; print(hmac.new(b"s3cret", b"1737072188." + b"{\"hello\":\"world\"}", hashlib.sha256).hexdigest())'# 2c08ba3b9a7164e130d805020b3b3c961b7b18c02cfd5e1c314c00be41aeb561A real delivery differs from this vector in exactly two ways: the body is §2’s envelope instead of
{"hello":"world"}, and t is the moment that attempt went out. Nothing about the construction
changes with size or content.
Run either snippet below against these three inputs to sanity-check your implementation before pointing it at a live endpoint.
3.6 Node.js
Section titled “3.6 Node.js”const crypto = require("crypto");
/** * Verifies a UG webhook delivery. * * @param {Buffer|string} rawBody The EXACT bytes of the request body, read * before any JSON.parse (e.g. from a raw * body middleware / bodyParser.raw()). * @param {string} signatureHeader The raw `UG-Signature` header value, e.g. * "t=1737072188,v1=2c08ba3b...". * @param {string} secret Your current signing secret (§4). * @param {number} toleranceSeconds Replay-window tolerance, default 300. * @returns {boolean} */function verifyUgWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( signatureHeader.split(",").map((kv) => kv.split("=")) ); const t = parts.t; const v1 = parts.v1; if (!t || !v1) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(t, 10)); if (age > toleranceSeconds) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8"); const signedMessage = Buffer.concat([Buffer.from(`${t}.`, "utf8"), body]); const expected = crypto.createHmac("sha256", secret).update(signedMessage).digest("hex");
const expectedBuf = Buffer.from(expected, "hex"); const receivedBuf = Buffer.from(v1, "hex"); if (expectedBuf.length !== receivedBuf.length) return false; // timingSafeEqual requires equal length return crypto.timingSafeEqual(expectedBuf, receivedBuf);}
// Example Express receiver — note express.raw(), NOT express.json(), so// req.body is the exact byte buffer the signature was computed over.const express = require("express");const app = express();
app.post( "/hooks/ug", express.raw({ type: "application/json" }), (req, res) => { const ok = verifyUgWebhook( req.body, req.header("UG-Signature") || "", process.env.UG_WEBHOOK_SECRET ); if (!ok) return res.status(401).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8")); if (alreadyProcessed(event.delivery_id)) { return res.status(200).send("ok"); // idempotent no-op (§3.4) } // ... handle event.event_type / event.task_kind / event.outcome ... markProcessed(event.delivery_id); res.status(200).send("ok"); });3.7 Python
Section titled “3.7 Python”import hashlibimport hmacimport time
def verify_ug_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool: """ raw_body: the EXACT bytes of the request body, before any json.loads. signature_header: the raw `UG-Signature` header value, e.g. "t=1737072188,v1=2c08ba3b...". secret: your current signing secret (see "Secrets" below). """ parts = dict(kv.split("=", 1) for kv in signature_header.split(",")) ts = parts.get("t") v1 = parts.get("v1") if not ts or not v1: return False
if abs(int(time.time()) - int(ts)) > tolerance_seconds: return False
signed_message = f"{ts}.".encode("utf-8") + raw_body expected = hmac.new(secret.encode("utf-8"), signed_message, hashlib.sha256).hexdigest()
# hmac.compare_digest is constant-time and accepts str or bytes of equal # or differing length safely (unlike `==`, which short-circuits early). return hmac.compare_digest(expected, v1)
# Example Flask receiver:## @app.route("/hooks/ug", methods=["POST"])# def receive_ug_webhook():# raw_body = request.get_data() # exact bytes, before Flask parses JSON# sig = request.headers.get("UG-Signature", "")# if not verify_ug_webhook(raw_body, sig, os.environ["UG_WEBHOOK_SECRET"]):# return "invalid signature", 401## event = request.get_json()# if already_processed(event["delivery_id"]):# return "ok", 200 # idempotent no-op# # ... handle event["event_type"] / event["task_kind"] / event["outcome"] ...# mark_processed(event["delivery_id"])# return "ok", 2004. Secrets
Section titled “4. Secrets”4.1 One secret per tenant
Section titled “4.1 One secret per tenant”Every tenant has exactly one tenant-default signing secret, and it is the only signing-secret
scope: every webhook subscription’s url — there is no other kind (§1) — always signs with it. There
is no mechanism to give one subscription’s URL its own override secret; if you’re fanning a task out to
several downstream systems (several subscriptions, several urls), every one of them verifies against
this same tenant secret.
GET /v1/webhook-signing-secret # tenant-default secret's metadataPOST /v1/webhook-signing-secret/rotate # rotate the tenant-default secretBoth are also on Admin → Developers in the app, which is where an administrator who does not hold a key can mint the first secret and read its last four.
4.2 One-time display
Section titled “4.2 One-time display”A secret’s full value is returned exactly once: in the response body of the request that created or
rotated it. Every rotate response carries all three fields — secret (the full plaintext value, shown
only this once), secret_last4 (the same last-4 you’ll see later on the metadata GET), and
rotated_at (the moment this rotation happened) — so you never need a follow-up call to learn the
metadata for the secret you just received:
// POST /v1/webhook-signing-secret/rotate → 200{ "secret": "whsec_1f2e3d4c5b6a7988776655443322110000ffeeddccbbaa9988776655443322", "secret_last4": "3322", "rotated_at": "2026-07-17T18:00:00Z"}It is never retrievable again afterward — GET /v1/webhook-signing-secret returns only metadata (id,
secret_last4, created_at, rotated_at), never the raw value. Store the secret value somewhere
durable the moment you receive it; there is no recovery path if you lose it short of rotating again (which
mints a new one and starts a fresh overlap window, §4.3).
4.3 Rotation & the 24-hour overlap window
Section titled “4.3 Rotation & the 24-hour overlap window”Rotating creates a new active secret and demotes the previous one to a “rotated out, but still valid”
state for 24 hours (overlap_window) before it is irreversibly destroyed. This overlap exists
because a delivery pins whichever secret was active at its first send attempt and reuses that same
secret for every retry of that same delivery, even if a rotation happens mid-retry — otherwise a
delivery’s 6th retry, hours after its 1st attempt, could be signed with a different secret than the
receiver originally saw, which would be unverifiable without knowing which attempt you were looking at.
The overlap window is deliberately set well beyond the actual webhook retry window (§5.2’s schedule
sums to roughly 5.5 hours, not 24), so a rotated-out secret stays valid for as long as any delivery
signed with it could plausibly still be retrying, with generous headroom to spare.
Practical guidance: immediately after you rotate your own verification key, keep verifying incoming signatures against both the new secret and the just-rotated-out one for a grace period (your own deployment/config-propagation lag, not something this API enforces on your behalf) — try the current secret first, then fall back to the previous one if verification fails. This costs nothing and protects against your own rollout being slower than the moment you called rotate.
The rotation runbook, from your side
Section titled “The rotation runbook, from your side”The window that matters is yours, not ours. Our overlap window exists so a delivery that pinned
the old secret can finish retrying; it does nothing about the gap between “you called rotate” and
“every one of your receiver instances has the new value”. Close that gap by holding two secrets
and trying both — the standard _PREVIOUS pattern:
UG_WEBHOOK_SECRET the value you expect most deliveries to be signed withUG_WEBHOOK_SECRET_PREVIOUS optional; empty except during a rotationdef verify_with_rotation(raw_body: bytes, signature_header: str) -> bool: current = os.environ["UG_WEBHOOK_SECRET"] previous = os.environ.get("UG_WEBHOOK_SECRET_PREVIOUS", "") if verify_ug_webhook(raw_body, signature_header, current): return True return bool(previous) and verify_ug_webhook(raw_body, signature_header, previous)The steps, in order, and each one is a separate deploy:
-
Ship the two-secret verifier first, with
UG_WEBHOOK_SECRET_PREVIOUSempty. Nothing changes behaviourally; you are only making step 4 possible. Do this well before you ever need to rotate. -
Call rotate and capture the response (§4.2 — the
secretvalue is shown once, and only here):Terminal window curl -sS -X POST "$UG_API/webhook-signing-secret/rotate" \-H "Authorization: Bearer $UG_API_KEY"From this instant, every new delivery is signed with the new secret; deliveries already mid-retry keep the old one for up to 24 hours.
-
Store the new value in your secret manager as the previous slot’s future contents — i.e. write it somewhere your next deploy can read. Do not overwrite
UG_WEBHOOK_SECRETin place if your platform propagates env changes without a restart; a half-updated fleet is exactly what step 4 exists to survive. -
Deploy with
UG_WEBHOOK_SECRET= the new value andUG_WEBHOOK_SECRET_PREVIOUS= the old one. During the rollout some instances still hold only the old value and verify old-signed deliveries; updated instances verify both. No delivery fails verification at any point in the rollout. -
Wait out our overlap window — at least 24 hours — before you clear
UG_WEBHOOK_SECRET_PREVIOUS. Our overlap window is 24 hours; wait it out. Clearing it early turns a legitimate late retry into a401and then into a dead letter. -
Deploy with
UG_WEBHOOK_SECRET_PREVIOUSempty. Rotation complete. Our side revokes the old secret’s usable value on its own schedule afterrevoke_at;secret_last4survives so support can still answer “was this delivery signed with the secret we rotated out last Tuesday?” without anyone holding a usable secret.
A redelivery is signed with whichever secret is active when you ask for it — not the one the
delivery originally pinned. The pin (§5.3) holds a delivery’s signing secret steady across the
retries of one send cycle, which is what lets you verify those attempts consistently through a
rotation. A manual redeliver is a new cycle: it drops the pin and re-resolves the current
active secret. So:
- Redeliver a delivery from before your rotation, at any age, and it arrives signed with your
current secret. Verify it the same way you verify everything else — no special case, and no
need for
UG_WEBHOOK_SECRET_PREVIOUSto still be populated. - A delivery whose original secret we destroyed long ago is still redeliverable. (Until 2026-09-02
it was not: the pin survived the reset, and a redeliver whose pinned secret had passed
revoke_atfailed at our end and dead-lettered again without ever reaching you.) - The
signatureheader on a redelivered attempt therefore differs from the one on its original attempt. Signatures are per-attempt anyway (the timestamp is in the signed string), so a verifier that recomputes rather than compares is unaffected.
If you have lost the secret (§4.2 — there is no recovery path), the runbook is the same but you
enter it at step 2 and you accept a gap: deliveries already sent under the lost secret cannot be
verified by anybody. Redelivering them now works — they will be re-signed with the new secret — so
redeliver what you still need and pick the rest back up from links.resource on each affected task.
Do not skip to “just verify against both forever.” Two live secrets is one more thing an attacker only has to steal one of, and the whole point of the window is that it closes.
4.4 Transport rules — what gets rejected, and why
Section titled “4.4 Transport rules — what gets rejected, and why”-
HTTPS only. A subscription’s
urlwith anhttp://scheme is rejected at write time with422 urn:ug:error:validation. It is never silently accepted or downgraded. -
Private/internal targets are rejected. Before every single send (re-checked each time, not cached — see below), the target hostname is resolved and the delivery is refused if it resolves to:
- a loopback address (
127.0.0.1,::1, …) - a link-local address (including the
169.254.169.254cloud-metadata address) - an RFC 1918 (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) or RFC 4193 unique-local (fc00::/7) private address - an unspecified (
0.0.0.0) or multicast address
This means a webhook endpoint must resolve to a genuinely public address at send time — an endpoint that’s only reachable over a VPN or from inside a private network will fail every delivery attempt.
- a loopback address (
-
DNS is re-resolved on every send, never cached — a defense against an endpoint’s DNS answer changing between when it was registered and when it’s actually dialed (DNS rebinding).
-
Redirects are never followed. A
3xxresponse is treated as a (retryable) delivery failure, not an instruction to follow — an endpoint that redirects to prove itself public, then serves the real response from a private address, is exactly the pivot this guards against. -
Timeouts. 5 seconds to establish the connection, 10 seconds total for the whole request/response. If your receiver needs longer than that to actually process the event, acknowledge immediately (respond
2xxright away) and do your processing asynchronously afterward — standard webhook-receiver practice.
5. Delivery semantics
Section titled “5. Delivery semantics”5.1 At-least-once, not exactly-once
Section titled “5.1 At-least-once, not exactly-once”A delivery may be retried even after your endpoint successfully processed it — for example, if your
response was lost in transit after you’d already handled the event. Always de-duplicate on
delivery_id (§3.4).
There are no ordering guarantees across different tasks: two tasks that complete near-simultaneously may
be delivered in either order, or concurrently. Within a single logical delivery, though, delivery_id
never changes across retries — it identifies one (subscription, event) pair for its entire retry
lifetime.
5.2 Retry / backoff schedule
Section titled “5.2 Retry / backoff schedule”| Attempt budget | Initial interval | Backoff coefficient | Maximum interval | Approx. total window |
|---|---|---|---|---|
| 8 attempts | 1 minute | ~2.4x | 4 hours | ~5.5 hours |
Any response other than 2xx — 3xx, 4xx, 5xx, a timeout, or a connection error — counts as a
retryable failure and consumes one of the 8 attempts, backing off toward the 4-hour cap between
attempts (the cap is never actually reached — the 7th and final gap is ~3.2 hours), for a total retry
window of roughly 5.5 hours before the delivery is marked dead-lettered. (Our secret-rotation overlap
window, §4.3, is a separate, deliberately more generous 24 hours — it is not sized to exactly match
this figure.)
Some failures skip retries entirely and dead-letter immediately rather than consuming any of the 8
attempts: an http:// URL, a target that resolves to a private/loopback address, or a structurally
unresolvable configuration (e.g. no signing secret configured for your tenant) are all treated as
categorically unfixable by retrying, since the problem is with the target/configuration itself rather
than a transient network condition.
5.3 Dead-letter status & manual redelivery
Section titled “5.3 Dead-letter status & manual redelivery”A delivery that exhausts its retry budget (or fails non-retryably, per above) moves to dead_lettered
and stays there, visible via the API:
GET /v1/task-notification-subscriptions/{subscriptionId}/deliveriesGET /v1/task-notification-subscriptions/{subscriptionId}/deliveries/{deliveryId}To manually retry a dead-lettered delivery (e.g. after fixing whatever was wrong with your endpoint):
POST /v1/task-notification-subscriptions/{subscriptionId}/deliveries/{deliveryId}/redeliverThis resets the delivery to pending and starts a fresh retry cycle from attempt 1’s backoff. It
returns 409 urn:ug:error:conflict if the delivery is not currently dead_lettered (e.g. it’s still
mid-retry, or already delivered) — redeliver is only for deliveries that have genuinely given up.
attempts is not reset by a redeliver; it keeps accumulating as real cumulative history across the
original attempts plus every subsequent redeliver, rather than starting over at zero.
What a redeliver does not change: the delivery_id — so your idempotency check still sees it as
the same logical delivery, which is the point — and the payload, which was rendered once and stored
verbatim.
What it does change, besides the status: the signing secret. A redeliver drops the pin the first cycle took and signs the new cycle with whichever secret is active now, so a delivery stays redeliverable however long it has been dead and however many times you have rotated since. See §4.3.
5.4 What counts as success
Section titled “5.4 What counts as success”2xx= delivered. Nothing else does — not3xx(§4.4), not any4xx/5xx.- Your response body is read only up to 16 KiB and then discarded; nothing beyond the status code is ever inspected or acted upon. Don’t try to communicate anything back through the response body — there is no mechanism that reads it.
- Because only the status code is checked, returning
2xxquickly and processing asynchronously (§4.4) is both safe and the recommended pattern for any handler whose real work might be slow.
6. Quickstart
Section titled “6. Quickstart”The examples below assume $UG_API_KEY is set to a valid API key with the notifications:write scope,
and $UG_API is https://api.example.com/v1.
6.1 Get your tenant’s default signing secret
Section titled “6.1 Get your tenant’s default signing secret”The default secret is provisioned lazily — rotate once to mint the first one and see its value. A tenant administrator can do exactly this from Admin → Developers in the app instead, which is the route to take when you do not yet hold a key:
curl -sS -X POST "$UG_API/webhook-signing-secret/rotate" \ -H "Authorization: Bearer $UG_API_KEY"{ "secret": "whsec_1f2e3d4c5b6a7988776655443322110000ffeeddccbbaa9988776655443322", "secret_last4": "3322", "rotated_at": "2026-07-17T18:00:00Z"}Store this value now (§4.2) — it will not be shown again. Use it in §3’s verification code as
UG_WEBHOOK_SECRET.
6.2 Subscribe a task to your URL
Section titled “6.2 Subscribe a task to your URL”Either inline, at the moment you start the task:
curl -sS -X POST "$UG_API/question-sets/0198c111-.../generate" \ -H "Authorization: Bearer $UG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "guideline_ids": ["0198c5aa-1111-7000-8000-000000000001"], "notifications": [ { "channel": "webhook", "webhook": { "url": "https://hooks.example.com/ug" } } ] }'…or standalone, against a task that’s already running:
curl -sS -X POST "$UG_API/task-notification-subscriptions" \ -H "Authorization: Bearer $UG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "task_kind": "generation_run", "task_id": "0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c", "channel": "webhook", "webhook": { "url": "https://hooks.example.com/ug" } }'{ "id": "0198c900-2222-7000-8000-000000000001", "task_kind": "generation_run", "task_id": "0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c", "channel": "webhook", "webhook": { "url": "https://hooks.example.com/ug" }, "status": "active", "created_at": "2026-07-17T18:01:00Z"}6.3 Receive and verify the first event
Section titled “6.3 Receive and verify the first event”Point your endpoint at §3.6/§3.7’s example receiver. When the task finishes, you’ll receive a POST to
your URL carrying §2’s envelope, signed per §3. A minimal end-to-end check:
# From your receiver's logs, once a delivery has arrived:echo -n '<raw body you received>' > /tmp/body.jsonecho "computed: $(node -e ' const crypto = require("crypto"); const fs = require("fs"); const body = fs.readFileSync("/tmp/body.json"); const t = Math.floor(Date.now()/1000) - 5; // however old the delivery actually was const mac = crypto.createHmac("sha256", process.env.UG_WEBHOOK_SECRET) .update(`${t}.`).update(body).digest("hex"); console.log(`t=${t},v1=${mac}`);')"# Compare against the UG-Signature header you actually received for that request.6.4 Inspect and, if needed, redeliver
Section titled “6.4 Inspect and, if needed, redeliver”# List this subscription's delivery attempts:curl -sS "$UG_API/task-notification-subscriptions/0198c900-.../deliveries" \ -H "Authorization: Bearer $UG_API_KEY"
# If one shows status "dead_lettered" after you've fixed your endpoint:curl -sS -X POST \ "$UG_API/task-notification-subscriptions/0198c900-.../deliveries/0198c9c2-.../redeliver" \ -H "Authorization: Bearer $UG_API_KEY"7. What this channel deliberately does not cover
Section titled “7. What this channel deliberately does not cover”Not everything you might want to be told about is a task, and this channel only carries tasks. The gaps worth knowing before you go looking for an event type that does not exist:
Operator access requests are poll-only. When a Underwriting Guru operator asks for time-boxed
access to your workspace (the “break-glass” path, which your workspace can require its own
administrator to approve), nobody is notified. There is no webhook event type, no email and no
in-app inbox for it: polling GET /v1/break-glass/requests?status=requested is the only discovery
path. A workspace that has turned on break_glass_require_tenant_approval and then does not look
there has blocked its own support. If you want to be told, you have to build the poll — and this is
the one place where a receiver’s absence is a support outage rather than a missed convenience, so
poll it on a schedule rather than on demand.
No progress events. Each task fires at least one notification per subscription — one per terminal
transition, plus (only for a generation_run whose vendor retries were given up on) a single
non-terminal task.stalled notification before that (§1, §2.1). There is no “50% done”, no per-stage
event, and no partial result otherwise. For in-flight progress, poll the task’s own resource or use the
tenant’s SSE stream in a first-party context.
No digests or batching. Every notification fires immediately, per event. There is no “daily summary of completed tasks.”
No per-user preference centre. Opt-in is per-task — inline on the task-creating request, or via a standalone subscription on a task already running (§1). There is no account-wide “always tell me about generation runs” setting.
No inbound webhooks. There is no endpoint on this API for you to push events to, and no callback route to forge against. Everything here is outbound-only.
No email-channel signature. Signing applies to the webhook channel. An email notification
carries the same envelope’s information as human-readable copy, not a signed payload.
Related
Section titled “Related”- Quickstart — the shortest path from a credential to a running underwrite.
- Embedding a case — putting one read-only case inside your own page.
- API reference — every route, request and response.