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.
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.
export DEACON_API_KEY="your key"2. Make your first call. This asks Deacon which team the key belongs to.
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.
{
"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.
curl "https://heydeacon.com/api/v1/conversations?limit=1" \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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.
const response = await fetch('https://heydeacon.com/api/v1/team', {
headers: { Authorization: `Bearer ${process.env.DEACON_API_KEY}` },
});
console.log(await response.json());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 |
| Open a ticket when a visitor reports a bug, or add a lead to your CRM when they leave their email | Webhooks |
| Find everything a customer has said, from their email | List conversations |
| Pull your chats into your own tools | List conversations |
| Reply to visitors from your own admin panel | Reply to a visitor |
| Add to what Deacon knows | Add text or Add a page |
| Get the list of what needs your attention | Read What needs you |
| Mark a bug done once your release is out | Mark a topic done |
| Catch up on what your server missed | 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.
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.
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, 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.
{
"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. |
| 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-Aftersays 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.
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 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.
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 withidempotency_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/webhooksrefuses one withidempotency_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,PATCHandDELETEdon'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 | Any |
DELETE /api/v1/key | Revoke this key | Any |
GET /api/v1/conversations | List conversations | Any |
GET /api/v1/conversations/{id} | Read a conversation | Any |
POST /api/v1/conversations/{id}/replies | Reply to a visitor | Read and write |
POST /api/v1/conversations/{id}/hand-back | Hand a chat back | Read and write |
GET /api/v1/files/{id} | Get a chat file | Any |
GET /api/v1/knowledge | List knowledge | Any |
GET /api/v1/knowledge/{id} | Read a source | Any |
DELETE /api/v1/knowledge/{id} | Remove a source | Read and write |
POST /api/v1/knowledge/texts | Add text | Read and write |
POST /api/v1/knowledge/pages | Add a page | Read and write |
POST /api/v1/knowledge/{id}/refetch | Re-fetch a page | Read and write |
GET /api/v1/help-document | Read the help document | Any |
POST /api/v1/help-document/sections | Add a section | Read and write |
PUT /api/v1/help-document/sections/{id} | Change a section | Read and write |
GET /api/v1/instructions | Read the Instructions | Any |
GET /api/v1/needs-you | Read What needs you | Any |
GET /api/v1/topics | List topics | Any |
POST /api/v1/topics/{id}/mark-done | Mark a topic done | Coding agent or Read and write |
POST /api/v1/topics/{id}/show-again | Show a topic again | Coding agent or Read and write |
POST /api/v1/answers/{id}/suggest-fix | Suggest a fix | Coding agent or Read and write |
GET /api/v1/site-errors | List site errors | Any |
GET /api/v1/events | List events | Not a Coding agent key |
POST /api/v1/webhooks | Add a webhook from your code | Not a Coding agent key |
GET /api/v1/webhooks | List a key's webhooks | Not a Coding agent key |
PATCH /api/v1/webhooks/{id} | Change a webhook | Not a Coding agent key |
DELETE /api/v1/webhooks/{id} | 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.
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. 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.
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.
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 explains every field.
{
"id": "evt_01M6DC34TBKBDTMSDSAK1ANWXN",
"type": "visitor.reported_bug",
"timestamp": "2026-11-02T05:35:39Z",
"data": {
"conversation": {
"id": "yjc8zx44",
"untrusted": {
"subject": "The export button does nothing",
"first_page": "https://acme.co/app/reports"
},
"api_url": "https://heydeacon.com/api/v1/conversations/yjc8zx44",
"dashboard_url": "https://heydeacon.com/acme/conversations?selected=yjc8zx44"
},
"message": {
"id": "c6gmg6yf",
"untrusted": {
"text": "The export button does nothing",
"page": "https://acme.co/app/reports"
},
"by": "visitor",
"type": "question",
"intent": "bug_report"
}
}
}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
httpson 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
pingand 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, 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.
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)
);
});
}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 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. 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_personandconversation.startedusually have anullintentand no topic.visitor.reported_bugandvisitor.requested_featurehave theirintent, but usually no topic yet.
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-signatureholds 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. |
curl "https://heydeacon.com/api/v1/events?type=visitor.reported_bug,visitor.requested_feature&limit=1" \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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_cursorback ascursor. - To catch up after your server was down, send the
idof the last event you saw asending_before. You get the events straight after it, with the newest first on each page. Send that page'snext_cursorasending_beforeto get the next page, until it'snull. - Send
cursororending_before, not both, and the sametypeeach time. - An
idstill 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
idit has seen and polls withending_before, it won't miss any. - A page stops early at about 2 MB, because
conversation.endedevents include the chat's messages. It then has fewer thanlimitevents but still gives anext_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. |
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.
{
"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.
curl https://heydeacon.com/api/v1/webhooks \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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_sinceis when it started failing, ornullif it isn't failing.turned_off_atis when Deacon stopped sending to it, ornull. 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. This replaces the old list. |
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.
{
"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.
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.
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_teamreturns the key's team, and the key's name and type. Call it first to check the connection.get_needs_youreturns What needs you for arange. Start here to find what broke or confused visitors.search_conversationslists conversations, filtered bysearch,email,tag,topic,intent,waiting_for_replyandupdated_since.searchis a phrase visitors typed, matched exactly, so uselist_topicsfor a subject. It says whether a visitor left an email, but not what it is.get_conversationreturns one conversation byid, with its messages and the email the visitor left. It stays unread on the dashboard.list_topicslists topics for arange, and onetypeif you send it.askis 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_errorslists your site's JavaScript errors for arange.get_knowledgelists your pages, texts and files. Withid, it returns one source's text. Withhelp_document, it returns the help document.mark_topic_donemarks a topic done bytopic_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_fixsuggests a better version of one of Deacon's answers. Send theanswer_idandtextof 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>.
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.
{
"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 byu003C,u003Eoru0026. 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_cursorfor a list,beforefor a conversation,fromfor a source's text or the help document. To fit,get_needs_youshortens the text underuntrusted, 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.
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.
curl https://heydeacon.com/api/v1/team \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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.
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.
List conversations
GET/api/v1/conversations
Your team's conversations, most recently active first. Each has the same fields as 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 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 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. |
curl "https://heydeacon.com/api/v1/conversations?email=sam@example.com" \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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.
curl https://heydeacon.com/api/v1/conversations/kx7mqprt \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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 and 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. |
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.
{
"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. If a teammate already holds the chat, the reply still goes through. Check
held_by_teammatefirst if your tool shouldn't talk over a person. conversation.reply_address_sourcesays whether the reply is also emailed. If it isn'tnull, 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'snull, nobody is emailed, and the visitor only sees the reply if they come back to the chat.typedmeans 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_atshows 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
idreturns 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. 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.
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.
{
"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.
curl https://heydeacon.com/api/v1/files/f3nq8zrt \
-H "Authorization: Bearer $DEACON_API_KEY" \
--output report.pngReturns 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.
curl https://heydeacon.com/api/v1/knowledge \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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
}typeispage,textorfile.crawledistrueif it came from a crawl of your site.sizeis the text's size in bytes.changed_atis when its text last changed, ornullif it never has.problemsays why the last read or training went wrong, or isnull. Alivepage 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.
curl https://heydeacon.com/api/v1/knowledge/t7dkqm3p \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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.
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
idreturns 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. |
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.
{
"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 iswaitinguntil someone presses Retrain agent on the dashboard. - A
namealready 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. |
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.
{
"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
problemsays why. - Adding a URL your team has already added reads it again, on the same source, and uses another 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.
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.
{
"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.
curl https://heydeacon.com/api/v1/help-document \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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"
}
]
}versiongoes up with each change. Send it back when you change a section.stateisreadingwhile Deacon processes a change, thenliveorfailed.problemsays why it failed. While it'sreading, 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. |
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.
{
"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. |
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.
{
"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.
curl https://heydeacon.com/api/v1/instructions \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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.
curl "https://heydeacon.com/api/v1/needs-you?range=7d" \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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.
topicsis the group's five biggest topics, andtopics_in_allsays how many it has.not_in_a_topiccounts the group's questions that aren't in any of its topics.askedquotes up to three that are still unanswered.- To see a topic's chats, send its
idto/api/v1/conversations?topic=. To open a question's chat, send itsconversation_idto/api/v1/conversations/{id}.answer_idis theidof Deacon's answer in that chat's messages.asked_onis the day it was asked. - A topic only counts the questions that belong in its group.
questionsis all of them,unansweredis how many Deacon couldn't answer when they were asked, andstill_unansweredis how many nobody has answered since. - Each group counts a topic's people differently.
problems_in_your_producthaspeople, everyone who reported it.what_deacon_didnt_knowhaspeople_unanswered, the people Deacon couldn't answer.account_and_billinghaspeople_still_unanswered, the people still waiting for an answer./api/v1/topicscounts everyone who asked. - A question's
fix_suggestedistruewhile 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.
curl "https://heydeacon.com/api/v1/topics?type=bug" \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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
}answeredis how many questions Deacon answered.still_unansweredis how many it couldn't answer and nobody has answered since.done_onis the day it was marked done. It goes back tonullonce 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 itsid.
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.
curl -X POST https://heydeacon.com/api/v1/topics/h4mzq8tv/mark-done \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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.
curl -X POST https://heydeacon.com/api/v1/topics/h4mzq8tv/show-again \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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, or the id of a message of type answer.
| Field | What to send |
|---|---|
text | The better answer, up to 4,000 characters. |
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."}'{
"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.
curl https://heydeacon.com/api/v1/site-errors \
-H "Authorization: Bearer $DEACON_API_KEY"{
"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
}categoryis what the message usually means, when Deacon can tell:deploy,ad_blocker,cross_origin,hydration,networkornot_readable.peoplecounts 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. Itsmessageandpageare each cut to 200 characters, andframescomes from the stack of its newest occurrence. - You never get the stack's raw text.
framesholds 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).functionisnullunless it looks like a function name.
Fields
A conversation
outcomeis how the chat went:handoff,capped,answeredordismissed. It'snullif 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_countcounts exchanges, so a question and Deacon's answer count as one. A teammate's reply isn't counted.unanswered_countis how many questions Deacon couldn't answer from your knowledge.awaiting_answer_countis how many it hasn't answered yet.failed_countis how many answers failed.held_by_teammateistruewhile a teammate has taken over the chat.playgroundistrueif any of it came from your own Playground.reply_address_sourcesays whether a teammate's reply would be emailed, and where the address came from. It'stypedif the visitor typed it,signed_inif their sign-in proved it, ornullif there's no address. The address is inuntrusted.reply_email, and atypedone is whatever the visitor typed.verifiedistruewhen your server proved who the visitor is with your identity secret.untrusted.proved_assays who that is, withexternal_id(your app's id for them),email,nameandsite.sitehas thehostthey proved it on, and whether it's adevelopmentaddress.- An
external_idonly 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_asis 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_pageis 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.
byis who wrote it:visitor,deaconorteammate. A reply sent with a Read and write key counts as ateammate, named after the person who made the key.typeisquestion(the visitor's words),answer(Deacon's),reply(a teammate's) oraddress(an email the visitor left). For anaddress,address_sourceistypedorsigned_in, and the email is inuntrusted.email.verifiedistrueif your server had proved who the visitor was when the message was written.- On an answer,
answeredsays whether Deacon answered from your knowledge, andin_reply_tois theidof the question it answers. tagsays 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) orother.- On older chats, where questions weren't saved as separate messages, an answer has the question in
untrusted.questioninstead. - On a question,
intentis its type:how_to,capability,feature_request,bug_report,account_billing,pricing,human_request,feedback,chit_chatorother. topic_idanduntrusted.topic_labelname the topic it was grouped into.topic_typeis that topic's type:bug,ask,how_to,pricing,account_billing,humanorfeedback. It'snullif 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.
fileslists the files sent with it. Each has anid, its name inuntrusted.name, acontent_typedetected from the file's bytes, asizein bytes and anapi_url.
An event
idisevt_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.typeis what happened, from Event types.timestampis 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.conversationis the chat, with the same fields as/api/v1/conversations. Inconversation.ended, its counts cover only the messages in the event.data.messageis the message it's about, with the fields under 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
tagorintent. 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/v1then 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 haveDeprecationandSunsetheaders 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 oncode.
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.