# Send notifications

> Receive Proof's webhooks, check their signatures, and turn comments, mentions and seals into your own notifications and emails.

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

<Steps>

1. **Add the endpoint.** In the console under **Webhooks**, or from code:

    ```ts
    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.

    ```ts title="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](/reference/webhooks/) 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.

</Steps>

## 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 `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.

```ts title="worker.ts"
worker.process("proof-event", async (event) => {
  const fresh = await db.handledEvents.insertIfMissing(event.id);
  if (!fresh) return;
  await handle(event);
});
```

## 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:

```ts title="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:

```json
{
  "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

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:

```ts
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=…`](/reference/api/users/#list-a-users-documents) answers with those they can, and their titles.

## Email

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

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.

## Next

- [Webhook events](/reference/webhooks/): every event and its fields.
- [Webhook endpoints](/reference/api/webhook-endpoints/) in the API reference.