Skip to content

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.

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:

  1. Receive a delivery. Verify the signature. Return 2xx immediately.
  2. Enqueue the session id.
  3. 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.

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.

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.

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

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 as invalid_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 3xx counts 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 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.

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.

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.

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.

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:

  1. Was the session created by the app installation, not by a human credential? No installation, no webhooks.
  2. Is the family subscribed? A publication-only endpoint sees no run events.
  3. Does the URL resolve publicly over HTTPS, with no redirect?
  4. Did your handler return a 2xx within ten seconds?
  5. Does the installation still hold wamp.cloud.sessions:read?