Skip to content

Send notifications

View .md

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.

  1. 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.

  2. 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 elsewhere
    res.sendStatus(200);
    });

    verify checks 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 the Proof-Signature header yourself.

  3. Answer quickly. Respond with any 2xx within 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.

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 id such as evt_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_at before overwriting, or read the current state from the API.
worker.ts
worker.process("proof-event", async (event) => {
const fresh = await db.handledEvents.insertIfMissing(event.id);
if (!fresh) return;
await handle(event);
});

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:

notify.ts
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”.

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.

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.