Send notifications
Proof doesn’t send email or keep a notification list: people are yours, so telling them things is too. Instead, Proof sends your server a webhook whenever something happens that someone might want to hear about, such as a comment, a reply, a mention or a seal. By the end of this guide, your server receives those events safely and turns them into your own bell and emails.
Set up an endpoint
Section titled “Set up an endpoint”-
Add the endpoint. In the console under Webhooks, or from code:
const endpoint = await proof.webhookEndpoints.create({url: "https://acme.com/proof/webhooks",events: ["thread.created", "thread.replied", "thread.message_edited", "thread.message_deleted", "seal.created"],});// endpoint.secret is "whsec_…": store it with your other secrets. It's shown only once.Use
["*"]to receive every event, including ones added later. -
Receive events with the raw body. The signature covers the exact bytes Proof sent, so read the body before any JSON parser touches it.
webhooks.ts import express from "express";app.post("/proof/webhooks", express.raw({ type: "application/json" }), async (req, res) => {let event;try {event = proof.webhooks.verify(req.body, req.get("Proof-Signature"), process.env.PROOF_WEBHOOK_SECRET);} catch {return res.sendStatus(400);}await queue.add("proof-event", event); // do the work elsewhereres.sendStatus(200);});verifychecks the HMAC and that the timestamp is within five minutes, then returns the parsed event. If you can’t use the SDK, the event reference shows how to check theProof-Signatureheader yourself. -
Answer quickly. Respond with any
2xxwithin 10 seconds, and do the real work afterwards. Anything else, or no answer, counts as a failure: Proof tries again with growing gaps for three days.
Handle each event once
Section titled “Handle each event once”Proof may send an event more than once, and doesn’t promise any order. Two habits cover both:
- Keep the ids you’ve handled. Every event has an
idsuch asevt_1Nf3Qa8Z. Skip any you’ve seen. - Don’t trust order. If you keep state from events, such as whether a thread is resolved, compare
created_atbefore overwriting, or read the current state from the API.
worker.process("proof-event", async (event) => { const fresh = await db.handledEvents.insertIfMissing(event.id); if (!fresh) return; await handle(event);});Turn events into notifications
Section titled “Turn events into notifications”Every event names who did it in actor: a person ({ type: "person", user }) or an agent acting for one ({ type: "agent", agent, user }). Don’t notify people about what they or their own agents did.
Thread events say whom they concern, so you needn’t ask the API: the document’s owners, the thread’s earlier participants, the suggestion_author of a thread on a suggestion, and the people the message mentions. Here is how Proof of Thought fills its bell from them:
async function handle(event) { const { data } = event; switch (event.type) { case "thread.created": case "thread.replied": { const message = event.type === "thread.created" ? data.thread.messages[0] : data.message; const threadId = event.type === "thread.created" ? data.thread.id : data.thread_id; // Owners, those who wrote in the thread, whose suggestion it's about, and those mentioned; never its author or their agent's person. const audience = new Set([...data.owners, ...data.participants, data.suggestion_author, ...data.mentions].filter(Boolean)); audience.delete(message.author.user); for (const user of audience) { await bell.add(user, { document: data.document, thread: threadId, message: message.id, kind: data.mentions.includes(user) ? "mention" : event.type === "thread.created" ? "comment" : "reply", from: message.author.name, excerpt: message.excerpt, }); } break; } case "thread.message_edited": { // Keep the excerpt current; those it newly mentions hear of it now. await bell.updateExcerpt(data.message.id, data.message.excerpt); for (const user of data.newly_mentioned) await bell.add(user, { document: data.document, thread: data.thread_id, message: data.message.id, kind: "mention" }); break; } case "thread.message_deleted": { if (data.thread_deleted) await bell.removeThread(data.document, data.thread_id); else await bell.removeMessage(data.message_id); break; } }}mention.created comes too, one per person mentioned, for a platform that only wants mentions. Use one or the other, or skip a mention you’ve already told someone of by its message_id.
A mention looks like this:
{ "id": "evt_1Nf3Qa8Z", "type": "mention.created", "created_at": "2026-10-30T14:12:09Z", "mode": "live", "actor": { "type": "agent", "agent": "agt_3Vn8Qx2K", "user": "usr_5Kq2Lm8R" }, "data": { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message_id": "msg_9Xc1", "mentioned": "usr_9Tb3Wc1X", "excerpt": "@Grace Hopper does this match your notes?" }}Here an agent wrote the comment for Ada (usr_5Kq2Lm8R), so it reaches Grace (usr_9Tb3Wc1X) but not Ada. Show it as Proof does, crediting both: “Claude Code (Ada Lovelace’s agent) mentioned you”.
Link to the right place
Section titled “Link to the right place”Events carry ids, not URLs, because the document lives at your URL. Build the link in your app, such as https://acme.com/notes/doc_8Hq2Lm4R?thread=thr_6Pz3, and open the editor at the comment with the target option:
mountEditor(element, { document: "doc_8Hq2Lm4R", getToken, target: { kind: "comment", id: "thr_6Pz3" } });When the person reads a thread in the editor, by opening it or writing in it, the editor sends a thread-read event: mark your notifications of it read.
Before showing notifications, check the person can still open each document: GET /v1/users/{id}/documents?ids=… answers with those they can, and their titles.
Most people want email for some of these, not all. Proof of Thought gathers what’s still unread in a document after 10 minutes into one email. You can do the same from your bell: when an item is still unread after a while, send it.
Keep your lists current
Section titled “Keep your lists current”Some events aren’t notifications but tell you to refresh something you keep:
document.created,document.renamed,document.trashed,document.restored,document.deleted: an agent or a person changed a document your lists show.agent.connected,agent.revoked: a person’s agents changed.seal.withdrawn: a seal link now says it was withdrawn.
- Webhook events: every event and its fields.
- Webhook endpoints in the API reference.