Outcome callback reference
Use this reference to implement and verify the Human Review v1 callback contract. Store a verified outcome before responding, then let a separate worker continue the application.
Receipt and execution are separate acknowledgments. Neither can substitute for the other.
Start with the Core Human Review lifecycle guide for the full pause, review, delivery, response, and application-worker flow.
Use the paths below with live data only after the selected release and environment are approved.
Paths and application authentication
| Operation | v1 path |
|---|---|
| Receive a verified outcome | POST /v1/review-outcomes on the registered application callback |
| Report application execution | POST /v1/review-outcomes/{deliveryId}/acknowledgments to VeriProof |
| Cancel a pending review case | POST /v1/review-cases/{reviewCaseId}/cancellation to VeriProof |
Calls from the application to VeriProof use a current application credential
with the review-continuations.write scope. Configure the SDK client to
resolve that credential for every control call, so a long pause does not reuse
the credential captured when the review started. The SDK handles the
portal-issued credential format and its application binding; do not construct
authentication headers yourself.
Policy evaluation binds an application-key workflow to its authenticated application. A replacement key must belong to that same application and tenant. Delegated workflows also retain their original user, authentication method, and client binding. An application key cannot replace that delegated identity. Neither identity grants permission to submit a human review decision.
Signing contract
VeriProof signs every callback with its own ed25519 key, following the Standard Webhooks profile. There is no shared secret to store: your receiver checks the signature with VeriProof’s public keys.
VeriProof signs these exact bytes:
{webhook-id}.{webhook-timestamp}.{raw request body}| Header | Meaning |
|---|---|
webhook-id | The delivery ID. It matches deliveryId in the body and stays the same on every retry. |
webhook-timestamp | Unix time in seconds when this attempt was signed |
webhook-signature | One or more space-separated signatures, each v1a,{base64 ed25519 signature} |
webhook-key-version | The signing key IDs, for troubleshooting only. Never pick a key from it. |
VeriProof publishes its public keys at
{API base URL}/.well-known/veriproof-webhook-keys.json. The SDK loads them
from the endpoint you already configure, keeps them for up to an hour, and
checks again at most once a minute when a signature doesn’t match a known key.
If your application can’t reach VeriProof, pin the same key set JSON in
configuration instead. Callbacks accept only v1a signatures.
During key rotation a callback carries two v1a signatures. Accept the callback
when any listed signature verifies with a trusted key.
Reject repeated webhook-id, webhook-timestamp, or webhook-signature
headers. The timestamp must be within 300 seconds of your clock, and the
callback body can be at most 65,536 bytes.
Required verification order
Enforce the body limit
Reject an oversized request before buffering or parsing it.
Read the raw body once
Keep the exact bytes. Do not parse, normalize, re-encode, or reconstruct the JSON before signature verification.
Validate the timestamp
Parse the Unix timestamp and reject requests more than 300 seconds from your clock.
Check the signature
Verify each v1a signature over the signed bytes with every trusted public
key. Accept the request when one matches. If none match, reload the key set
(at most once a minute) and try again before rejecting.
Parse and validate
After signature verification, parse JSON and confirm deliveryId equals the
webhook-id header. Then validate the schema plus every tenant, application,
environment, case, workflow, branch, continuation, checkpointGeneration,
boundStateFingerprint, decision version, callbackRegistrationVersion, and
expiry binding.
Store before responding
Atomically store the event and delivery identity before returning the durable receipt.
If the key set can’t be loaded, respond with HTTP 503 so VeriProof retries
the delivery later.
Verify review event webhooks
Review event webhooks use the same headers. They’re signed with v1a by
default, or with v1 (HMAC-SHA256) when the subscription uses a whsec_
secret. Each SDK includes a small webhook verifier that checks either kind, so
you don’t need to write signature code. Pass it the raw body, the headers, and
the key set, the secret, or both. De-duplicate accepted events by webhook-id.
Connection tests do not resume work
The portal sends a signed continuation.connection_test before callback activation.
Verify its signature and timestamp before reading it, and confirm its deliveryId matches webhook-id.
It must have isConnectionTest: true and canReleaseContinuation: false.
Do not create a review receipt, store a directive, or run application work for this event.
Every SDK review runtime answers a valid connection test with HTTP 202 and this body, without writing a review outcome:
{
"schemaVersion": "1.0",
"eventId": "test-...",
"deliveryId": "test-delivery-...",
"state": "connection_test_received",
"canReleaseContinuation": false
}| SDK | Callback method | Connection-test result |
|---|---|---|
| Python | HumanReviewRuntime.receive | (202, body) |
| TypeScript | HumanReviewRuntime.receive | status: 202 with connectionTest |
| .NET | HumanReviewRuntime.ReceiveAsync | StatusCode 202, IsConnectionTest, ConnectionTest |
| Java | HumanReviewRuntime.receive or ReviewCallbackReceiver.accept | statusCode() 202, isConnectionTest(), connectionTest() |
| Go | HumanReviewRuntime.ReceiveCallback | StatusCode 202, IsConnectionTest(), ConnectionTest |
| Rust | HumanReviewRuntime::receive_callback | HumanReviewReceiveResponse::ConnectionTest, status_code() 202 |
Go’s Receive and Rust’s receive accept review outcomes only, so use the callback methods above in your handler.
Direct callers of Python’s verify_review_callback must explicitly set allow_connection_test=True and handle this event separately.
Its default remains outcome-only verification.
Successful connection testing proves endpoint verification, not review delivery or application resumption.
Preserve the raw body in web frameworks
The SDK receiver must receive the bytes that arrived on the wire. Do not pass an object produced by a JSON body parser and do not serialize an object back to JSON for verification.
The customer application team owns and operates this listener. VeriProof delivers the signed outcome, and the SDK verifies it, but neither owns the application’s endpoint, checkpoint store, queue, worker, revision path, or protected side effect. The listener must stop after durable storage and worker signaling; it must not resume an agent or run a protected tool.
TypeScript with Express
Register a route-scoped raw parser before any JSON parser that can consume the same route:
import express from 'express'
const app = express()
app.post(
'/v1/review-outcomes',
express.raw({ type: 'application/json', limit: '64kb' }),
async (request, response) => {
if (!Buffer.isBuffer(request.body)) {
return response.status(400).json({ code: 'raw_body_required' })
}
const valuesByName = new Map<string, string[]>()
for (let index = 0; index < request.rawHeaders.length; index += 2) {
const name = request.rawHeaders[index].toLowerCase()
const values = valuesByName.get(name) ?? []
values.push(request.rawHeaders[index + 1])
valuesByName.set(name, values)
}
const result = await receiver.receive(
new Uint8Array(request.body),
{
getAll: name => valuesByName.get(name.toLowerCase()) ?? [],
},
)
return response.status(result.status).json(
result.receipt ?? { code: 'delivery_conflict' },
)
},
)
app.use(express.json())Do not put express.json() before this callback route. If a shared middleware,
gateway, or serverless adapter parses the body first, configure that layer to
retain and pass the original bytes.
The runnable TypeScript example uses Node’s HTTP server directly at
sdk/typescript/veriproof-sdk-core/examples/human-review-receiver.mjs.
Python with ASGI
Read the ASGI request body once and pass those bytes directly to the review
runtime that await vp.reviews(...) returns:
async def receive_outcome(request: Request) -> JSONResponse:
raw_body = await request.body()
headers: dict[str, list[str]] = {}
for name, value in request.scope["headers"]:
headers.setdefault(name.decode("latin-1"), []).append(value.decode("latin-1"))
try:
status, receipt = await reviews.receive(raw_body, headers)
except ReviewCallbackError as error:
status = 503 if error.code == "key_set_unavailable" else 400
receipt = {"code": error.code}
return JSONResponse(receipt, status_code=status)Do not call request.json() first and do not verify a JSON value reconstructed
by middleware. Preserve repeated security headers so the runtime can reject
duplicates.
The runnable Python example applies the same raw-bytes pattern with the standard
library HTTP server at sdk/python/veriproof-sdk/examples/human-review-webhook/app.py.
Durable receipt is not execution acknowledgment
For first acceptance, return HTTP 202 with a
durable-review-outcome-receipt body:
{
"schemaVersion": "1.0",
"receiptId": "receipt_01",
"deliveryId": "delivery_01",
"eventId": "event_01",
"state": "durably_received",
"durablyReceivedAt": "2026-07-25T12:00:00Z",
"replayed": false
}An exact replay returns the prior result with HTTP 200 and replayed: true.
A conflicting delivery returns HTTP 409. A durable receipt means only that
the application safely stored the event. It does not mean the protected action
resumed.
After the application applies the directive, send the execution acknowledgment
to the outcome’s acknowledgmentUrl. The body records one of:
resumedterminatedremained_pausedsupersededfailed
The acknowledgment response records the same state, its server timestamp, and whether the request was an exact replay.
When VeriProof is still recording the receipt
VeriProof records your durable receipt when your callback response reaches it.
If your application resumes quickly, its acknowledgment can arrive first. In
that case VeriProof answers HTTP 409 with a Retry-After header (in seconds)
and this body:
{
"error": "durable_receipt_pending",
"code": "durable_receipt_pending",
"message": "VeriProof is still recording the receipt for this delivery. Send the same acknowledgment again after the Retry-After interval."
}The X-Veriproof-Error-Code header carries the same code. Nothing was
recorded. Send the identical acknowledgment, with the same idempotency key,
after the Retry-After interval. The SDK acknowledgment clients do this for
you within their retry limit; if the receipt still isn’t recorded, they keep
the acknowledgment in your review store and send it on the next flush.
Every other 409 from this endpoint is final. Don’t retry it.
Outcome, directive, and resume-mode rules
| Outcome | Directive | Resume mode | Application behavior |
|---|---|---|---|
approved | may_resume | continue_original | After application-state and current VeriProof policy checks pass, resume once with the original protected payload. |
response_submitted | may_resume | resume_with_response | Give the bounded typed response and permitted replacements to the exact paused branch, then resume once. |
response_submitted | may_resume | route_to_revision | Enter the application’s revision path. Do not execute the superseded protected payload. |
response_submitted | remain_paused | none | Record the typed response and keep the checkpoint paused. |
rejected | must_not_resume | none | End the protected branch without releasing or executing the work. |
follow_up_required | remain_paused | none | Keep the checkpoint paused for a later decision version. |
cancelled | must_not_resume | none | End or supersede the paused work according to application state. |
expired | must_not_resume | none | Do not resume from an expired review result. |
response_submitted with continue_original is invalid. VeriProof and the SDK
fail closed on an unsupported outcome, directive, and resume-mode combination.
The callback may include application-visible typed answers, permitted
replace operations, feedback, and their binding digests. It must not include
reviewer identity, the private review note, raw evidence, checkpoint data, the
protected payload, prompt history, secrets, or unrelated context.
Do not dispatch by session alone, latest pause, action type, or branch label. The exact continuation, generation, and fingerprint must match.
For either advancing response_submitted mode, recheck the saved application
state and current VeriProof policy before work continues. A permitted
replacement must use op: "replace", target a registered JSON Pointer path,
match the registered value kind and classification, and match both the saved
protected-payload digest and editable-scope digest. Reject root replacement,
unregistered or sensitive paths, executable expressions, tool-name changes,
and add, remove, move, or copy. Apply the accepted replacement only to
the locally held payload.
Event order and replay
eventSequence and decisionVersion are monotonic for their bound case. A
duplicate event ID is the same business event. A later decision version may
supersede an earlier follow-up result, but it must not cause a second side
effect. Reject a result after deliveryExpiresAt and reject an outcome tied to
an old callback registration unless the registered replay policy explicitly
allows it.
Cancel a pending case
Cancellation binds the tenant, application, review case, workflow instance, branch, continuation, checkpoint generation, expected case version, and idempotency key. Supported reasons are:
end_user_cancelledparent_workflow_endedsafer_alternative_completedcheckpoint_unavailablecheckpoint_superseded
VeriProof can also cancel a pending case itself. You receive a cancelled
outcome with one of these reasons, and the action stays stopped:
application_lifecycle: the application was retired, the tenant was offboarded, or access was removed.policy_rule_withdrawn: every rule that asked for this review was turned off, set to observe or Shadow, no longer covers the application, or no longer escalates. If you retry the action, it is checked against current policy.
An exact idempotent replay returns the prior result. A conflicting request hash
returns HTTP 409. Cancellation must not erase an already recorded decision.