Webhook events
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.
The event
Section titled “The event”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. |
The actor
Section titled “The actor”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. |
Verify the signature
Section titled “Verify the signature”Each delivery carries a Proof-Signature header:
Proof-Signature: t=1793455331,v1=5f1c0b8a9e3d4f27c6a1e0b9d8c7f6a5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9t 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:
- Read the raw body as bytes, before any JSON parsing; parsing and re-encoding changes it.
- Compute the HMAC of
`${t}.${body}`with your secret. - Compare it with each
v1in the header, in constant time. One must match. - Refuse the delivery if
tis more than five minutes from your clock.
The Node SDK does this for you with proof.webhooks.verify. By hand, in Node:
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.
Delivery
Section titled “Delivery”- Proof posts each event with
Content-Type: application/jsonandUser-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/:idrather than the lastdocument.renamedyou 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.
Event types
Section titled “Event types”| 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.
Who to tell
Section titled “Who to tell”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.
thread.created
Section titled “thread.created”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"]}thread.replied
Section titled “thread.replied”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": []}thread.resolved
Section titled “thread.resolved”{ "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3" }thread.reopened
Section titled “thread.reopened”Sent when someone reopens a resolved thread, including by replying in it.
{ "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3" }thread.message_edited
Section titled “thread.message_edited”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.message_deleted
Section titled “thread.message_deleted”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 }mention.created
Section titled “mention.created”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?"}document.created
Section titled “document.created”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 }}document.renamed
Section titled “document.renamed”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" }document.trashed
Section titled “document.trashed”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.restored
Section titled “document.restored”{ "document": "doc_8Hq2Lm4R" }document.deleted
Section titled “document.deleted”The document is gone for everyone, its seals are withdrawn, and no further events will name it.
{ "document": "doc_8Hq2Lm4R" }seal.created
Section titled “seal.created”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 }}seal.withdrawn
Section titled “seal.withdrawn”The seal’s page now says it was withdrawn.
{ "code": "AH271NSHS", "document": "doc_8Hq2Lm4R" }agent.connected
Section titled “agent.connected”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" }}agent.revoked
Section titled “agent.revoked”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" }