# Webhook events

> Every event Proof sends to your webhook endpoints, how deliveries are signed, and how they are retried.

Proof tells your server what happened in the editor by posting events to the endpoints you register in the [console](https://developer.proof.exalto.ai/console) or with [`POST /v1/webhook-endpoints`](/reference/api/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](/guides/notifications/).

## The event

Every delivery is one event, posted as JSON:

```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](#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

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

Each delivery carries a `Proof-Signature` header:

```http
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](/reference/node/#webhooks) does this for you with `proof.webhooks.verify`. By hand, in Node:

```ts title="verify.ts"

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

- 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`](/reference/api/documents/) 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.

## Event types

| Type | When |
| --- | --- |
| [`thread.created`](#threadcreated) | Someone started a comment thread. |
| [`thread.replied`](#threadreplied) | Someone replied in a thread. |
| [`thread.resolved`](#threadresolved) | A thread was resolved. |
| [`thread.reopened`](#threadreopened) | A resolved thread was reopened. |
| [`thread.message_edited`](#threadmessage_edited) | Someone changed a message they wrote. |
| [`thread.message_deleted`](#threadmessage_deleted) | Someone deleted a message, or a whole thread. |
| [`mention.created`](#mentioncreated) | Someone was mentioned in a comment. |
| [`document.created`](#documentcreated) | A document was created by an agent or through the API. |
| [`document.renamed`](#documentrenamed) | A document's name changed. |
| [`document.trashed`](#documenttrashed) | A document went to the trash. |
| [`document.restored`](#documentrestored) | A document came back from the trash. |
| [`document.deleted`](#documentdeleted) | A document was deleted for good. |
| [`seal.created`](#sealcreated) | A document was sealed. |
| [`seal.withdrawn`](#sealwithdrawn) | A seal was withdrawn. |
| [`agent.connected`](#agentconnected) | A person approved an agent. |
| [`agent.revoked`](#agentrevoked) | 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

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

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`](#mentioncreated).

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

`participants` is everyone who wrote in the thread before this reply. See [Who to tell](#who-to-tell).

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

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

### thread.reopened

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

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

### thread.message_edited

A message says something new. It carries the message as it is now, with the [fields above](#who-to-tell), `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`](#mentioncreated).

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

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

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

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

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

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](/reference/api/documents/).

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

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

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

### document.trashed

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

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

### document.restored

```json
{ "document": "doc_8Hq2Lm4R" }
```

### document.deleted

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

```json
{ "document": "doc_8Hq2Lm4R" }
```

### seal.created

Sent whether the document was sealed through the API or with the editor's Seal button. `data.seal` is the [seal object](/reference/api/seals/).

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

The seal's page now says it was withdrawn.

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

### 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](/reference/api/agents/).

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

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

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