Skip to Content

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 the completed, failed, canceled, expired, and action_required events of Turns and Runs. run.step.completed marks 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 2xx right away. A duplicate also gets 2xx.
  • 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_id and body. Reject a different body that arrives with the same event_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 2xx is 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 of FAILED deliveries 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 with POST /api/runtime/projects/{project_id}/webhook-deliveries/{delivery_id}/replay. It needs Idempotency-Key and 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 .internal are 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.