Receive webhooks
After this page you can register an HTTPS endpoint, verify that a request genuinely came from WAMP, and process deliveries without double-acting on a retry. Webhooks replace the polling loop; they do not replace the event log.
What webhooks are for
Section titled “What webhooks are for”A webhook tells you that something happened in a session. It is not the
authoritative record of what happened — that is
GET /v1/sessions/{sessionId}/events, which is ordered and gap-free. Delivery
is at-least-once, unordered relative to other deliveries, and covers a subset of
the event catalog.
The pattern that works is therefore:
- Receive a delivery. Verify the signature. Return
2xximmediately. - Enqueue the session id.
- In your worker, page events from the cursor you persisted for that session.
That gives you push latency with pull correctness. Acting directly on webhook payloads and never paging works until the first redelivery or the first transcript you needed, and then it does not.
Three limits to know before you build
Section titled “Three limits to know before you build”Webhooks fire only for sessions your app created. The delivery pipeline is keyed on the app installation that created the session. A session created by a human-delegated credential, or in the WAMP Cloud web app, emits no webhooks at all — silently. If your integration creates sessions with an installation token, you are fine; if it does not, webhooks are not available to you and you should poll.
Only four coarse families are deliverable. You subscribe to families, not event types, and the five timeline event types are never pushed:
| Family | Event types delivered |
|---|---|
run |
wamp.run.started, wamp.run.completed, wamp.run.failed, wamp.run.cancelled, wamp.run.crashed |
interaction |
wamp.interaction.opened, wamp.interaction.resolved, wamp.interaction.expired |
artifact |
wamp.artifact.created, wamp.artifact.omitted |
publication |
wamp.publication.created, wamp.publication.failed, wamp.publication.merge_succeeded, wamp.publication.merge_failed |
That is 14 of the 19 event types. The transcript events —
wamp.message.created, wamp.activity.completed, wamp.context.compacted,
wamp.conversation.cleared, wamp.timeline.truncated — are deliberately not
pushed. They are recovered by paging events after a run delivery, because
pushing conversation content would widen what an installation receives beyond
what the organization consented to.
Delivery depends on a live read capability. Deliveries are gated on the
installation still holding wamp.cloud.sessions:read. If that capability is
revoked, webhooks stop arriving without an error on your side.
Register an endpoint
Section titled “Register an endpoint”Registration is an authenticated call the app owner makes as themselves. An installation token cannot register a webhook — that is deliberate, so a stolen backend credential cannot be turned into a durable exfiltration channel by pointing deliveries at an attacker’s host. There is no console screen for this today; you make the HTTP call with your own WAMP user credential.
curl -sS -X POST \ "$WAMP_API/api/apps/$APP_SLUG/installations/$INSTALLATION_ID/webhooks" \ -H "Authorization: Bearer $OWNER_USER_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "url": "https://hooks.example.com/wamp", "families": ["run", "publication"] }'{ "success": true, "endpoint": { "id": "e2f1a4c8-7d3b-4c19-9a0e-5b6d8f2c1a30", "url": "https://hooks.example.com/wamp", "families": ["run", "publication"] }, "secret": "whsec_9Fj2K…"}The secret is shown exactly once, in this 201 response. Store it in your
secret manager before you do anything else. It cannot be retrieved later; the
only recovery is to delete the endpoint and register a new one.
| Field | Rule |
|---|---|
url |
Valid URL, at most 2048 characters, HTTPS, publicly resolvable |
families |
1 to 4 of run, interaction, artifact, publication |
Other operations on the same path: GET lists an installation’s endpoints
(without secrets), and DELETE …/webhooks/{endpointId} removes one. At most
five endpoints per installation; a sixth registration is 409 endpoint_limit_reached. Registration errors are 400 invalid_url,
400 invalid_event_families, and 404 installation_not_found.
URL requirements
Section titled “URL requirements”These are enforced at registration and re-checked on every delivery, and they are the most common cause of a webhook that “never arrives”:
- HTTPS only. An
http://URL is rejected asinvalid_url. - Public hosts only. Loopback, private ranges, link-local and cloud metadata addresses are refused — as hostnames and as IP literals. A tunnel to localhost will not work; use a publicly reachable staging host.
- DNS is resolved and pinned. The connection goes to the validated address with TLS SNI set to your hostname, so a name that resolves to a private address at delivery time fails rather than connecting.
- Redirects are not followed. A
3xxcounts as a failed delivery and is retried. Register the final URL. - Success is any
2xx. Everything else retries. - Ten-second timeout per attempt, measured on your response.
The payload
Section titled “The payload”The body is the canonical session event envelope, unwrapped — byte-identical to
an element of the events[] array from
GET /v1/sessions/{sessionId}/events. One producer, one shape:
{ "id": "6f1c1d80-6c2a-4f0e-9d5f-0c2a2f0f4b11", "type": "wamp.run.completed", "createdAt": "2026-08-11T09:00:04.221Z", "sequence": 42, "visibility": "summary", "subject": { "sessionId": "3f7d4f4c-2b6a-4a2e-9c1a-1f2b3c4d5e6f", "runId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" }, "data": { "status": "completed", "stopReason": "completed", "costUsd": 0.0123, "turns": 3 }}Because sequence is present, a delivery doubles as a cursor hint: if it is
greater than the cursor you have stored for that session, there are events to
page. Do not treat it as your cursor, though — you may receive sequence: 42
before you have processed 30.
Request headers on every delivery:
| Header | Value |
|---|---|
Content-Type |
application/json |
Wamp-Signature |
t=<unix-seconds>,v1=<hex-hmac-sha256> |
Content-Length |
Byte length of the body |
Host |
Your registered host |
The method is always POST. Payload field sets grow additively — ignore
type values and data fields you do not recognize instead of rejecting them.
Inbound GitHub webhooks that WAMP itself receives use
X-Hub-Signature-256. That is a different header on a different endpoint and has nothing to do with verifying the deliveries described here.
Verify the signature
Section titled “Verify the signature”The header carries a timestamp and one HMAC:
Wamp-Signature: t=1786518004,v1=3f8a…64 lowercase hex characters…The signed input is the string <timestamp> + . + <raw request body>, and
the MAC is HMAC-SHA256 keyed with the endpoint secret, hex-encoded:
signature = hex(HMAC_SHA256(secret, `${t}.${rawBody}`))Two rules make or break this:
- Verify the raw bytes. Parsing JSON and re-serializing changes whitespace and key order, which changes the MAC. Capture the body as a string or buffer before any body parser touches it.
- Check freshness. Reject a signature whose timestamp is more than 300 seconds away from now, in either direction. That bounds replay.
A complete Express receiver:
import express from 'express';import { createHmac, timingSafeEqual } from 'node:crypto';
const SIGNATURE_RE = /^t=(\d+),v1=([0-9a-f]{64})$/;const TOLERANCE_SEC = 300;
function verify(rawBody, header, secret) { const match = SIGNATURE_RE.exec(String(header ?? '').trim()); if (!match) return false;
const timestamp = Number(match[1]); if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SEC) return false;
const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest(); const provided = Buffer.from(match[2], 'hex'); return provided.length === expected.length && timingSafeEqual(provided, expected);}
const app = express();
// express.raw, NOT express.json — the HMAC covers the exact bytes.app.post('/wamp', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => { const raw = req.body.toString('utf8');
if (!verify(raw, req.get('Wamp-Signature'), process.env.WAMP_WEBHOOK_SECRET)) { res.status(401).end(); return; }
const event = JSON.parse(raw);
// Acknowledge first; do the work asynchronously. A slow handler is a retry. res.status(204).end();
void enqueue(event); // your own durable queue});If you use the Node SDK, verifyWebhook implements exactly this, including the
constant-time comparison and the 300-second window:
import { verifyWebhook } from '@wamp/app-sdk';
const ok = verifyWebhook({ payload: raw, // the raw body string signature: req.get('Wamp-Signature'), secret: process.env.WAMP_WEBHOOK_SECRET,});See SDK for the package’s current availability.
Retries and redelivery
Section titled “Retries and redelivery”| Property | Value |
|---|---|
| Attempts | 6, then the delivery is permanently failed |
| Backoff | 1, 2, 4, 8, 16, 32 minutes (doubling, capped at 6 hours) |
| Timeout | 10 seconds per attempt |
| Retried on | Any non-2xx, including 3xx, plus timeouts and connection errors |
| Guarantee | At-least-once |
Two consequences for your handler:
Answer fast. The clock is on your response, not on your processing. Verify,
enqueue, return 204. A handler that does the work inline and takes eleven
seconds gets retried even though it succeeded — and now you have a duplicate.
Expect duplicates. WAMP can deliver the same event more than once: a worker can die after your server accepted the request but before the outcome is recorded. Nothing about that is unusual, and the fix is on the receiving side.
Idempotency on your side
Section titled “Idempotency on your side”The payload’s id is a stable UUID for the event. It is the same value on every
redelivery, and the same value you would read from the events endpoint. Use it
as your deduplication key:
create table wamp_event_seen ( event_id uuid primary key, received_at timestamptz not null default now());async function enqueue(event) { const inserted = await db.query( `insert into wamp_event_seen (event_id) values ($1) on conflict (event_id) do nothing`, [event.id], ); if (inserted.rowCount === 0) return; // already handled await handle(event);}Insert the marker in the same transaction as the effect it guards, or you have swapped one race for another. If the effect is external and not transactional (charging a card, sending mail), make that call idempotent with the event id as its own idempotency key.
For per-session state, prefer the monotonic sequence over a set of ids:
store last_sequence per session, and drop any delivery whose sequence is not
greater than it. That is both a dedupe and an ordering fix, and it composes
with the paging loop you already need.
Verify it end to end
Section titled “Verify it end to end”There is no test-delivery endpoint. The reliable smoke test is a real one:
create a session with your installation token, submit a trivial turn, and wait
for the wamp.run.completed delivery.
If nothing arrives, check in this order:
- Was the session created by the app installation, not by a human credential? No installation, no webhooks.
- Is the family subscribed? A
publication-only endpoint sees no run events. - Does the URL resolve publicly over HTTPS, with no redirect?
- Did your handler return a
2xxwithin ten seconds? - Does the installation still hold
wamp.cloud.sessions:read?
Related
Section titled “Related”- Follow a run live — the event log the webhook points at.
- Events reference — every type and payload, including the five that are never delivered.
- Drive Cloud from a backend — where the secret and the signing key live.