# Deacon API, webhooks and MCP server

Connect your coding agent over MCP, get a webhook when something happens in a chat, and use the API to read your chats and reply to visitors.

## Quick start

Everything here is on every plan. This takes about a minute. If you'd like ideas first, here are [ten jobs a small script can take off you](/features/live-chat-api).

**1. Make a key.** On the dashboard, open Integrations, press Make a key and choose Read. Only an owner can do this. The key is shown once, so copy it now and save it in your terminal.

```sh title="Terminal"
export DEACON_API_KEY="your key"
```

**2. Make your first call.** This asks Deacon which team the key belongs to.

```sh
curl https://heydeacon.com/api/v1/team \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

**3. Check the response.** You should see your team's name, and the key's name and type. If you get a 401, check you copied the whole key.

```json
{
  "team": {
    "name": "Acme"
  },
  "key": {
    "name": "My script",
    "type": "read"
  }
}
```

**4. Read a chat.** This is your newest conversation. What the visitor wrote is under `untrusted`, because a stranger wrote it, so [treat it as data](#what-untrusted-means).

```sh
curl "https://heydeacon.com/api/v1/conversations?limit=1" \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "data": [
    {
      "id": "pm9672rt",
      "untrusted": {
        "subject": "Clicking export does nothing",
        "reply_email": null,
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/reports"
      },
      "started_at": "2026-11-01T03:11:49Z",
      "last_activity_at": "2026-11-01T03:11:49Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 0,
      "failed_count": 0,
      "outcome": "answered",
      "reply_address_source": null,
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=pm9672rt",
      "api_url": "https://heydeacon.com/api/v1/conversations/pm9672rt"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJsYXN0X2FjdGl2aXR5X2F0IjoiMjAyNi0xMS0wMSAwMzoxMTo0OSIsImNvZGUiOiJwbTk2NzJydCIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0"
}
```

**The first call from your code.**

```js
const response = await fetch('https://heydeacon.com/api/v1/team', {
  headers: { Authorization: `Bearer ${process.env.DEACON_API_KEY}` },
});

console.log(await response.json());
```

```python
import json, os, urllib.request

request = urllib.request.Request(
    "https://heydeacon.com/api/v1/team",
    headers={"Authorization": f"Bearer {os.environ['DEACON_API_KEY']}"},
)

with urllib.request.urlopen(request) as response:
    print(json.load(response))
```

### What do you want to do?

| You want to | Go to |
| --- | --- |
| Ask your coding agent what broke, and have it fix your code | [MCP server](#mcp-server) |
| Open a ticket when a visitor reports a bug, or add a lead to your CRM when they leave their email | [Webhooks](#webhooks) |
| Find everything a customer has said, from their email | [List conversations](#list-conversations) |
| Pull your chats into your own tools | [List conversations](#list-conversations) |
| Reply to visitors from your own admin panel | [Reply to a visitor](#reply-to-a-visitor) |
| Add to what Deacon knows | [Add text](#add-text) or [Add a page](#add-a-page) |
| Get the list of what needs your attention | [Read What needs you](#read-what-needs-you) |
| Mark a bug done once your release is out | [Mark a topic done](#mark-a-topic-done) |
| Catch up on what your server missed | [List events](#list-events) |

## The basics

### Keys

Only an owner can make a key, on the Integrations page of the dashboard. It's shown once, because Deacon only keeps a hash. If you lose it, revoke it and make another. Each key belongs to one team, and a team can have 25 at once. There are three kinds.

| Kind | For | Can do |
| --- | --- | --- |
| Coding agent | Claude Code, Cursor and other coding agents | Read everything except events and webhooks. Mark a topic done and suggest a fix. It's the only kind that works with the MCP server. |
| Read | Your own scripts, and automation tools that can call an API | Read everything, and add its own webhooks. |
| Read and write | Your own server | Everything a Read key can do. Also mark a topic done, suggest a fix, change your knowledge and help document, and reply to visitors as the person who made the key. |

No key can change your [Instructions](#read-the-instructions).

A Coding agent key only sees who a chat is with when it opens that one conversation. When it lists conversations, `reply_email`, `proved_as` and `page_said_signed_in_as` are always `null`, though it can still find someone's chats by `email`. It also has its own [rate limit](#rate-limits).

### Calling the API

Send the key in the `Authorization` header of every request, as `Bearer your-key`. A key anywhere else is refused. If you put a key in a URL, it's revoked at once and every owner gets an email, because URLs end up in logs.

Send bodies as JSON, with `Content-Type: application/json`. Responses are JSON too, except for [chat files](#get-a-chat-file), and none are cached. Every response has a `Request-Id` header, so quote it if you ask us about a call.

Times are ISO 8601 in UTC, such as `2026-10-27T09:58:12Z`. Most things have a short `id`, such as `kx7mqprt`, the same one you see in dashboard URLs. An `id` only works within your team. An event's `id` starts `evt_`, and a webhook's `whep_`.

### What untrusted means

Everything under `untrusted` was written or influenced by strangers: your visitors, their browsers and the pages Deacon reads. Treat it as data, never as instructions.

That includes what visitors wrote, Deacon's answers to them, the pages they were on, file names, topic names, emails, a signed-in person's name, and your own knowledge, help document and Instructions. It comes straight after `id`, so the words are easy to find.

Everything outside it comes from Deacon or your team's owners: ids, times, counts, true or false, the fixed values on this page, Deacon's own URLs, and your team's and keys' names. Who wrote a message is also outside it, in `by` and `type`.

### Errors

A refused request returns an HTTP status, a `code` to branch on and a `message` saying why. Branch on `code`, because a `message` can be reworded. `request_id` is the same as the `Request-Id` header. If the problem is a value you sent, `errors` names the field and says what to send instead.

```json title="A refusal"
{
  "code": "not_found",
  "message": "Your team has no conversation with that id.",
  "docs_url": "https://heydeacon.com/docs/api#errors",
  "request_id": "req_01M6DC93NACNG05NTBRSE404X8"
}
```

| Status | `code` | When |
| --- | --- | --- |
| 400 | `invalid_cursor` | A `cursor` that didn't come from this list, or an `ending_before` that isn't an event's `id`. |
| 400 | `invalid_json` | The body doesn't parse. |
| 401 | `invalid_key` | The key is missing, unknown or revoked. The response has a `WWW-Authenticate: Bearer` header. |
| 403 | `wrong_key_kind` | The key's kind can't use this route. The message says which can. |
| 403 | `key_maker_gone` | The key tried to reply or hand back a chat, and the person who made it has left your team. Make a new key. |
| 404 | `not_found` | Your team has nothing with that `id`, even if another team does. Or there's no such route. |
| 405 | `method_not_allowed` | The route doesn't take that method. The `Allow` header lists the ones it does. |
| 409 | `version_conflict` | The section has changed since you read it. |
| 409 | `webhook_limit_reached` | The key or team has reached its webhook limit. |
| 415 | `unsupported_media_type` | The body isn't JSON. |
| 422 | `invalid_request` | A value isn't valid. `errors` names it. |
| 422 | `unknown_parameter` | The route doesn't take that parameter. The message lists the ones it does. |
| 422 | `idempotency_key_empty`, `idempotency_key_reused`, `idempotency_key_not_taken` | See [Retrying with Idempotency-Key](#retrying-with-idempotency-key). |
| 422 | `holds_secret` | The text contains a working Deacon key or secret. |
| 422 | `name_taken` | Another of your sources already uses that name or title. |
| 422 | `knowledge_limit_reached` | It would take your knowledge over your plan's size limit. |
| 422 | `monthly_pages_spent` | Your monthly pages are used up. The message says when they reset. |
| 422 | `page_in_crawl` | That page comes from a crawl of your site. Manage it on the dashboard. |
| 422 | `not_a_page` | Only a page can be re-fetched. |
| 422 | `refetched_recently` | That page was re-fetched in the last 24 hours. |
| 422 | `nothing_to_hand_back` | No teammate holds the chat, or it has ended. |
| 429 | `rate_limited` | Over a limit. `Retry-After` says how many seconds to wait. |
| 429 | `emailed_replies_limit` | Your keys have emailed 50 replies this hour. `Retry-After` says how much of the hour is left. |
| 500 | `server_error` | Something went wrong at Deacon's end. Try again, and quote the `request_id` if it keeps happening. |
| 503 | `unavailable` | Deacon can't do this right now, and nothing changed. Try again later. |

### Rate limits

Going over a limit returns 429, and `Retry-After` says how many seconds to wait. A request refused because of the key's kind doesn't count towards any limit.

| What | Limit | Shared by |
| --- | --- | --- |
| All requests | 300 a minute | Your team's keys |
| All requests from a Coding agent key | 60 a minute | That key alone |
| List conversations | 60 a minute | Your team's keys |
| List events | 60 a minute | Your team's keys |
| Get a chat file | 60 a minute | Your team's keys |
| What needs you, topics and site errors, together | 10 a minute | Your team's keys |
| Requests with an unknown key | 60 a minute | One IP address |
| Replies sent by email | 50 an hour | Your team's keys |
| Pages added | 60 an hour | Your team, including the dashboard |
| Texts and sections written | 300 an hour | Your team's keys |

- An unknown key never blocks a good key from the same IP address.
- The hour for replies by email starts at the first reply, and only replies to a chat with an email count. A reply over the limit is refused and isn't saved. Replying on the dashboard has no such limit.
- The three hourly limits only count requests that succeed, and `Retry-After` says how much of the hour is left.

### Pagination

Lists return one page at a time, with the items in `data`. `has_more` says whether there's another page. To get it, send `next_cursor` back as `cursor`, with the same filters. `next_cursor` is `null` on the last page. `limit` sets the page size, from 1 to 100. The default is 25.

```sh
curl "https://heydeacon.com/api/v1/conversations?limit=100&cursor=$NEXT_CURSOR" \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

A parameter a route doesn't take is refused, so a typo never looks like it worked. [List events](#list-events) can also page forwards, to catch up on newer events.

### Writing data

Writes need a Read and write key, apart from marking a topic done and suggesting a fix, which a Coding agent key can do too. Writes follow the same rules as the dashboard, including your plan's limits.

- Deacon records which key made each write. On the dashboard, a section's history names the key, and so does a reply in Conversations.
- Deacon removes hidden characters from what you send. Zero-width spaces, NUL bytes and direction overrides are deleted, and text hidden in Unicode tag characters becomes `[hidden text removed]`. Anything that isn't valid UTF-8 is refused.
- Text containing a working Deacon key or identity secret is refused, because visitors can read what you write. A prefix on its own, such as `deacon_sk_`, is fine.

### Retrying with `Idempotency-Key`

Send a new random `Idempotency-Key`, such as a UUID, with each POST, and send the same one if you retry. That way a reply is never sent twice, and a page never counts twice towards your limit. Without the header, every POST is a new request.

```sh
curl https://heydeacon.com/api/v1/conversations/kx7mqprt/replies \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Idempotency-Key: 8f14e45f-ceea-467d-9d1f-2f1b6e0a7c31" \
  -H "Content-Type: application/json" \
  -d '{"text": "We can export that for you. It should be with you today."}'
```

- The same value with the same request returns the same status, and what the first request created, as it is now. If that has since been deleted, you get a 404.
- The same value with a different request returns 422 with `idempotency_key_reused`, and changes nothing. An empty value returns 422 with `idempotency_key_empty`.
- A refused request isn't remembered, so the same value works once you've fixed the problem.
- A retry sent while the first request is still running waits for it, then returns the same result.
- A value belongs to your team, whichever API key sent it. Each is kept at least 24 hours and at most about 48. After that, the same value counts as a new request. Deacon only keeps a hash of it.
- `POST /api/v1/webhooks` refuses one with `idempotency_key_not_taken`, because a retry couldn't show the secret again. If you lose the response, list your webhooks to see whether it was added.
- `PUT`, `PATCH` and `DELETE` don't need one, and nor do marking a topic done, showing it again and suggesting a fix. Doing any of those twice does no more than doing it once.

### All routes

| Route | What it does | Key |
| --- | --- | --- |
| `GET /api/v1/team` | [Check the connection](#check-the-connection) | Any |
| `DELETE /api/v1/key` | [Revoke this key](#revoke-this-key) | Any |
| `GET /api/v1/conversations` | [List conversations](#list-conversations) | Any |
| `GET /api/v1/conversations/{id}` | [Read a conversation](#read-a-conversation) | Any |
| `POST /api/v1/conversations/{id}/replies` | [Reply to a visitor](#reply-to-a-visitor) | Read and write |
| `POST /api/v1/conversations/{id}/hand-back` | [Hand a chat back](#hand-a-chat-back) | Read and write |
| `GET /api/v1/files/{id}` | [Get a chat file](#get-a-chat-file) | Any |
| `GET /api/v1/knowledge` | [List knowledge](#list-knowledge) | Any |
| `GET /api/v1/knowledge/{id}` | [Read a source](#read-a-source) | Any |
| `DELETE /api/v1/knowledge/{id}` | [Remove a source](#remove-a-source) | Read and write |
| `POST /api/v1/knowledge/texts` | [Add text](#add-text) | Read and write |
| `POST /api/v1/knowledge/pages` | [Add a page](#add-a-page) | Read and write |
| `POST /api/v1/knowledge/{id}/refetch` | [Re-fetch a page](#re-fetch-a-page) | Read and write |
| `GET /api/v1/help-document` | [Read the help document](#read-the-help-document) | Any |
| `POST /api/v1/help-document/sections` | [Add a section](#add-a-section) | Read and write |
| `PUT /api/v1/help-document/sections/{id}` | [Change a section](#change-a-section) | Read and write |
| `GET /api/v1/instructions` | [Read the Instructions](#read-the-instructions) | Any |
| `GET /api/v1/needs-you` | [Read What needs you](#read-what-needs-you) | Any |
| `GET /api/v1/topics` | [List topics](#list-topics) | Any |
| `POST /api/v1/topics/{id}/mark-done` | [Mark a topic done](#mark-a-topic-done) | Coding agent or Read and write |
| `POST /api/v1/topics/{id}/show-again` | [Show a topic again](#show-a-topic-again) | Coding agent or Read and write |
| `POST /api/v1/answers/{id}/suggest-fix` | [Suggest a fix](#suggest-a-fix) | Coding agent or Read and write |
| `GET /api/v1/site-errors` | [List site errors](#list-site-errors) | Any |
| `GET /api/v1/events` | [List events](#list-events) | Not a Coding agent key |
| `POST /api/v1/webhooks` | [Add a webhook from your code](#add-a-webhook-from-your-code) | Not a Coding agent key |
| `GET /api/v1/webhooks` | [List a key's webhooks](#list-a-key-s-webhooks) | Not a Coding agent key |
| `PATCH /api/v1/webhooks/{id}` | [Change a webhook](#change-a-webhook) | Not a Coding agent key |
| `DELETE /api/v1/webhooks/{id}` | [Delete a webhook](#delete-a-webhook) | Not a Coding agent key |

## Webhooks

A webhook sends each event to your URL as JSON, as soon as it happens. Your code can then open a ticket for a bug report, or add a new email to your CRM. Each request holds one event, with its `id`, `type`, `timestamp` and `data`. Here are [eight jobs webhooks can do for you](/features/chatbot-webhooks).

### Event types

| `type` | Name | Sent when | You could |
| --- | --- | --- | --- |
| `visitor.reported_bug` | A visitor reported a bug | Deacon decides a question is reporting something broken. Once per chat. | Open a ticket. |
| `visitor.requested_feature` | A visitor asked for a feature | Deacon decides a question is asking for something your product doesn't do. Once per chat. | Add it to your roadmap. |
| `visitor.asked_for_person` | A visitor asked for a person | A visitor asks to talk to a person. | Alert whoever's on support. |
| `visitor.left_email` | A visitor left their email | A visitor leaves their email. Once per chat. | Add a lead to your CRM. |
| `visitor.wrote_back` | A visitor wrote back to a teammate | A visitor sends a message in a chat a teammate has taken over. | Alert that teammate. |
| `visitor.waiting_signed_in` | A signed-in visitor is waiting for a reply | Deacon hands a visitor your server has verified over to your team. Your reply goes to their verified email. Once per chat. | Look up their account and reply. |
| `conversation.started` | A chat started | A visitor sends the first message in a chat. | Count chats in your own analytics. |
| `conversation.ended` | A chat ended | The visitor starts a new chat, or it goes quiet and Deacon closes it. | Save the transcript. |

### Choosing which events you get

A webhook only gets the event types you choose.

**On Integrations.** Choose them when you add a webhook. To change them later, open the webhook's menu and press Edit.

**Through the API.** List them in `events` when you [add a webhook from your code](#add-a-webhook-from-your-code). It needs at least one type. If you leave it out, send an empty list or name an unknown type, you get a 422 listing the valid types, and no webhook is made. To change them later, send a new list to [Change a webhook](#change-a-webhook).

```sh
curl https://heydeacon.com/api/v1/webhooks \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/deacon", "events": ["visitor.reported_bug", "visitor.left_email"]}'
```

**In your own code.** Every body has a `type`, so switch on it. Return a 2xx for any type you don't handle. That includes `ping`, which Send test on Integrations sends whichever types you chose.

```js
function handle(event) {
  switch (event.type) {
    case 'visitor.reported_bug':
      return openTicket(event.data.message.untrusted.text);
    case 'visitor.left_email':
      return addLead(event.data.message.untrusted.email);
    default:
      return; // ping, and anything else you didn't ask to handle
  }
}
```

### What each event looks like

These are real bodies from Deacon. In `data`, `conversation` is the chat and `message` is the message the event is about. `conversation.ended` has no `message`. It has the chat's `messages` instead. [An event](#an-event) explains every field.

```json event="visitor.reported_bug"
{
  "id": "evt_01M6DC34TBKBDTMSDSAK1ANWXN",
  "type": "visitor.reported_bug",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "yjc8zx44",
      "untrusted": {
        "subject": "The export button does nothing",
        "reply_email": null,
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/reports"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 0,
      "failed_count": 0,
      "outcome": "answered",
      "reply_address_source": null,
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=yjc8zx44",
      "api_url": "https://heydeacon.com/api/v1/conversations/yjc8zx44"
    },
    "message": {
      "id": "c6gmg6yf",
      "untrusted": {
        "text": "The export button does nothing",
        "question": null,
        "page": "https://acme.co/app/reports",
        "topic_label": null,
        "author": null,
        "email": null
      },
      "created_at": "2026-11-02T05:35:39Z",
      "by": "visitor",
      "type": "question",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": "bug_report",
      "topic_id": null,
      "topic_type": null,
      "address_source": null,
      "files": []
    }
  }
}
```

```json event="visitor.requested_feature"
{
  "id": "evt_01M6DC34TKF7EJC7R61ZBH9K5P",
  "type": "visitor.requested_feature",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "vgnbx2pg",
      "untrusted": {
        "subject": "Can you add a dark mode?",
        "reply_email": null,
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/reports"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 0,
      "failed_count": 0,
      "outcome": "answered",
      "reply_address_source": null,
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=vgnbx2pg",
      "api_url": "https://heydeacon.com/api/v1/conversations/vgnbx2pg"
    },
    "message": {
      "id": "rqwsrg22",
      "untrusted": {
        "text": "Can you add a dark mode?",
        "question": null,
        "page": "https://acme.co/app/reports",
        "topic_label": null,
        "author": null,
        "email": null
      },
      "created_at": "2026-11-02T05:35:39Z",
      "by": "visitor",
      "type": "question",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": "feature_request",
      "topic_id": null,
      "topic_type": null,
      "address_source": null,
      "files": []
    }
  }
}
```

```json event="visitor.asked_for_person"
{
  "id": "evt_01M6DC34S978DK0H4Q6VR55FF5",
  "type": "visitor.asked_for_person",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "mnf8rgpn",
      "untrusted": {
        "subject": "Can I talk to someone about my invoice?",
        "reply_email": null,
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/billing"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 1,
      "failed_count": 0,
      "outcome": null,
      "reply_address_source": null,
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=mnf8rgpn",
      "api_url": "https://heydeacon.com/api/v1/conversations/mnf8rgpn"
    },
    "message": {
      "id": "vfmx6kdp",
      "untrusted": {
        "text": "Can I talk to someone about my invoice?",
        "question": null,
        "page": "https://acme.co/billing",
        "topic_label": null,
        "author": null,
        "email": null
      },
      "created_at": "2026-11-02T05:35:39Z",
      "by": "visitor",
      "type": "question",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": null,
      "topic_id": null,
      "topic_type": null,
      "address_source": null,
      "files": []
    }
  }
}
```

```json event="visitor.left_email"
{
  "id": "evt_01M6DC34S0E24W7B7A9AX6EVGF",
  "type": "visitor.left_email",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "cdrj68vy",
      "untrusted": {
        "subject": "Can I export to CSV?",
        "reply_email": "sam@example.com",
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/reports"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 1,
      "failed_count": 0,
      "outcome": null,
      "reply_address_source": "typed",
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=cdrj68vy",
      "api_url": "https://heydeacon.com/api/v1/conversations/cdrj68vy"
    },
    "message": {
      "id": "kbjc7nm6",
      "untrusted": {
        "text": null,
        "question": null,
        "page": null,
        "topic_label": null,
        "author": null,
        "email": "sam@example.com"
      },
      "created_at": "2026-11-02T05:35:39Z",
      "by": "visitor",
      "type": "address",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": null,
      "topic_id": null,
      "topic_type": null,
      "address_source": "typed",
      "files": []
    }
  }
}
```

```json event="visitor.wrote_back"
{
  "id": "evt_01M6DC34SQQAX38TP8AFWEA2DE",
  "type": "visitor.wrote_back",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "dkknqc2j",
      "untrusted": {
        "subject": "My report is empty",
        "reply_email": null,
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/reports"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": null,
      "held_by_teammate": true,
      "playground": false,
      "language": "en",
      "exchange_count": 2,
      "unanswered_count": 0,
      "awaiting_answer_count": 2,
      "failed_count": 0,
      "outcome": null,
      "reply_address_source": null,
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=dkknqc2j",
      "api_url": "https://heydeacon.com/api/v1/conversations/dkknqc2j"
    },
    "message": {
      "id": "rtpsq3js",
      "untrusted": {
        "text": "The monthly one",
        "question": null,
        "page": "https://acme.co/app/reports",
        "topic_label": null,
        "author": null,
        "email": null
      },
      "created_at": "2026-11-02T05:35:39Z",
      "by": "visitor",
      "type": "question",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": null,
      "topic_id": null,
      "topic_type": null,
      "address_source": null,
      "files": []
    }
  }
}
```

```json event="visitor.waiting_signed_in"
{
  "id": "evt_01M6DC34T1QNVQC393RZ47HRRE",
  "type": "visitor.waiting_signed_in",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "qz4b2y92",
      "untrusted": {
        "subject": "Can a person look at my account?",
        "reply_email": "ana@studio.com",
        "proved_as": {
          "external_id": "u_123",
          "email": "ana@studio.com",
          "name": "Ana Ruiz",
          "site": {
            "host": "acme.co",
            "development": false
          }
        },
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/settings"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 1,
      "failed_count": 0,
      "outcome": null,
      "reply_address_source": "signed_in",
      "verified": true,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=qz4b2y92",
      "api_url": "https://heydeacon.com/api/v1/conversations/qz4b2y92"
    },
    "message": {
      "id": "h3bj3c3g",
      "untrusted": {
        "text": null,
        "question": null,
        "page": null,
        "topic_label": null,
        "author": null,
        "email": "ana@studio.com"
      },
      "created_at": "2026-11-02T05:35:39Z",
      "by": "visitor",
      "type": "address",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": null,
      "topic_id": null,
      "topic_type": null,
      "address_source": "signed_in",
      "files": []
    }
  }
}
```

```json event="conversation.started"
{
  "id": "evt_01M6DC34R1NJQHHKF2T2K2XCY8",
  "type": "conversation.started",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "cdrj68vy",
      "untrusted": {
        "subject": "Can I export to CSV?",
        "reply_email": "sam@example.com",
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/reports"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 1,
      "failed_count": 0,
      "outcome": null,
      "reply_address_source": "typed",
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=cdrj68vy",
      "api_url": "https://heydeacon.com/api/v1/conversations/cdrj68vy"
    },
    "message": {
      "id": "yqt4kz4c",
      "untrusted": {
        "text": "Can I export to CSV?",
        "question": null,
        "page": "https://acme.co/app/reports",
        "topic_label": null,
        "author": null,
        "email": null
      },
      "created_at": "2026-11-02T05:35:39Z",
      "by": "visitor",
      "type": "question",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": null,
      "topic_id": null,
      "topic_type": null,
      "address_source": null,
      "files": []
    }
  }
}
```

```json event="conversation.ended"
{
  "id": "evt_01M6DC34TST2HZ1ZF97YQ3PPA6",
  "type": "conversation.ended",
  "timestamp": "2026-11-02T05:35:39Z",
  "data": {
    "conversation": {
      "id": "syjnbvr9",
      "untrusted": {
        "subject": "Do you have an API?",
        "reply_email": null,
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/pricing"
      },
      "started_at": "2026-11-02T05:35:39Z",
      "last_activity_at": "2026-11-02T05:35:39Z",
      "ended_at": "2026-11-02T05:35:39Z",
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 0,
      "failed_count": 0,
      "outcome": "answered",
      "reply_address_source": null,
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=syjnbvr9",
      "api_url": "https://heydeacon.com/api/v1/conversations/syjnbvr9"
    },
    "message": null,
    "messages": [
      {
        "id": "k4mgnkpz",
        "untrusted": {
          "text": "Do you have an API?",
          "question": null,
          "page": "https://acme.co/pricing",
          "topic_label": null,
          "author": null,
          "email": null
        },
        "created_at": "2026-11-02T05:35:39Z",
        "by": "visitor",
        "type": "question",
        "verified": false,
        "answered": null,
        "tag": null,
        "in_reply_to": null,
        "intent": null,
        "topic_id": null,
        "topic_type": null,
        "address_source": null,
        "files": []
      },
      {
        "id": "wk3nzcwf",
        "untrusted": {
          "text": "Yes, on every plan.",
          "question": null,
          "page": "https://acme.co/pricing",
          "topic_label": null,
          "author": null,
          "email": null
        },
        "created_at": "2026-11-02T05:35:39Z",
        "by": "deacon",
        "type": "answer",
        "verified": false,
        "answered": true,
        "tag": "answered",
        "in_reply_to": "k4mgnkpz",
        "intent": null,
        "topic_id": null,
        "topic_type": null,
        "address_source": null,
        "files": []
      }
    ],
    "messages_truncated": false
  }
}
```

```json event="ping"
{
  "id": "evt_01M6DC350GESPSX8JN3VTFRKQ2",
  "type": "ping",
  "timestamp": "2026-11-02T05:35:40Z",
  "data": {
    "test": true
  }
}
```

### Adding a webhook

An owner adds a webhook on Integrations, with its URL and the events to send. They get a signing secret that starts `whsec_`. It's shown once, so copy it.

Every owner gets an email when a webhook is added or removed on Integrations. Webhooks a key adds through the API are grouped into one email an hour. A key deleting its own webhook sends no email.

- The URL must be public and `https` on port 443, with no username or password in it, and can't be Deacon's own.
- Deacon only sends to public IP addresses. If a URL later resolves to a private one, that attempt counts as failed.
- A team can have 16 webhooks.
- Press Send test to send a signed `ping` and see whether your server took it.
- Press Check it refuses fake requests to find out whether your server checks signatures. It sends that `ping`, then one signed with the wrong secret. It passes only if your server accepts the first and returns a 4xx for the second.
- A team can send 10 tests an hour.

### Checking a signature

Use a [Standard Webhooks library](https://www.standardwebhooks.com), or the Node or Python below. Check the raw body, before anything parses it. Refuse a timestamp more than 5 minutes from now, so nobody can replay a recorded request.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function isFromDeacon(headers, rawBody, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];

  if (!id || !/^\d+$/.test(timestamp ?? '') || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.split(' ').some((signature) => {
    const given = Buffer.from(signature.replace(/^v1,/, ''), 'base64');

    return (
      signature.startsWith('v1,') &&
      given.length === expected.length &&
      timingSafeEqual(given, expected)
    );
  });
}
```

```python
import base64, hashlib, hmac, time


def is_from_deacon(headers, raw_body, secret):
    event_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")

    if not event_id or not (timestamp or "").isdigit() or not signatures:
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{event_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    return any(
        given.startswith("v1,") and hmac.compare_digest(given[3:].encode(), expected)
        for given in signatures.split(" ")
    )
```

If you're writing your own, each request has these three headers, plus `Content-Type: application/json`.

| Header | Holds |
| --- | --- |
| `webhook-id` | The event's `id`. |
| `webhook-timestamp` | When this attempt was sent, as a Unix timestamp in seconds. |
| `webhook-signature` | `v1,` followed by the base64 signature. It can hold several, separated by spaces. Accept the request if any one matches. |

The signature is an HMAC-SHA256 of the id, the timestamp and the body, joined by dots. The HMAC key is the part of your secret after `whsec_`, decoded from base64.

### Retries

Return a 2xx within 15 seconds. Anything else counts as a failure, including a redirect, because Deacon doesn't follow them. Deacon tries 10 times over about 3 days, then gives up. After the first try, it waits 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours between tries.

- If your server is down (no response, a 429 or a 5xx), its other events wait, and each retry sends one request rather than one per event. An event not tried yet goes first at each try, so one event your server chokes on can't hold the rest back.
- As soon as your server answers anything else to a retry, Resend or Send now, or a 2xx to Send test, everything waiting goes straight away.
- Any other failure, such as a 400, only retries that event, on its own schedule. Everything else carries on.
- Deacon turns a webhook off if your URL returns 410 Gone, or if every try has failed for 5 days. Anything still waiting is dropped, and Integrations shows it as turned off.
- To start again, an owner presses Turn back on. The secret stays the same, and missed events aren't sent. [List events](#list-events) keeps them for 30 days.
- Every owner gets an email once a webhook has been failing for 30 minutes, and again when it's turned off. A team gets at most 16 of these an hour.
- Your team's webhooks go out one at a time, so respond quickly and do slow work afterwards. If your servers keep Deacon waiting for 15 minutes in total in an hour, the rest wait until the hour is up. None are dropped.

### Repeats and order

A webhook can arrive more than once, and out of order. A repeat has the same `webhook-id` and body, with a new timestamp and signature. Keep the ids you've handled and drop any you see again.

To put events in order, sort by the `timestamp` in the body, not the `webhook-timestamp` header.

If your server was down, catch up with [List events](#list-events). Each event there has the same `id`.

### A webhook is a snapshot

A webhook is a snapshot of the chat at the moment it was sent, and retries send the same thing. Deacon works out a question's type and topic after answering, so those can be empty. For the latest, fetch the chat with `api_url`.

- `visitor.asked_for_person` and `conversation.started` usually have a `null` `intent` and no topic.
- `visitor.reported_bug` and `visitor.requested_feature` have their `intent`, but usually no topic yet.

[List events](#list-events) isn't a snapshot. It shows each chat as it is when you ask.

### Seeing what was sent

Every webhook shows on Integrations, including ones a key made. Open its menu and press See what was sent to list its newest 25 requests: what happened, when, and whether it got through, with the status your server last returned if it didn't. Open one to see the body Deacon sent. Deacon doesn't keep your server's response. A request and its body leave the list when the event is deleted, after 30 days or when an owner deletes everything from that person. Any team member can look, as anyone on the team can read the chats.

Unless the webhook is turned off, an owner can send anything that hasn't got through. The button says Send now on a request that hasn't been tried yet, and Resend on one that has or that Deacon gave up on. A row that says Trying again is still being retried. A resend has the same body and `webhook-id`, with a new timestamp and signature. If your server drops repeats, it will drop this too. If a resend fails, the retries start again from the beginning. If your server was down, Send test is quicker: once it returns a 2xx, everything waiting goes.

### Making a new secret

An owner can make a new signing secret for a webhook added on Integrations. It asks for their password, and shows the new secret once.

- For 7 days, each request is signed with both secrets, so `webhook-signature` holds two signatures, the new one first. Your server accepts the request with either secret. Put the new one on your server within those 7 days.
- Press Stop the old secret to end it sooner. If you lose the new secret, press Make a new secret again, and the old one still works until the same day.
- Every owner gets an email about each change.
- A webhook a key made can't get a new secret this way. Delete it through the API and make another.

### List events

`GET` `/api/v1/events`

Events from your team's chats over the last 30 days, newest first. They're the same events webhooks send, so a tool can see what it missed. Needs a Read or Read and write key, because events include the emails visitors left.

| Parameter | Does |
| --- | --- |
| `type` | Only these types, separated by commas, such as `visitor.reported_bug,visitor.left_email`. |
| `cursor` | A page's `next_cursor`. Returns the next, older events. |
| `ending_before` | An event's `id`. Returns the events straight after it. |
| `limit` | Page size, from 1 to 100. The default is 25. |

```sh
curl "https://heydeacon.com/api/v1/events?type=visitor.reported_bug,visitor.requested_feature&limit=1" \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "data": [
    {
      "id": "evt_01M6D3VRJ8ZWG893PYFKSJXEBS",
      "type": "visitor.reported_bug",
      "timestamp": "2026-10-30T03:11:55Z",
      "data": {
        "conversation": {
          "id": "kzwwjftq",
          "untrusted": {
            "subject": "The export button does nothing",
            "reply_email": null,
            "proved_as": null,
            "page_said_signed_in_as": null,
            "first_page": "https://acme.co/app/reports"
          },
          "started_at": "2026-10-30T03:11:49Z",
          "last_activity_at": "2026-10-30T03:11:49Z",
          "ended_at": null,
          "held_by_teammate": false,
          "playground": false,
          "language": "en",
          "exchange_count": 1,
          "unanswered_count": 1,
          "awaiting_answer_count": 0,
          "failed_count": 0,
          "outcome": null,
          "reply_address_source": null,
          "verified": false,
          "dashboard_url": "https://heydeacon.com/acme/conversations?selected=kzwwjftq",
          "api_url": "https://heydeacon.com/api/v1/conversations/kzwwjftq"
        },
        "message": {
          "id": "qnfpxkp7",
          "untrusted": {
            "text": "The export button does nothing",
            "question": null,
            "page": "https://acme.co/app/reports",
            "topic_label": "Export button does nothing",
            "author": null,
            "email": null
          },
          "created_at": "2026-10-30T03:11:49Z",
          "by": "visitor",
          "type": "question",
          "verified": false,
          "answered": null,
          "tag": null,
          "in_reply_to": null,
          "intent": "bug_report",
          "topic_id": "h4mzq8tv",
          "topic_type": "bug",
          "address_source": null,
          "files": []
        }
      }
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

An event's `id` also works as a cursor.

- To read older events, send a page's `next_cursor` back as `cursor`.
- To catch up after your server was down, send the `id` of the last event you saw as `ending_before`. You get the events straight after it, with the newest first on each page. Send that page's `next_cursor` as `ending_before` to get the next page, until it's `null`.
- Send `cursor` or `ending_before`, not both, and the same `type` each time.
- An `id` still works as a cursor after its event has gone. Every event is deleted after 30 days.
- The list leaves out events from about the last 5 seconds, so it never skips one that's still being saved. If your tool keeps the newest `id` it has seen and polls with `ending_before`, it won't miss any.
- A page stops early at about 2 MB, because `conversation.ended` events include the chat's messages. It then has fewer than `limit` events but still gives a `next_cursor`.
- A chat with no messages has no events.
- Each event shows its chat and message as they are when you ask, so they can be newer than when the event happened.

### Add a webhook from your code

`POST` `/api/v1/webhooks`

Adds a webhook from your code, for example when someone turns on a trigger in your tool. To add one by hand, use Integrations. Needs a Read or Read and write key.

| Field | What to send |
| --- | --- |
| `url` | A public `https` URL on port 443, up to 2,048 characters, with no username or password in it. It can't be Deacon's own. |
| `events` | A list of one or more [event types](#event-types). |

```sh
curl https://heydeacon.com/api/v1/webhooks \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/deacon", "events": ["visitor.left_email"]}'
```

Returns 201 with the webhook and its signing secret. The secret is only shown this once.

```json
{
  "id": "whep_01M6DC8XFN863KK8W0ZNXZZ0N5",
  "url": "https://example.com/deacon",
  "events": [
    "visitor.left_email"
  ],
  "created_at": "2026-11-02T05:38:48Z",
  "failing_since": null,
  "turned_off_at": null,
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
```

- The webhook belongs to the key that made it. No other key can list, change or delete it. It shows on Integrations under the key's name, where an owner can remove it too.
- A key can't see webhooks made by another key or added on Integrations.
- To get a new secret, delete the webhook and make another.
- It doesn't take an `Idempotency-Key`, because a retry couldn't show the secret again. If you lose the response, list your webhooks, delete the one at that URL and make another.
- A key can make 10 webhooks, and a team can have 16 in total. Going over either returns 409 saying which.
- Every owner gets an email about new webhooks from a key, at most once an hour per key.

### List a key's webhooks

`GET` `/api/v1/webhooks`

The webhooks this key made, newest first, without their secrets. A key has at most 10, so `data` holds them all and `has_more` is `false`.

```sh
curl https://heydeacon.com/api/v1/webhooks \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "data": [
    {
      "id": "whep_01JBK3Q8V2M7Y4H6T9R0C5N2XW",
      "url": "https://hooks.example.com/deacon",
      "events": [
        "visitor.left_email"
      ],
      "created_at": "2026-11-02T03:11:49Z",
      "failing_since": null,
      "turned_off_at": null
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

- `failing_since` is when it started failing, or `null` if it isn't failing.
- `turned_off_at` is when Deacon stopped sending to it, or `null`. Deacon stops if your URL returns 410 Gone, or after 5 days of failures. Only an owner can turn it back on, on Integrations.
- When a key is revoked, its webhooks are deleted too, and anything waiting to be sent to them is dropped. A revoked key may have leaked, so they stop at once. The events stay in `/api/v1/events`.

### Change a webhook

`PATCH` `/api/v1/webhooks/{id}`

Changes a webhook this key made, by its `whep_` id. Send `url`, `events` or both, the same as when you add one. The secret stays the same, and every owner gets an email if it now sends more. A new `url` starts clean: it's no longer failing, and everything waiting goes to it straight away.

| Field | What to send |
| --- | --- |
| `url` | A new URL, with the same rules as adding one. |
| `events` | The new list of [event types](#event-types). This replaces the old list. |

```sh
curl -X PATCH https://heydeacon.com/api/v1/webhooks/whep_01JBK3Q8V2M7Y4H6T9R0C5N2XW \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events": ["visitor.left_email", "visitor.asked_for_person"]}'
```

Returns the webhook, or 404 if this key didn't make it.

```json
{
  "id": "whep_01JBK3Q8V2M7Y4H6T9R0C5N2XW",
  "url": "https://hooks.example.com/deacon",
  "events": [
    "visitor.left_email",
    "visitor.asked_for_person"
  ],
  "created_at": "2026-11-02T03:11:49Z",
  "failing_since": null,
  "turned_off_at": null
}
```

### Delete a webhook

`DELETE` `/api/v1/webhooks/{id}`

Deletes a webhook this key made, by its `whep_` id. Nothing more is sent to it, anything waiting is dropped, and no owner is emailed.

```sh
curl -X DELETE https://heydeacon.com/api/v1/webhooks/whep_01JBK3Q8V2M7Y4H6T9R0C5N2XW \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

Returns 204 with no body, or 404 if this key didn't make that webhook.

## MCP server

Connect Claude Code, Cursor or another coding agent, then ask it what broke this week and have it fix your code. It can also mark a topic done, and suggest a better answer for Deacon. Visitors don't see the suggestion until you save it. You can [watch it fix a bug a customer reported](/features/mcp-server).

### Connecting

Add the server to your agent, then approve it in your browser. There's no key to copy.

| Client | What to do |
| --- | --- |
| Claude Code | Run `claude mcp add --transport http deacon https://heydeacon.com/mcp`, then `claude mcp login deacon` |
| Claude on the web | In Claude, open Settings, then Customize, then Connectors. Press Add, then Add custom connector, name it Deacon and paste `https://heydeacon.com/mcp`. Press Continue, Add and Connect |
| Codex | Run `codex mcp add deacon --url https://heydeacon.com/mcp` |
| Cursor | Press Add to Cursor on the Integrations page. Cursor asks you to install it, then to sign in |
| VS Code | Press Add to VS Code on the Integrations page. VS Code asks you to install it, trust it and sign in |

Your browser opens on Deacon. Choose the team and press Allow. Your agent then shows under Coding agents on the Integrations page, where you can disconnect it, and every owner gets an email. Only an owner can allow an agent.

It can do what a Coding agent key can and no more: it can't add webhooks, change your knowledge or reply to anyone. An agent you allowed is disconnected if you're removed from the team, and if nobody uses it for 90 days it asks you to approve it again.

### Connecting with a key

For a script, CI, Windsurf or a teammate who isn't an owner, use a key instead. Press Use a key instead when you connect your agent on the Integrations page. It makes a Coding agent key, the only kind the server takes, and shows the exact steps for each client, with your key already in them.

| Client | Where the key goes |
| --- | --- |
| Claude Code | `~/.claude.json`, for that repository, through `claude mcp add` |
| Cursor | `~/.cursor/mcp.json` |
| VS Code | `~/.copilot/mcp-config.json` |
| Codex | `~/.codex/config.toml` |
| Windsurf, now Devin Desktop | `~/.config/devin/mcp_config.json`, or `%APPDATA%\devin\mcp_config.json` on Windows |

These files sit outside your repositories, so the key stays out of your commits. Your agent could still read them if you let it, which is why this kind of key can't add webhooks, change your knowledge or reply to anyone.

If you send a wrong key, some clients take it as a reason to sign in. VS Code and Cursor open your browser, and Codex says the server is not logged in. Check the key, or sign in instead.

### Tools

Every tool works on the key's team, and everything a tool does, the API does too. Lists return 20 items unless you set `limit`, up to 50. If you send an argument a tool doesn't take, it's refused, and the message names the ones it does.

- `get_team` returns the key's team, and the key's name and type. Call it first to check the connection.
- `get_needs_you` returns [What needs you](#read-what-needs-you) for a `range`. Start here to find what broke or confused visitors.
- `search_conversations` [lists conversations](#list-conversations), filtered by `search`, `email`, `tag`, `topic`, `intent`, `waiting_for_reply` and `updated_since`. `search` is a phrase visitors typed, matched exactly, so use `list_topics` for a subject. It says whether a visitor left an email, but not what it is.
- `get_conversation` returns one conversation by `id`, with its messages and the email the visitor left. It stays unread on the dashboard.
- `list_topics` [lists topics](#list-topics) for a `range`, and one `type` if you send it. `ask` is people asking for something to be added. People asking whether a feature exists have no type, so read the list without a type too.
- `list_site_errors` [lists your site's JavaScript errors](#list-site-errors) for a `range`.
- `get_knowledge` lists your pages, texts and files. With `id`, it returns one source's text. With `help_document`, it returns the help document.
- `mark_topic_done` marks a topic done by `topic_id`, the same as pressing Done on What needs you. It takes effect at once and visitors don't see it. Press Show again on the dashboard to bring the topic back.
- `suggest_fix` suggests a better version of one of Deacon's answers. Send the `answer_id` and `text` of up to 4,000 characters. It waits in that question's fix box in Conversations, and visitors see no change until someone on your team saves it. A second fix for the same answer replaces the first. A fix containing a Deacon key or secret is refused.

### What a tool returns

Each result has a one-line summary from Deacon and the data. It comes in two forms, because a client may pass either one to its model. `content` is one block of text, with the line first, then the data as JSON inside `<visitor-data>`.

```text title="content"
The team this API key reads, and the key’s own name and type.
<visitor-data>
{"team":{"name":"Acme"},"key":{"name":"Claude Code","type":"agent"}}
</visitor-data>
```

`structuredContent` is the same data, with the line first as `notice`.

```json title="structuredContent"
{
  "notice": "The team this API key reads, and the key’s own name and type.",
  "team": {
    "name": "Acme"
  },
  "key": {
    "name": "Claude Code",
    "type": "agent"
  }
}
```

- Deacon writes the whole line, so it never contains a stranger's words.
- Inside the block of text, `<`, `>` and `&` are written as JSON Unicode escapes: a backslash followed by `u003C`, `u003E` or `u0026`. So a visitor's words can't close the tag.
- Invisible text is removed from every string in both forms. Hidden Unicode tag characters and runs of variation selectors become `[hidden text removed]`. Direction controls and zero-width spaces are removed without a marker.
- A result stays under about 24,000 characters. A longer one comes in parts, and Deacon's line says how to get the rest: `next_cursor` for a list, `before` for a conversation, `from` for a source's text or the help document. To fit, `get_needs_you` shortens the text under `untrusted`, and says so.

### Prompt injection

Your agent reads what visitors type, and a visitor could try to trick it. Keep approvals on, and check its changes before you accept them.

A visitor's message can carry instructions aimed at your agent. Deacon puts everything strangers wrote under `untrusted` and tells the agent to treat it as data, but that isn't a guarantee. Site errors and goal names are untrusted too. Anyone with your site's public key can send them, with no chat at all.

### When a call fails

If a tool can't do what you asked, the result has `isError` set to `true` and one sentence in its text, such as these.

- `Your team has no conversation with that id. Find one with search_conversations.`
- `This tool doesn’t take created_after. It takes search, email, tag, topic, intent, waiting_for_reply, updated_since, limit and cursor.`
- `That cursor isn’t from this list. Ask for the first page without it.`
- `The id field is required.`
- `Your team’s keys have read What needs you, topics and site errors 10 times this minute. Try again in a minute.`
- `An internal server error occurred.` That one is a fault in Deacon, and it's logged without what you sent.

A call refused before any tool runs returns an HTTP status.

| Status | When |
| --- | --- |
| 401 | The key is missing, unknown or revoked. The response has `WWW-Authenticate: Bearer realm="mcp", error="invalid_token"`, and its `message` says to make a key on Integrations. |
| 403 | A Read or Read and write key, or any call with an `Origin` header, because a browser should never hold this key. |
| 429 | Over a limit: 60 calls a minute for the key, or 300 for the team, shared with `/api/v1`. `Retry-After` says how many seconds to wait. |

### Calling it directly

The server is at `https://heydeacon.com/mcp`, over HTTP, with one POST per call and no session. Your client does this for you, but you can try a tool by hand with a Coding agent key.

```sh
curl https://heydeacon.com/mcp \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-11-25" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_team", "arguments": {}}}'
```

## Your team and key

### Check the connection

`GET` `/api/v1/team`

Returns your team's name, and the key's name and type. Use it to test a key.

```sh
curl https://heydeacon.com/api/v1/team \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "team": {
    "name": "Acme"
  },
  "key": {
    "name": "My script",
    "type": "read"
  }
}
```

### Revoke this key

`DELETE` `/api/v1/key`

Revokes the key you call it with, at once. A tool can use this when it finds a key has leaked. Any webhooks the key made are deleted too, and every owner gets an email.

```sh
curl -X DELETE https://heydeacon.com/api/v1/key \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

Returns 204 with no body.

## Conversations

To keep your own copy without writing any code, [export your chats as a CSV](/features/chat-transcripts).

### List conversations

`GET` `/api/v1/conversations`

Your team's conversations, most recently active first. Each has the same fields as [Read a conversation](#read-a-conversation), without the messages.

You can combine filters, but only one of `topic`, `waiting_for_reply` and `intent` at a time.

| Parameter | Finds |
| --- | --- |
| `search` | Chats where a visitor typed this exact phrase. It reads their words, not Deacon's answers or your replies. A whole email address finds the chats a reply would go to at that address, and any where a visitor typed it. |
| `tag` | Chats with an answer of this type: `gap`, `handoff`, `failed`, `capped`, `answered` or `dismissed`. [A message](#a-message) explains them. |
| `topic` | Chats with a question in this topic. Send the topic's `id`. |
| `waiting_for_reply` | With `true`, chats where a visitor left their email and is still waiting for a reply. |
| `intent` | Chats with a question of this type, such as `bug_report`. [A message](#a-message) lists the types. |
| `updated_since` | Chats active at or after this time. A time with an offset is converted, so `09:00:00+05:00` means 04:00 UTC. |
| `email` | Chats where a teammate's reply would be emailed to this address, whether the visitor typed it or their sign-in proved it. Not case-sensitive. |
| `limit`, `cursor` | See [Pagination](#pagination). |

```sh
curl "https://heydeacon.com/api/v1/conversations?email=sam@example.com" \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "data": [
    {
      "id": "kx7mqprt",
      "untrusted": {
        "subject": "Can I export to CSV?",
        "reply_email": "sam@example.com",
        "proved_as": null,
        "page_said_signed_in_as": null,
        "first_page": "https://acme.co/app/reports"
      },
      "started_at": "2026-10-31T03:11:49Z",
      "last_activity_at": "2026-10-31T03:14:49Z",
      "ended_at": null,
      "held_by_teammate": false,
      "playground": false,
      "language": "en",
      "exchange_count": 1,
      "unanswered_count": 0,
      "awaiting_answer_count": 0,
      "failed_count": 0,
      "outcome": "answered",
      "reply_address_source": "typed",
      "verified": false,
      "dashboard_url": "https://heydeacon.com/acme/conversations?selected=kx7mqprt",
      "api_url": "https://heydeacon.com/api/v1/conversations/kx7mqprt"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

A filter that finds nothing returns an empty page, not an error.

### Read a conversation

`GET` `/api/v1/conversations/{id}`

One conversation and its messages, oldest first. Reading it here doesn't mark it as read on the dashboard.

```sh
curl https://heydeacon.com/api/v1/conversations/kx7mqprt \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "id": "kx7mqprt",
  "untrusted": {
    "subject": "Can I export to CSV?",
    "reply_email": "sam@example.com",
    "proved_as": null,
    "page_said_signed_in_as": null,
    "first_page": "https://acme.co/app/reports"
  },
  "started_at": "2026-10-31T03:11:49Z",
  "last_activity_at": "2026-10-31T03:14:49Z",
  "ended_at": null,
  "held_by_teammate": false,
  "playground": false,
  "language": "en",
  "exchange_count": 1,
  "unanswered_count": 0,
  "awaiting_answer_count": 0,
  "failed_count": 0,
  "outcome": "answered",
  "reply_address_source": "typed",
  "verified": false,
  "dashboard_url": "https://heydeacon.com/acme/conversations?selected=kx7mqprt",
  "api_url": "https://heydeacon.com/api/v1/conversations/kx7mqprt",
  "messages": [
    {
      "id": "p9tq4mwx",
      "untrusted": {
        "text": "Can I export to CSV?",
        "question": null,
        "page": "https://acme.co/app/reports",
        "topic_label": "Exporting reports",
        "author": null,
        "email": null
      },
      "created_at": "2026-10-31T03:11:49Z",
      "by": "visitor",
      "type": "question",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": "how_to",
      "topic_id": "hv3kq8zt",
      "topic_type": "how_to",
      "address_source": null,
      "files": [
        {
          "id": "f3nq8zrt",
          "untrusted": {
            "name": "report.png"
          },
          "content_type": "image/png",
          "size": 1024,
          "api_url": "https://heydeacon.com/api/v1/files/f3nq8zrt"
        }
      ]
    },
    {
      "id": "m4rwx8zc",
      "untrusted": {
        "text": "Yes, from Reports › Export.",
        "question": null,
        "page": "https://acme.co/app/reports",
        "topic_label": null,
        "author": null,
        "email": null
      },
      "created_at": "2026-10-31T03:11:57Z",
      "by": "deacon",
      "type": "answer",
      "verified": false,
      "answered": true,
      "tag": "answered",
      "in_reply_to": "p9tq4mwx",
      "intent": null,
      "topic_id": null,
      "topic_type": null,
      "address_source": null,
      "files": []
    },
    {
      "id": "fybr2t4x",
      "untrusted": {
        "text": null,
        "question": null,
        "page": "https://acme.co/app/reports",
        "topic_label": null,
        "author": null,
        "email": "sam@example.com"
      },
      "created_at": "2026-10-31T03:13:49Z",
      "by": "visitor",
      "type": "address",
      "verified": false,
      "answered": null,
      "tag": null,
      "in_reply_to": null,
      "intent": null,
      "topic_id": null,
      "topic_type": null,
      "address_source": "typed",
      "files": []
    }
  ],
  "messages_truncated": false
}
```

[A conversation](#a-conversation) and [A message](#a-message) explain every field. If a conversation has more than 1,000 messages, you get the newest 1,000 with `messages_truncated` set to `true`. Its counts and subject then cover only those.

### Reply to a visitor

`POST` `/api/v1/conversations/{id}/replies`

Sends the visitor a reply from the person who made the key, the same as replying on the dashboard. The visitor sees that person's first name and your company.

| Field | What to send |
| --- | --- |
| `text` | The reply, up to 4,000 characters. |

```sh
curl https://heydeacon.com/api/v1/conversations/kx7mqprt/replies \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Idempotency-Key: 45c48cce-2e2d-4fbd-8e1a-6c7bd1b2f0aa" \
  -H "Content-Type: application/json" \
  -d '{"text": "We can export that for you. It should be with you today."}'
```

Returns 201 with the chat and the new message.

```json
{
  "conversation": {
    "id": "kx7mqprt",
    "untrusted": {
      "subject": "Can I export to CSV?",
      "reply_email": "sam@example.com",
      "proved_as": null,
      "page_said_signed_in_as": null,
      "first_page": "https://acme.co/app/reports"
    },
    "started_at": "2026-10-31T03:11:49Z",
    "last_activity_at": "2026-11-02T05:38:50Z",
    "ended_at": null,
    "held_by_teammate": true,
    "playground": false,
    "language": "en",
    "exchange_count": 1,
    "unanswered_count": 0,
    "awaiting_answer_count": 0,
    "failed_count": 0,
    "outcome": "answered",
    "reply_address_source": "typed",
    "verified": false,
    "dashboard_url": "https://heydeacon.com/acme/conversations?selected=kx7mqprt",
    "api_url": "https://heydeacon.com/api/v1/conversations/kx7mqprt"
  },
  "message": {
    "id": "xnbdk6ft",
    "untrusted": {
      "text": "We can export that for you. It should be with you today.",
      "question": null,
      "page": null,
      "topic_label": null,
      "author": "Sam from Acme",
      "email": null
    },
    "created_at": "2026-11-02T05:38:50Z",
    "by": "teammate",
    "type": "reply",
    "verified": false,
    "answered": null,
    "tag": null,
    "in_reply_to": null,
    "intent": null,
    "topic_id": null,
    "topic_type": null,
    "address_source": null,
    "files": []
  }
}
```

- A reply takes over the chat, so Deacon stops answering until it's [handed back](#hand-a-chat-back). If a teammate already holds the chat, the reply still goes through. Check `held_by_teammate` first if your tool shouldn't talk over a person.
- `conversation.reply_address_source` says whether the reply is also emailed. If it isn't `null`, Deacon emails it about a minute later under the same name, unless the visitor has read it in the chat by then. Replies within that minute go as one email. If it's `null`, nobody is emailed, and the visitor only sees the reply if they come back to the chat.
- `typed` means the visitor typed the address and nothing proved it, so a tool that replies to every chat will email whatever they typed.
- You can also reply to an ended chat. It stays ended, and `conversation.ended_at` shows when it ended. The visitor can't answer in it, and without an email they may never see your reply.
- If an owner deleted everything from that person, the chat is gone and its `id` returns 404.
- If the person who made the key has left your team, this returns 403. Make a new key.
- Your team's keys can send 50 replies by email an hour. See [Rate limits](#rate-limits). Send an `Idempotency-Key`, so a retry never sends a second reply.

### Hand a chat back

`POST` `/api/v1/conversations/{id}/hand-back`

Hands a chat back to Deacon, which starts answering again. A line in the chat tells the visitor, in their language, and it comes from the person who made the key. No email is sent. The line doesn't count as a reply, so a visitor waiting for one is still waiting.

```sh
curl -X POST https://heydeacon.com/api/v1/conversations/kx7mqprt/hand-back \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Idempotency-Key: 6512bd43-d9ca-4e6a-9c1f-3f8a2b7d5e10"
```

Returns 201 with the chat and that line. A chat no teammate holds, or one that has ended, returns 422.

```json
{
  "conversation": {
    "id": "kx7mqprt",
    "untrusted": {
      "subject": "Can I export to CSV?",
      "reply_email": "sam@example.com",
      "proved_as": null,
      "page_said_signed_in_as": null,
      "first_page": "https://acme.co/app/reports"
    },
    "started_at": "2026-10-31T03:11:49Z",
    "last_activity_at": "2026-11-02T05:38:50Z",
    "ended_at": null,
    "held_by_teammate": false,
    "playground": false,
    "language": "en",
    "exchange_count": 1,
    "unanswered_count": 0,
    "awaiting_answer_count": 0,
    "failed_count": 0,
    "outcome": "answered",
    "reply_address_source": "typed",
    "verified": false,
    "dashboard_url": "https://heydeacon.com/acme/conversations?selected=kx7mqprt",
    "api_url": "https://heydeacon.com/api/v1/conversations/kx7mqprt"
  },
  "message": {
    "id": "vgt5kwr5",
    "untrusted": {
      "text": "Handing you back to our AI assistant now. It can help with anything else.",
      "question": null,
      "page": null,
      "topic_label": null,
      "author": "Sam from Acme",
      "email": null
    },
    "created_at": "2026-11-02T05:38:50Z",
    "by": "teammate",
    "type": "reply",
    "verified": false,
    "answered": null,
    "tag": null,
    "in_reply_to": null,
    "intent": null,
    "topic_id": null,
    "topic_type": null,
    "address_source": null,
    "files": []
  }
}
```

### Get a chat file

`GET` `/api/v1/files/{id}`

A file sent in one of your team's chats, by the `id` in a message's `files`. Images come back inline, and anything else as a download.

```sh
curl https://heydeacon.com/api/v1/files/f3nq8zrt \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  --output report.png
```

Returns 200 with the file itself, not JSON. Its filename is its `id`, such as `f3nq8zrt.png`. The name the visitor gave it is the file's `untrusted.name`. Files always need a key, and a message never holds a link that works without one.

## Knowledge

### List knowledge

`GET` `/api/v1/knowledge`

The pages, texts and files Deacon answers from, newest first. That includes every page from a crawl of your site. For help document sections, see [Read the help document](#read-the-help-document).

```sh
curl https://heydeacon.com/api/v1/knowledge \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "data": [
    {
      "id": "t7dkqm3p",
      "untrusted": {
        "name": "Pricing",
        "url": "https://acme.co/pricing"
      },
      "type": "page",
      "crawled": false,
      "size": 38,
      "created_at": "2026-11-02T03:11:49Z",
      "changed_at": null,
      "state": "live",
      "problem": null,
      "dashboard_url": "https://heydeacon.com/acme/knowledge?source=t7dkqm3p",
      "api_url": "https://heydeacon.com/api/v1/knowledge/t7dkqm3p"
    },
    {
      "id": "n9vqz2hc",
      "untrusted": {
        "name": "Old pricing",
        "url": "https://acme.co/old-pricing"
      },
      "type": "page",
      "crawled": false,
      "size": 11,
      "created_at": "2026-11-02T03:11:49Z",
      "changed_at": null,
      "state": "live",
      "problem": null,
      "dashboard_url": "https://heydeacon.com/acme/knowledge?source=n9vqz2hc",
      "api_url": "https://heydeacon.com/api/v1/knowledge/n9vqz2hc"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

- `type` is `page`, `text` or `file`.
- `crawled` is `true` if it came from a crawl of your site.
- `size` is the text's size in bytes.
- `changed_at` is when its text last changed, or `null` if it never has.
- `problem` says why the last read or training went wrong, or is `null`. A `live` page can have one if a later read was refused, because Deacon keeps answering from what it had.

| `state` | Means |
| --- | --- |
| `reading` | Deacon is still reading it, and can't answer from all of it yet. |
| `live` | Deacon answers from it. |
| `waiting` | Deacon answers from the last version, and a new one is waiting for someone to approve it on the dashboard. This happens when you edit text Deacon already answers from, or when a re-fetch comes back under half the page's size. |
| `failed` | Deacon couldn't read it or learn from it. |

### Read a source

`GET` `/api/v1/knowledge/{id}`

One source, with the same fields as the list, plus the text Deacon answers from now in `untrusted.text`. If a page has changed on the web but hasn't been read again, you get the old text.

```sh
curl https://heydeacon.com/api/v1/knowledge/t7dkqm3p \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "id": "t7dkqm3p",
  "untrusted": {
    "name": "Pricing",
    "url": "https://acme.co/pricing",
    "text": "Starter is $40 a month, billed yearly."
  },
  "type": "page",
  "crawled": false,
  "size": 38,
  "created_at": "2026-11-02T03:11:49Z",
  "changed_at": null,
  "state": "live",
  "problem": null,
  "dashboard_url": "https://heydeacon.com/acme/knowledge?source=t7dkqm3p",
  "api_url": "https://heydeacon.com/api/v1/knowledge/t7dkqm3p"
}
```

### Remove a source

`DELETE` `/api/v1/knowledge/{id}`

Removes a source, so Deacon stops answering from it.

```sh
curl -X DELETE https://heydeacon.com/api/v1/knowledge/n9vqz2hc \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

Returns 204 with no body.

- You can't remove a single page from a crawl of your site. That returns 422 naming the site. Manage it on the dashboard.
- Help document sections aren't sources, so a section's `id` returns 404.

### Add text

`POST` `/api/v1/knowledge/texts`

Adds a piece of text for Deacon to answer from.

| Field | What to send |
| --- | --- |
| `name` | Its name, up to 255 characters. |
| `text` | The text, up to 1,500,000 characters. |

```sh
curl https://heydeacon.com/api/v1/knowledge/texts \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Idempotency-Key: 3c59dc04-8d3f-4a0e-b1f6-5d8a6f2c9e10" \
  -H "Content-Type: application/json" \
  -d '{"name": "Refund policy", "text": "Refunds within 30 days, no questions asked."}'
```

Returns 202 with the source. Deacon learns it in the background, and `state` becomes `live` when it's done.

```json
{
  "id": "fkmptpbf",
  "untrusted": {
    "name": "Refund policy",
    "url": null,
    "text": "Refunds within 30 days, no questions asked."
  },
  "type": "text",
  "crawled": false,
  "size": 43,
  "created_at": "2026-11-02T05:38:51Z",
  "changed_at": null,
  "state": "reading",
  "problem": null,
  "dashboard_url": "https://heydeacon.com/acme/knowledge?source=fkmptpbf",
  "api_url": "https://heydeacon.com/api/v1/knowledge/fkmptpbf"
}
```

- If one of your texts already has this `name`, this replaces it. If Deacon already answers from that text, the new version is `waiting` until someone presses Retrain agent on the dashboard.
- A `name` already used by a page, a file or a help document section is refused.
- So is text that would take your knowledge over your plan's size limit.

### Add a page

`POST` `/api/v1/knowledge/pages`

Adds a page by its URL, on any site.

| Field | What to send |
| --- | --- |
| `url` | The page's URL, up to 2,048 characters. |

```sh
curl https://heydeacon.com/api/v1/knowledge/pages \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Idempotency-Key: c81e728d-9d4c-4f63-8a2b-7e1f0d3c5b42" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://acme.co/shipping"}'
```

Returns 202 with the source. Its name is its URL until Deacon has read the page's title.

```json
{
  "id": "bv2mz865",
  "untrusted": {
    "name": "acme.co/shipping",
    "url": "https://acme.co/shipping",
    "text": ""
  },
  "type": "page",
  "crawled": false,
  "size": 0,
  "created_at": "2026-11-02T05:38:52Z",
  "changed_at": null,
  "state": "reading",
  "problem": null,
  "dashboard_url": "https://heydeacon.com/acme/knowledge?source=bv2mz865",
  "api_url": "https://heydeacon.com/api/v1/knowledge/bv2mz865"
}
```

- Reading the page uses one page of your monthly limit. Once that's used up, this is refused, and the message gives the date it resets. If it runs out while a page is queued, the page isn't read, and `problem` says why.
- Adding a URL your team has already added reads it again, on the same source, and uses another page. [Re-fetch a page](#re-fetch-a-page) uses none.
- A page from a crawl of your site is refused, and the message names the site.

### Re-fetch a page

`POST` `/api/v1/knowledge/{id}/refetch`

Reads a page again from its URL, without using your monthly page limit. The new text goes live at once, unless it's under half the page's old size. Then it's `waiting`. Deacon doesn't re-read pages by itself, so call this when a page changes, and read `problem` afterwards to find out if it stopped working.

```sh
curl -X POST https://heydeacon.com/api/v1/knowledge/t7dkqm3p/refetch \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

Returns 202 with the source. You can re-fetch each page once every 24 hours. Trying sooner returns 422, and so does any source that isn't a page.

```json
{
  "id": "t7dkqm3p",
  "untrusted": {
    "name": "Pricing",
    "url": "https://acme.co/pricing",
    "text": "Starter is $40 a month, billed yearly."
  },
  "type": "page",
  "crawled": false,
  "size": 38,
  "created_at": "2026-11-02T03:11:49Z",
  "changed_at": null,
  "state": "reading",
  "problem": null,
  "dashboard_url": "https://heydeacon.com/acme/knowledge?source=t7dkqm3p",
  "api_url": "https://heydeacon.com/api/v1/knowledge/t7dkqm3p"
}
```

## Help document and Instructions

### Read the help document

`GET` `/api/v1/help-document`

Your team's help document, with its sections in order.

```sh
curl https://heydeacon.com/api/v1/help-document \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "dashboard_url": "https://heydeacon.com/acme/knowledge",
  "sections": [
    {
      "id": "w2bnz6rk",
      "untrusted": {
        "title": "Refunds",
        "text": "Refunds within 30 days, no questions asked."
      },
      "version": 3,
      "state": "live",
      "problem": null,
      "changed_at": "2026-11-02T03:11:49Z"
    }
  ]
}
```

- `version` goes up with each change. Send it back when you [change a section](#change-a-section).
- `state` is `reading` while Deacon processes a change, then `live` or `failed`. `problem` says why it failed. While it's `reading`, Deacon still answers from the section's last version.

### Add a section

`POST` `/api/v1/help-document/sections`

Adds a section at the end of the help document.

| Field | What to send |
| --- | --- |
| `title` | Its title, up to 255 characters. |
| `text` | Its text, up to 4,000 characters. |

```sh
curl https://heydeacon.com/api/v1/help-document/sections \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Idempotency-Key: a87ff679-a2f3-4e71-9c4d-8b6e5f0a1d23" \
  -H "Content-Type: application/json" \
  -d '{"title": "Shipping", "text": "We ship in 2 days."}'
```

Returns 202 with the section. It goes live once Deacon has processed it, with no need to retrain.

```json
{
  "id": "cnk35qv5",
  "untrusted": {
    "title": "Shipping",
    "text": "We ship in 2 days."
  },
  "version": 1,
  "state": "reading",
  "problem": null,
  "changed_at": "2026-11-02T05:38:53Z"
}
```

A title already used anywhere in your knowledge is refused. So is a section that would take your knowledge over your plan's size limit.

### Change a section

`PUT` `/api/v1/help-document/sections/{id}`

Changes a section's title and text. If the section has changed since you read it, this returns 409 and changes nothing, so two writers can't overwrite each other. Read it again and redo your edit.

| Field | What to send |
| --- | --- |
| `title` | Its title, up to 255 characters. |
| `text` | Its text, up to 4,000 characters. |
| `version` | The `version` you read. |

```sh
curl -X PUT https://heydeacon.com/api/v1/help-document/sections/w2bnz6rk \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title": "Refunds", "text": "Refunds within 60 days.", "version": 3}'
```

Returns 202 with the section. Every version stays in its history on the dashboard. To remove a section, use the dashboard.

```json
{
  "id": "w2bnz6rk",
  "untrusted": {
    "title": "Refunds",
    "text": "Refunds within 60 days."
  },
  "version": 4,
  "state": "reading",
  "problem": null,
  "changed_at": "2026-11-02T05:38:54Z"
}
```

### Read the Instructions

`GET` `/api/v1/instructions`

Your team's Instructions, which tell Deacon how to answer. `untrusted.text` is `null` if your team hasn't saved any. Keys can read them but not change them.

```sh
curl https://heydeacon.com/api/v1/instructions \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "untrusted": {
    "text": "Be brief. Hand billing questions to us."
  },
  "dashboard_url": "https://heydeacon.com/acme/instructions"
}
```

## Analytics

### Read What needs you

`GET` `/api/v1/needs-you`

The list on both Analytics pages, with the same counts. It shows who's waiting for a reply, what's broken, what Deacon couldn't answer, and what's stopping it from working. `range` is `7d`, `30d` (the default), `90d` or `all`.

```sh
curl "https://heydeacon.com/api/v1/needs-you?range=7d" \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "range": "7d",
  "dashboard_url": "https://heydeacon.com/acme/analytics/widget?range=7d",
  "waiting_for_reply": {
    "people": 1,
    "asked_for_a_person": 0,
    "newest": [
      {
        "untrusted": {
          "question": null,
          "page": null
        },
        "conversation_id": "kx7mqprt",
        "asked_on": "2026-10-31"
      }
    ]
  },
  "problems_in_your_product": {
    "bug_reports": 3,
    "topics": [
      {
        "id": "h4mzq8tv",
        "untrusted": {
          "label": "Export button does nothing"
        },
        "type": "bug",
        "people": 3,
        "questions": 3,
        "unanswered": 2,
        "still_unanswered": 2,
        "quote": {
          "untrusted": {
            "question": "Export is broken in Safari",
            "page": "/app/reports"
          },
          "conversation_id": "ppbzb5vv",
          "answer_id": "f7ppwp5j",
          "asked_on": "2026-10-31",
          "outcome": "unanswered",
          "still_unanswered": true,
          "fix_suggested": false
        }
      }
    ],
    "topics_in_all": 1,
    "not_in_a_topic": {
      "questions": 0,
      "unanswered": 0,
      "still_unanswered": 0,
      "asked": []
    },
    "site_errors": [
      {
        "untrusted": {
          "message": "TypeError: plan is undefined",
          "page": "/pricing"
        },
        "category": null,
        "first_seen_on": "2026-11-01",
        "last_seen_on": "2026-11-01",
        "people": 2,
        "visits": 2,
        "times": 3
      }
    ],
    "site_errors_in_all": 1
  },
  "account_and_billing": {
    "topics": [],
    "topics_in_all": 0,
    "not_in_a_topic": {
      "questions": 0,
      "unanswered": 0,
      "still_unanswered": 0,
      "asked": []
    }
  },
  "what_deacon_didnt_know": {
    "topics": [],
    "topics_in_all": 0,
    "not_in_a_topic": {
      "questions": 2,
      "unanswered": 0,
      "still_unanswered": 0,
      "asked": []
    }
  },
  "setup": [
    {
      "type": "signed_in",
      "people": 2
    },
    {
      "type": "goal_waiting",
      "untrusted": {
        "goal": "Sign up"
      },
      "named_on": "2026-10-31"
    }
  ]
}
```

| Group | Holds |
| --- | --- |
| `waiting_for_reply` | How many people left their email and are still waiting for your reply, the three newest, and how many asked for a person. |
| `problems_in_your_product` | Bug reports, and the three JavaScript errors on your site that hit the most visits. |
| `account_and_billing` | Questions about a visitor's own account, invoices or payments, which Deacon can't look up. |
| `what_deacon_didnt_know` | Every other question Deacon couldn't answer. |
| `setup` | What's stopping Deacon from working fully. |

`problems_in_your_product`, `account_and_billing` and `what_deacon_didnt_know` have the same parts.

- `topics` is the group's five biggest topics, and `topics_in_all` says how many it has.
- `not_in_a_topic` counts the group's questions that aren't in any of its topics. `asked` quotes up to three that are still unanswered.
- To see a topic's chats, send its `id` to `/api/v1/conversations?topic=`. To open a question's chat, send its `conversation_id` to `/api/v1/conversations/{id}`. `answer_id` is the `id` of Deacon's answer in that chat's messages. `asked_on` is the day it was asked.
- A topic only counts the questions that belong in its group. `questions` is all of them, `unanswered` is how many Deacon couldn't answer when they were asked, and `still_unanswered` is how many nobody has answered since.
- Each group counts a topic's people differently. `problems_in_your_product` has `people`, everyone who reported it. `what_deacon_didnt_know` has `people_unanswered`, the people Deacon couldn't answer. `account_and_billing` has `people_still_unanswered`, the people still waiting for an answer. `/api/v1/topics` counts everyone who asked.
- A question's `fix_suggested` is `true` while a suggested fix is waiting for your team to save or discard it.
- Questions are cut to 280 characters, and error messages to 200.

Each row under `setup` has a `type`.

| `type` | Means |
| --- | --- |
| `silent` | Deacon hasn't loaded on your site since `since`. |
| `allowance` | Your plan's answers have run out or are running out. |
| `signed_in` | `people` is how many visitors Deacon couldn't answer and can't reach, because they left no email. Telling Deacon who's signed in lets them leave one with a tap. |
| `goal_local`, `goal_waiting`, `goal_stalled` | A goal isn't being counted. Its name is under `untrusted`, because anyone with your site's public key can send one. |

### List topics

`GET` `/api/v1/topics`

Your team's topics, as on Analytics › Widget. A topic is a group of questions asking the same thing. A topic is listed once two people have asked, or once it's marked done, with the most people first. `range` defaults to `30d`, and `type` limits it to one type.

```sh
curl "https://heydeacon.com/api/v1/topics?type=bug" \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "data": [
    {
      "id": "h4mzq8tv",
      "untrusted": {
        "label": "Export button does nothing"
      },
      "type": "bug",
      "people": 3,
      "questions": 3,
      "answered": 1,
      "still_unanswered": 2,
      "last_asked_on": "2026-11-01",
      "done_on": null,
      "dashboard_url": "https://heydeacon.com/acme/conversations?finding=topic%3Ah4mzq8tv"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

- `answered` is how many questions Deacon answered. `still_unanswered` is how many it couldn't answer and nobody has answered since.
- `done_on` is the day it was marked done. It goes back to `null` once more people have asked than had when it was marked.
- The label is under `untrusted`, because a model wrote it from what visitors asked.
- To get its chats, call `/api/v1/conversations?topic=` with its `id`.

### Mark a topic done

`POST` `/api/v1/topics/{id}/mark-done`

Marks a topic done, the same as pressing Done on What needs you. It leaves the list until more people ask about it than have so far, and visitors don't see any of this. Your release script can call this once the fix is out. Needs a Coding agent or Read and write key, and no body.

```sh
curl -X POST https://heydeacon.com/api/v1/topics/h4mzq8tv/mark-done \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "id": "h4mzq8tv",
  "done_on": "2026-11-02",
  "dashboard_url": "https://heydeacon.com/acme/analytics/widget"
}
```

### Show a topic again

`POST` `/api/v1/topics/{id}/show-again`

Undoes that, the same as pressing Show again. The topic goes back on What needs you. Needs the same keys, and returns the same fields with `done_on` set to `null`.

```sh
curl -X POST https://heydeacon.com/api/v1/topics/h4mzq8tv/show-again \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "id": "h4mzq8tv",
  "done_on": null,
  "dashboard_url": "https://heydeacon.com/acme/analytics/widget"
}
```

### Suggest a fix

`POST` `/api/v1/answers/{id}/suggest-fix`

Suggests a better version of one of Deacon's answers. It waits in that question's fix box in Conversations, and visitors see no change until someone on your team saves it. A second fix for the same answer replaces the first. Needs a Coding agent or Read and write key.

`{id}` is a question's `answer_id` from [What needs you](#read-what-needs-you), or the `id` of a message of type `answer`.

| Field | What to send |
| --- | --- |
| `text` | The better answer, up to 4,000 characters. |

```sh
curl https://heydeacon.com/api/v1/answers/m4rwx8zc/suggest-fix \
  -H "Authorization: Bearer $DEACON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Yes. Open Reports and press Export, then choose CSV."}'
```

```json
{
  "answer_id": "m4rwx8zc",
  "dashboard_url": "https://heydeacon.com/acme/conversations?selected=kx7mqprt&turn=m4rwx8zc"
}
```

### List site errors

`GET` `/api/v1/site-errors`

JavaScript errors from your own site, as on Analytics › Website, with one row per bug. The ones that hit the most visits come first. `range` defaults to `30d`.

```sh
curl https://heydeacon.com/api/v1/site-errors \
  -H "Authorization: Bearer $DEACON_API_KEY"
```

```json
{
  "data": [
    {
      "untrusted": {
        "message": "TypeError: plan is undefined",
        "page": "/pricing",
        "frames": [
          {
            "file": "https://acme.co/assets/app-4f3a9b2c.js",
            "line": 214,
            "column": 19,
            "function": "render"
          }
        ]
      },
      "category": null,
      "first_seen_on": "2026-11-01",
      "last_seen_on": "2026-11-01",
      "people": 2,
      "visits": 2,
      "times": 3
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

- `category` is what the message usually means, when Deacon can tell: `deploy`, `ad_blocker`, `cross_origin`, `hydration`, `network` or `not_readable`.
- `people` counts each person once a day, as your site's visits do.
- Anyone with your site's public key can send an error, so everything about it is under `untrusted`. Its `message` and `page` are each cut to 200 characters, and `frames` comes from the stack of its newest occurrence.
- You never get the stack's raw text. `frames` holds up to 10 lines that look like stack frames, from a script on one of your own sites at a plain URL (no query, escapes or letters outside ASCII, 200 characters at most). `function` is `null` unless it looks like a function name.

## Fields

### A conversation

- `outcome` is how the chat went: `handoff`, `capped`, `answered` or `dismissed`. It's `null` if something still needs attention, such as a question Deacon couldn't answer, an answer that failed, or a question still waiting for its answer.
- `exchange_count` counts exchanges, so a question and Deacon's answer count as one. A teammate's reply isn't counted.
- `unanswered_count` is how many questions Deacon couldn't answer from your knowledge. `awaiting_answer_count` is how many it hasn't answered yet. `failed_count` is how many answers failed.
- `held_by_teammate` is `true` while a teammate has taken over the chat. `playground` is `true` if any of it came from your own Playground.
- `reply_address_source` says whether a teammate's reply would be emailed, and where the address came from. It's `typed` if the visitor typed it, `signed_in` if their sign-in proved it, or `null` if there's no address. The address is in `untrusted.reply_email`, and a `typed` one is whatever the visitor typed.
- `verified` is `true` when your server proved who the visitor is with your identity secret. `untrusted.proved_as` says who that is, with `external_id` (your app's id for them), `email`, `name` and `site`. `site` has the `host` they proved it on, and whether it's a `development` address.
- An `external_id` only identifies someone on the site that signed it, because a staging or local site may use the same id for someone else.
- `untrusted.page_said_signed_in_as` is who the page said was signed in, with no proof. Anyone can make a page say anything, so never use it to identify someone.
- `untrusted.first_page` is the page the visitor was on when the chat began. In a Coding agent key's list it has no query or fragment, which can hold an email or a token.

### A message

Every message has every field, `null` where it doesn't apply.

- `by` is who wrote it: `visitor`, `deacon` or `teammate`. A reply sent with a Read and write key counts as a `teammate`, named after the person who made the key.
- `type` is `question` (the visitor's words), `answer` (Deacon's), `reply` (a teammate's) or `address` (an email the visitor left). For an `address`, `address_source` is `typed` or `signed_in`, and the email is in `untrusted.email`.
- `verified` is `true` if your server had proved who the visitor was when the message was written.
- On an answer, `answered` says whether Deacon answered from your knowledge, and `in_reply_to` is the `id` of the question it answers.
- `tag` says how an answer turned out: `gap` (Deacon couldn't answer from your knowledge), `handoff` (the visitor asked for a person), `failed` (something went wrong), `capped` (your plan's answers had run out), `answered`, `dismissed` (your team set it aside) or `other`.
- On older chats, where questions weren't saved as separate messages, an answer has the question in `untrusted.question` instead.
- On a question, `intent` is its type: `how_to`, `capability`, `feature_request`, `bug_report`, `account_billing`, `pricing`, `human_request`, `feedback`, `chit_chat` or `other`.
- `topic_id` and `untrusted.topic_label` name the topic it was grouped into. `topic_type` is that topic's type: `bug`, `ask`, `how_to`, `pricing`, `account_billing`, `human` or `feedback`. It's `null` if no single type stands out.
- A topic's type is worked out from all its questions, over all time, so it can differ from the type the dashboard's Topics list shows for a shorter range.
- `files` lists the files sent with it. Each has an `id`, its name in `untrusted.name`, a `content_type` detected from the file's bytes, a `size` in bytes and an `api_url`.

### An event

- `id` is `evt_` followed by a ULID. It never changes, and it's the same in a webhook and in the events list, so use it to drop repeats.
- `type` is what happened, from [Event types](#event-types).
- `timestamp` is when it happened: when the message was written, or when the chat ended. For a bug report or feature request, it's when Deacon answered the question.
- `data.conversation` is the chat, with the same fields as `/api/v1/conversations`. In `conversation.ended`, its counts cover only the messages in the event.
- `data.message` is the message it's about, with the fields under [A message](#a-message).

| `type` | `data.message` |
| --- | --- |
| `visitor.reported_bug`, `visitor.requested_feature` | The visitor's question, with its `intent`, and its topic if it has one yet. |
| `visitor.asked_for_person`, `visitor.wrote_back` | The visitor's message. |
| `visitor.left_email`, `visitor.waiting_signed_in` | The email, as a message of type `address`. |
| `conversation.started` | The first message. |
| `conversation.ended` | `null`. `data.messages` holds the chat's messages instead, oldest first. You get the newest 1,000, and `data.messages_truncated` is `true` if there were more. |

## What can change

`/api/v1` keeps working. Here's what that means, including for the MCP server and webhooks.

- **We add things without warning.** New fields, routes, filters and event types, and new words in a list such as `tag` or `intent`. So ignore what you don't recognise, and don't fail on a new word.
- **We won't rename or remove anything, or change what it means.** A change like that goes in `/api/v2`. `/api/v1` then keeps running for at least six months, and we email every owner of a team with a key, with the date it stops. Its responses will have `Deprecation` and `Sunset` headers until then.
- **An event's shape stays.** If one has to change, it becomes a new `type`, and the old one keeps sending for the same six months.
- **Sentences can change.** That's every `message`, and every line of Deacon's in an MCP result. Branch on `code`.

### Changelog

Every change to the API, webhooks and the MCP server is listed here, newest first.

- **November 2026.** First release.

## Forgetting a person

When an owner deletes everything from a person on the dashboard, Deacon deletes all it holds on them. It can't delete what has already left Deacon: webhooks it sent, anything a tool or coding agent has read, and any CSV someone saved. If your systems keep any of that, delete it yourself.
