Skip to content

Webhook events

View .md

Proof tells your server what happened in the editor by posting events to the endpoints you register in the console or with POST /v1/webhook-endpoints. Use them for what Proof leaves to you: notifications, emails, activity feeds and your own document lists. For a walkthrough, see Send notifications.

Every delivery is one event, posted as JSON:

{
"id": "evt_4Rk9Tn2Wq8Lm",
"type": "mention.created",
"created_at": "2026-10-30T14:02:11Z",
"mode": "live",
"actor": { "type": "person", "user": "usr_5Kq2Lm8R" },
"data": {
"document": "doc_8Hq2Lm4R",
"thread_id": "thr_6Pz3",
"message_id": "msg_9Xc1",
"mentioned": "usr_9Tb3Wc1X",
"excerpt": "@Grace Hopper could you check the second table?"
}
}
Field What it is
id The event’s id. A retried delivery has the same id.
type One of the event types below.
created_at When it happened, in UTC.
mode "test" or "live", matching the key that made the data.
actor Who did it. See below.
data What happened. Its shape depends on type.
actor.type Fields Who
person user One of your users, writing in the editor.
agent agent, user An agent, with user the person it acts for.
platform none Your own server, through the API.

Each delivery carries a Proof-Signature header:

Proof-Signature: t=1793455331,v1=5f1c0b8a9e3d4f27c6a1e0b9d8c7f6a5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9

t is when it was sent, in seconds since 1970. v1 is an HMAC-SHA256, in hex, of the timestamp, a full stop and the raw request body, keyed with the endpoint’s signing secret (whsec_…). To check a delivery:

  1. Read the raw body as bytes, before any JSON parsing; parsing and re-encoding changes it.
  2. Compute the HMAC of `${t}.${body}` with your secret.
  3. Compare it with each v1 in the header, in constant time. One must match.
  4. Refuse the delivery if t is more than five minutes from your clock.

