Outbound webhooks
Get a signed HTTP call when something happens on a canvas you can open.
The app can POST a signed JSON event to URLs you register when something happens on a canvas you can open. Route comments to Slack, hand finished agent work to an issue tracker, or trigger an n8n flow: anything with an HTTP endpoint.
Setup
- Open Settings → Webhooks, paste the URL and pick the events.
- Copy the signing secret. It is shown once; Rotate secret issues a new one.
- Click Send test: the app POSTs a
pingand shows the HTTP status it got back.
A webhook belongs to your account and fires for every canvas you own, were invited to, or that lives in one of your teams. Canvases you only reach through a share link do not count. You can have up to 10.
Events
type | When | Extra fields |
|---|---|---|
comment.created | a comment is pinned to an element or to the canvas | actor, comment, frame (may be null) |
comment.replied | a reply lands in a thread | actor, comment, frame (may be null) |
comment.resolved | a thread is resolved | actor, comment, frame (may be null) |
frame.created | a frame is created, by a person or an agent | actor, frame |
task.completed | the built-in agent finishes a task | actor, task |
task.failed | the built-in agent gives up on a task | actor, task, reason |
ping | the Send test button | message |
Events fire the same way whether the change came from the browser or from an agent over MCP. Duplicating a page or a canvas does not fire frame.created for the copies.
Delivery
POST /your/url HTTP/1.1 Content-Type: application/json User-Agent: canvas-webhooks/1 X-Canvas-Event: comment.created X-Canvas-Delivery: evt_8f1Kq2pZ0mVt X-Canvas-Timestamp: 1791216000000 X-Canvas-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
{
"id": "evt_8f1Kq2pZ0mVt",
"type": "comment.created",
"at": 1791216000000,
"canvas": { "id": "4f6c…", "name": "Landing page", "url": "https://app.example.com/c/4f6c…" },
"actor": { "name": "Alice", "kind": "user" },
"comment": {
"id": "9a1e…",
"frameId": "b2d0…",
"pageId": null,
"x": 120,
"y": 48,
"parentId": null,
"from": "Alice",
"text": "Can the heading be larger?",
"at": 1791216000000,
"forAgent": false,
"targetAgent": null,
"resolvedBy": null,
"resolvedAt": null,
"url": "https://app.example.com/c/4f6c…?frame=b2d0…&comment=9a1e…"
},
"frame": { "id": "b2d0…", "name": "Hero", "x": 0, "y": 0, "width": 1440, "height": 900, "url": "…?frame=b2d0…" }
}
actor.kind is user or agent; on comment.resolved only the resolver's name is known. A reply reports its thread's anchor (frameId, pageId, x, y) and its comment link opens the thread. Frames carry name and geometry, never their HTML. Tasks carry id, title, agentName, queuedBy, startedAt, endedAt, failedAt, failureReason and frameIds. Times are milliseconds since the epoch.
Answer with any 2xx within 10 seconds. Redirects are not followed and count as failures. A failed delivery is retried after 10 s, 1 min and 5 min, then dropped. Retries are queued in the database, so they survive a restart. Before every attempt the app checks again that the webhook is on and that you can still open the canvas. A webhook that fails 100 deliveries in a row pauses itself; turn it back on in Settings.
Verifying the signature
The signature is HMAC-SHA256 over <X-Canvas-Timestamp>.<raw request body> with the webhook's secret, hex-encoded and prefixed with sha256=. Verify against the raw bytes, before any JSON parsing, and reject timestamps more than 5 minutes off to shut out replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, headers, rawBody) {
const timestamp = headers['x-canvas-timestamp'];
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return false;
const expected =
'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const given = headers['x-canvas-signature'] ?? '';
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
In n8n, a Webhook node with Raw body on, followed by a Crypto node (HMAC, SHA256, your secret, input {{ $headers['x-canvas-timestamp'] }}.{{ $rawBody }}) and an IF comparing it with the header, is the whole pipeline.
Private addresses
Because the server opens a connection to whatever URL you give it, addresses on its own network are refused by default: localhost, loopback, link-local, carrier-grade NAT, RFC 1918 / unique-local, reserved and multicast ranges, and the IPv6 forms that lead back to them (IPv4-mapped, NAT64, 6to4). They are checked when you save the URL and again at every delivery (DNS answers can change), and the connection goes only to the address that passed the check. A self-host whose n8n or Mattermost lives on the same network opts in by setting WEBHOOK_ALLOW_PRIVATE_URLS=true on both the web app and the worker.