Skip to content

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.

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": "email", "email": { "address": "[email protected]" } },
{ "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.

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.

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.

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.

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"
}
}

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.

UG-Signature: t=<unix_ts>,v1=<hex(hmac_sha256(secret, "<unix_ts>.<raw_body>"))>
  • t is 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.
  • v1 is hex(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.

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.

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.

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.

This is a real, verifiable test vector — the exact inputs and output the signing implementation produces:

secret: s3cret
timestamp (t): 1737072188
raw body: {"hello":"world"}
signed message: "1737072188.{\"hello\":\"world\"}"
UG-Signature: t=1737072188,v1=2c08ba3b9a7164e130d805020b3b3c961b7b18c02cfd5e1c314c00be41aeb561

Reproduce 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.

Terminal window
printf '1737072188.{"hello":"world"}' | openssl dgst -sha256 -hmac 's3cret' -hex
# SHA2-256(stdin)= 2c08ba3b9a7164e130d805020b3b3c961b7b18c02cfd5e1c314c00be41aeb561
# ^ the label before "=" varies by OpenSSL version; the hex does not
Terminal window
python3 -c 'import hmac,hashlib; print(hmac.new(b"s3cret", b"1737072188." + b"{\"hello\":\"world\"}", hashlib.sha256).hexdigest())'
# 2c08ba3b9a7164e130d805020b3b3c961b7b18c02cfd5e1c314c00be41aeb561

A 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.

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");
}
);
import hashlib
import hmac
import 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", 200

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 metadata
POST /v1/webhook-signing-secret/rotate # rotate the tenant-default secret

Both 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.

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

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 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 with
UG_WEBHOOK_SECRET_PREVIOUS optional; empty except during a rotation
def 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:

  1. Ship the two-secret verifier first, with UG_WEBHOOK_SECRET_PREVIOUS empty. Nothing changes behaviourally; you are only making step 4 possible. Do this well before you ever need to rotate.

  2. Call rotate and capture the response (§4.2 — the secret value 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.

  3. 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_SECRET in place if your platform propagates env changes without a restart; a half-updated fleet is exactly what step 4 exists to survive.

  4. Deploy with UG_WEBHOOK_SECRET = the new value and UG_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.

  5. 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 a 401 and then into a dead letter.

  6. Deploy with UG_WEBHOOK_SECRET_PREVIOUS empty. Rotation complete. Our side revokes the old secret’s usable value on its own schedule after revoke_at; secret_last4 survives 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_PREVIOUS to 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_at failed at our end and dead-lettered again without ever reaching you.)
  • The signature header 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 url with an http:// scheme is rejected at write time with 422 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.254 cloud-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.

  • 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 3xx response 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 2xx right away) and do your processing asynchronously afterward — standard webhook-receiver practice.

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.

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 2xx3xx, 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}/deliveries
GET /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}/redeliver

This 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.

  • 2xx = delivered. Nothing else does — not 3xx (§4.4), not any 4xx/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 2xx quickly and processing asynchronously (§4.4) is both safe and the recommended pattern for any handler whose real work might be slow.

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:

Terminal window
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.

Either inline, at the moment you start the task:

Terminal window
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:

Terminal window
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"
}

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:

Terminal window
# From your receiver's logs, once a delivery has arrived:
echo -n '<raw body you received>' > /tmp/body.json
echo "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.
Terminal window
# 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.

  • 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.