The Node SDK does this for you with proof.webhooks.verify. By hand, in Node:

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyProofSignature(rawBody: Buffer, header: string, secret: string): boolean {
const pairs = header.split(",").map((part) => part.split("=") as [string, string]);
const t = Number(pairs.find(([key]) => key === "t")?.[1]);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return pairs
.filter(([key]) => key === "v1")
.some(([, hex]) => {
const given = Buffer.from(hex, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}

When you roll an endpoint’s secret in the console, deliveries carry two v1 signatures, one for each secret, for 24 hours. Code that accepts any matching v1 keeps working through the change.

  • Proof posts each event with Content-Type: application/json and User-Agent: Proof-Webhooks/1.
  • Answer with any 2xx status within 10 seconds. Do slow work after you answer, from a queue.
  • Anything else, or no answer, counts as a failure. Proof tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then every 12 hours, for 3 days in all.
  • An event can arrive more than once. Keep the ids you’ve handled and skip repeats.
  • Events can arrive out of order, especially after a retry. Where order matters, compare created_at, or read the current state from the API: GET /v1/documents/:id rather than the last document.renamed you saw.

An endpoint receives the types it was registered for, or all of them with ["*"]. New event types may be added to /v1 at any time, so ignore types you don’t know.

Type When
thread.created Someone started a comment thread.
thread.replied Someone replied in a thread.
thread.resolved A thread was resolved.
thread.reopened A resolved thread was reopened.
thread.message_edited Someone changed a message they wrote.
thread.message_deleted Someone deleted a message, or a whole thread.
mention.created Someone was mentioned in a comment.
document.created A document was created by an agent or through the API.
document.renamed A document’s name changed.
document.trashed A document went to the trash.
document.restored A document came back from the trash.
document.deleted A document was deleted for good.
seal.created A document was sealed.
seal.withdrawn A seal was withdrawn.
agent.connected A person approved an agent.
agent.revoked An agent was removed.

suggestion.created, suggestion.accepted, suggestion.rejected and checkin.missed are reserved: an endpoint may name them, but Proof doesn’t send them yet.

The examples below show data only; every event has the envelope above around it.

The thread events carry what you need to decide whom to notify without asking the API:

Field What it is
owners The document’s owners, by Proof id.
participants Everyone who wrote in the thread before this message, by Proof id; an agent’s messages count for its person.
suggestion_author For a thread on a suggestion, the person who proposed it (an agent’s person, even once the agent was removed), or null.
mentions The people this message mentions, but its author.

Each message carries its author as an actor, with the name it was written under (an agent’s own), its plain-text body, and an excerpt of it cut to about 200 characters.

A comment thread started, on a stretch of text (quote) or on a suggestion (suggestion_id). Mentions in its first message also send mention.created.

{
"document": "doc_8Hq2Lm4R",
"thread": {
"id": "thr_6Pz3",
"quote": "the engine might compose elaborate pieces of music",
"suggestion_id": null,
"messages": [
{
"id": "msg_9Xc1",
"author": { "type": "person", "user": "usr_5Kq2Lm8R", "name": "Ada Lovelace" },
"body": "Is there a source for this? @Grace Hopper",
"excerpt": "Is there a source for this? @Grace Hopper",
"created_at": "2026-10-30T14:02:11Z"
}
]
},
"participants": [],
"suggestion_author": null,
"owners": ["usr_5Kq2Lm8R"],
"mentions": ["usr_9Tb3Wc1X"]
}

participants is everyone who wrote in the thread before this reply. See Who to tell.

{
"document": "doc_8Hq2Lm4R",
"thread_id": "thr_6Pz3",
"message": {
"id": "msg_2Vb7",
"author": { "type": "agent", "agent": "agt_3Hs8", "user": "usr_5Kq2Lm8R", "name": "Claude Code" },
"body": "Menabrea's notes, 1842. I added the citation.",
"excerpt": "Menabrea's notes, 1842. I added the citation.",
"created_at": "2026-10-30T14:05:40Z"
},
"participants": ["usr_5Kq2Lm8R", "usr_9Tb3Wc1X"],
"suggestion_author": null,
"owners": ["usr_5Kq2Lm8R"],
"mentions": []
}
{ "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3" }

Sent when someone reopens a resolved thread, including by replying in it.

{ "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3" }

A message says something new. It carries the message as it is now, with the fields above, kind (comment for a thread’s first message, reply otherwise), and newly_mentioned: the people it mentions now and didn’t before, who also get mention.created.

{
"document": "doc_8Hq2Lm4R",
"thread_id": "thr_6Pz3",
"message": {
"id": "msg_9Xc1",
"author": { "type": "person", "user": "usr_5Kq2Lm8R", "name": "Ada Lovelace" },
"body": "Is there a source for this? @Grace Hopper @Charles Babbage",
"excerpt": "Is there a source for this? @Grace Hopper @Charles Babbage",
"created_at": "2026-10-30T14:02:11Z"
},
"kind": "comment",
"participants": [],
"suggestion_author": null,
"owners": ["usr_5Kq2Lm8R"],
"mentions": ["usr_9Tb3Wc1X", "usr_2Hd7Np4Q"],
"newly_mentioned": ["usr_2Hd7Np4Q"]
}

thread_deleted is true when the thread went with it, as when its first message is deleted. Remove any notification you made of it.

{ "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message_id": "msg_2Vb7", "thread_deleted": false }

One event per person mentioned, whether in a new thread, a reply, or an edited message that newly mentions them. Notify mentioned; the actor is who wrote the mention.

{
"document": "doc_8Hq2Lm4R",
"thread_id": "thr_6Pz3",
"message_id": "msg_9Xc1",
"mentioned": "usr_9Tb3Wc1X",
"excerpt": "@Grace Hopper could you check the second table?"
}

Sent for documents an agent starts with create_document, and for those you create through the API, so your document lists stay complete either way. data.document is the document object.

{
"document": {
"id": "doc_2Mw5Rt9K",
"name": null,
"title": "Reading notes: Menabrea",
"owners": ["usr_5Kq2Lm8R"],
"link_access": "restricted",
"created_at": "2026-10-30T15:11:02Z",
"edited_at": "2026-10-30T15:11:02Z",
"trashed_at": null
}
}

name is null when someone cleared the name and the document is called by its first line again. title is what to show either way.

{ "document": "doc_8Hq2Lm4R", "name": "On the Analytical Engine", "title": "On the Analytical Engine" }

Sent when an owner moves it to the trash for everyone. A member putting it in their own trash sends nothing.

{ "document": "doc_8Hq2Lm4R", "delete_at": "2026-11-29T15:20:00Z" }
{ "document": "doc_8Hq2Lm4R" }

The document is gone for everyone, its seals are withdrawn, and no further events will name it.

{ "document": "doc_8Hq2Lm4R" }

Sent whether the document was sealed through the API or with the editor’s Seal button. data.seal is the seal object.

{
"seal": {
"code": "AH271NSHS",
"url": "https://proof.exalto.ai/AH271NSHS",
"document": "doc_8Hq2Lm4R",
"version": 1,
"sealed_at": "2026-10-30T16:31:00Z",
"sealed_by": "usr_5Kq2Lm8R",
"method": "v0.2",
"line": "proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min",
"composition": { "typed": 71, "shaped": 16, "ai": 8, "external": 5 },
"presence": { "share": 97, "verified_share": 0, "verified": false },
"prompts": 4,
"sittings": 2,
"duration": "1hr48min",
"attested_by": { "platform": "acme", "key_ids": ["key_5Jd8Wq"] },
"withdrawn_at": null
}
}

The seal’s page now says it was withdrawn.

{ "code": "AH271NSHS", "document": "doc_8Hq2Lm4R" }

Sent when a person approves a client they hadn’t connected before; approving the same one again changes its access and sends nothing. data.agent is the agent object.

{
"agent": {
"id": "agt_3Hs8",
"user": "usr_5Kq2Lm8R",
"name": "Claude Code",
"host": "claude.ai",
"connected_at": "2026-10-30T13:40:12Z",
"last_used_at": null,
"access": "all"
}
}

Sent when the agent’s person or your server removes it, and when its person is deleted. Its tokens no longer work.

{ "agent_id": "agt_3Hs8", "user": "usr_5Kq2Lm8R" }