Webhooks
A webhook is how UpServe sends a notification to your server when a Turn or Run ends. A notification carries no raw result, only identifiers, a status, and lookup paths, so after receiving it you must re-read the current state with your API key.
Registering
Webhook management is a management API that uses the signed-in organization OWNER’s permission (JWT), not an API key. You can do it in the Webhooks tab of the developer screen (https://upserve.app/settings/developer), and the management API lives under /api/runtime/projects/{project_id}/webhooks. These paths are not part of the /v1 OpenAPI, and an execution API key cannot change a webhook address or secret.
# Management API: authenticated with the owner's login token (JWT), not an API key
curl -s -X POST "https://api.upserve.app/api/runtime/projects/$PROJECT_ID/webhooks" \
-H "Authorization: Bearer $OWNER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://receiver.example/upserve","event_types":["turn.completed","turn.failed","run.completed","run.failed"]}'- Only HTTPS addresses are allowed. A Project can have at most 10 endpoints that are not deleted.
- If you omit
event_types, you receive thecompleted,failed,canceled,expired, andaction_requiredevents of Turns and Runs.run.step.completedmarks only a step finishing, not the whole Run. - The response contains the server generated secret, shown only once. Keep it in your receiver’s secret store.
- While you rotate the secret (
POST /api/runtime/projects/{project_id}/webhooks/{endpoint_id}/rotate-secret), verify with both the old and the new secret. You may remove the old one after the 20 second maximum send time of requests already sent and the 300 second signature window have passed. - Changing the address or subscribed events cancels deliveries that were waiting, and past events are not automatically sent to the new address.
Notification body
The body is JSON of at most 4KiB.
{
"event_id": "7c2e0d44-0000-4000-8000-000000000008",
"type": "turn.completed",
"aggregate_kind": "SESSION",
"sequence": 4,
"occurred_at": "2026-10-05T09:00:20+00:00",
"session_id": "6f1c0a52-0000-4000-8000-000000000001",
"turn_id": "6f1c0a52-0000-4000-8000-000000000002",
"status_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/turns/6f1c0a52-0000-4000-8000-000000000002",
"outputs_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/turns/6f1c0a52-0000-4000-8000-000000000002",
"status": "SUCCEEDED"
}It contains no raw input or result, no files, and no download URLs. status_url and outputs_url are relative paths that need authentication. A Run notification has run_id and points to /v1/runs/{run_id}. Step events add step_id, and question and approval events add action_id.
Verifying the signature
Every request has an Upserve-Signature header.
Upserve-Signature: t=1791190820,v1=<64 hex characters>
signed_bytes = ASCII(timestamp) + "." + raw_body- The signature is the HMAC-SHA256 of the
timestamp, a., and the raw request bytes joined together, with the whole secret string as the key. - Do not verify against bytes you re-serialized after parsing the JSON. Use the bytes exactly as received.
- Compare in constant time (
hmac.compare_digest,timingSafeEqual).
import hashlib
import hmac
import json
import re
import time
def verify(body: bytes, header: str, secrets: list[str], *, now=None) -> dict:
if len(body) > 4096:
raise ValueError("Webhook body too large")
match = re.fullmatch(r"t=([0-9]{1,12}),v1=([0-9a-f]{64})", header)
if not match:
raise ValueError("Invalid signature header")
if abs((time.time() if now is None else now) - int(match[1])) > 300:
raise ValueError("Expired signature")
signed = match[1].encode("ascii") + b"." + body
if not any(hmac.compare_digest(hmac.new(s.encode("ascii"), signed, hashlib.sha256).hexdigest(), match[2])
for s in secrets):
raise ValueError("Invalid signature")
return json.loads(body)The TypeScript version is as follows.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(body: Buffer, header: string, secrets: string[], now = Math.floor(Date.now() / 1000)) {
if (body.length > 4096) throw new Error('Webhook body too large');
const match = /^t=([0-9]{1,12}),v1=([0-9a-f]{64})$/.exec(header);
if (!match || Math.abs(now - Number(match[1])) > 300) throw new Error('Invalid or expired signature');
const supplied = Buffer.from(match[2], 'hex');
const signed = Buffer.concat([Buffer.from(match[1] + '.', 'ascii'), body]);
if (!secrets.some((secret) => timingSafeEqual(createHmac('sha256', secret).update(signed).digest(), supplied))) {
throw new Error('Invalid signature');
}
return JSON.parse(body.toString('utf8'));
}Timestamp window
Reject a request whose t= time differs from now by more than 300 seconds (plus or minus 5 minutes). Retries and replays also compute a fresh timestamp and signature for each attempt, so your server clock must be accurate. A server with a skewed clock will reject legitimate notifications.
Deduplicating by event_id
Delivery is at least once. The same event can arrive several times, so store event_id with a UNIQUE constraint in durable storage and drop duplicates.
def accept(db, body: bytes, header: str, secrets: list[str]) -> bool:
event = verify(body, header, secrets)
with db:
inserted = db.execute(
"INSERT INTO webhook_inbox(event_id, body) VALUES (?, ?) ON CONFLICT(event_id) DO NOTHING",
(event["event_id"], body),
).rowcount
# Return 204 to the sender in both cases: a duplicate is already stored
return bool(inserted)- Once the signature is verified and the write succeeds, return
2xxright away. A duplicate also gets2xx. - Do heavy work in a separate job after you respond. If handling takes long inside the receiving request, it times out and is retried.
- Keep deduplication IDs for the replay horizon of at most 30 days.
- A retry and a replay of the same event have the same
event_idand body. Reject a different body that arrives with the sameevent_id.
Out-of-order delivery
Webhook delivery order is not guaranteed. A late progress notification can arrive after the completed notification of a finished Run. Compare sequence only within the same Session or Run. And do not overwrite your stored state with the status of a notification. When a notification arrives, read the current state with an authenticated API request and apply that.
# After accepting an event, read the authoritative state with your API key
curl -s "https://api.upserve.app/v1/runs/$RUN_ID" \
-H "Authorization: Bearer $UPSERVE_API_KEY"A webhook is not a signal to start work. Do not submit a new execution to recover from a communication problem. Do not follow an address contained in the notification. Build the lookup path yourself from the IDs in the notification and attach your configured API base URL. Do not follow redirects on that request either.
Retries and replay
- Only an HTTP
2xxis a success. Other responses and network errors are retried. - Retries come 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, and 24 hours after the first attempt. Including the first, there are at most 7 attempts, and if all fail the delivery status becomes
FAILED. The list ofFAILEDdeliveries acts as the DLQ. - A failed delivery does not change the success or failure status of the Turn or Run. A successful Run can have a failed delivery.
- Read the delivery history with
GET /api/runtime/projects/{project_id}/webhook-deliveries, and resend a retained event withPOST /api/runtime/projects/{project_id}/webhook-deliveries/{delivery_id}/replay. It needsIdempotency-Keyand is limited to 10 per minute per Project. - A replay only resends the notification. It does not rerun the original execution.
Network rules
- Only HTTPS is allowed, and the TLS certificate is verified.
- Redirects (3xx) are not followed and are recorded as failures. If you moved the address, change the endpoint to the new address.
- DNS is checked again on every attempt, and every address must be a public address. Internal networks, loopback, link-local, metadata addresses,
localhost,.local, and.internalare rejected. - One send takes at most 20 seconds including DNS.
- The failure history keeps only a fixed error code and the numeric HTTP status. The response body of your receiver is not stored.