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.

View as Markdown

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.

Terminal
export DEACON_API_KEY="your key"

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

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

Response
{
  "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.

Request
curl "https://heydeacon.com/api/v1/conversations?limit=1" \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

Node
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 toGo to
Ask your coding agent what broke, and have it fix your codeMCP server
Open a ticket when a visitor reports a bug, or add a lead to your CRM when they leave their emailWebhooks
Find everything a customer has said, from their emailList conversations
Pull your chats into your own toolsList conversations
Reply to visitors from your own admin panelReply to a visitor
Add to what Deacon knowsAdd text or Add a page
Get the list of what needs your attentionRead What needs you
Mark a bug done once your release is outMark a topic done
Catch up on what your server missedList 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.

KindForCan do
Coding agentClaude Code, Cursor and other coding agentsRead everything except events and webhooks. Mark a topic done and suggest a fix. It's the only kind that works with the MCP server.
ReadYour own scripts, and automation tools that can call an APIRead everything, and add its own webhooks.
Read and writeYour own serverEverything 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.

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"
}
StatuscodeWhen
400invalid_cursorA cursor that didn't come from this list, or an ending_before that isn't an event's id.
400invalid_jsonThe body doesn't parse.
401invalid_keyThe key is missing, unknown or revoked. The response has a WWW-Authenticate: Bearer header.
403wrong_key_kindThe key's kind can't use this route. The message says which can.
403key_maker_goneThe key tried to reply or hand back a chat, and the person who made it has left your team. Make a new key.
404not_foundYour team has nothing with that id, even if another team does. Or there's no such route.
405method_not_allowedThe route doesn't take that method. The Allow header lists the ones it does.
409version_conflictThe section has changed since you read it.
409webhook_limit_reachedThe key or team has reached its webhook limit.
415unsupported_media_typeThe body isn't JSON.
422invalid_requestA value isn't valid. errors names it.
422unknown_parameterThe route doesn't take that parameter. The message lists the ones it does.
422idempotency_key_empty, idempotency_key_reused, idempotency_key_not_takenSee Retrying with Idempotency-Key.
422holds_secretThe text contains a working Deacon key or secret.
422name_takenAnother of your sources already uses that name or title.
422knowledge_limit_reachedIt would take your knowledge over your plan's size limit.
422monthly_pages_spentYour monthly pages are used up. The message says when they reset.
422page_in_crawlThat page comes from a crawl of your site. Manage it on the dashboard.
422not_a_pageOnly a page can be re-fetched.
422refetched_recentlyThat page was re-fetched in the last 24 hours.
422nothing_to_hand_backNo teammate holds the chat, or it has ended.
429rate_limitedOver a limit. Retry-After says how many seconds to wait.
429emailed_replies_limitYour keys have emailed 50 replies this hour. Retry-After says how much of the hour is left.
500server_errorSomething went wrong at Deacon's end. Try again, and quote the request_id if it keeps happening.
503unavailableDeacon 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.

WhatLimitShared by
All requests300 a minuteYour team's keys
All requests from a Coding agent key60 a minuteThat key alone
List conversations60 a minuteYour team's keys
List events60 a minuteYour team's keys
Get a chat file60 a minuteYour team's keys
What needs you, topics and site errors, together10 a minuteYour team's keys
Requests with an unknown key60 a minuteOne IP address
Replies sent by email50 an hourYour team's keys
Pages added60 an hourYour team, including the dashboard
Texts and sections written300 an hourYour 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.

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

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

RouteWhat it doesKey
GET /api/v1/teamCheck the connectionAny
DELETE /api/v1/keyRevoke this keyAny
GET /api/v1/conversationsList conversationsAny
GET /api/v1/conversations/{id}Read a conversationAny
POST /api/v1/conversations/{id}/repliesReply to a visitorRead and write
POST /api/v1/conversations/{id}/hand-backHand a chat backRead and write
GET /api/v1/files/{id}Get a chat fileAny
GET /api/v1/knowledgeList knowledgeAny
GET /api/v1/knowledge/{id}Read a sourceAny
DELETE /api/v1/knowledge/{id}Remove a sourceRead and write
POST /api/v1/knowledge/textsAdd textRead and write
POST /api/v1/knowledge/pagesAdd a pageRead and write
POST /api/v1/knowledge/{id}/refetchRe-fetch a pageRead and write
GET /api/v1/help-documentRead the help documentAny
POST /api/v1/help-document/sectionsAdd a sectionRead and write
PUT /api/v1/help-document/sections/{id}Change a sectionRead and write
GET /api/v1/instructionsRead the InstructionsAny
GET /api/v1/needs-youRead What needs youAny
GET /api/v1/topicsList topicsAny
POST /api/v1/topics/{id}/mark-doneMark a topic doneCoding agent or Read and write
POST /api/v1/topics/{id}/show-againShow a topic againCoding agent or Read and write
POST /api/v1/answers/{id}/suggest-fixSuggest a fixCoding agent or Read and write
GET /api/v1/site-errorsList site errorsAny
GET /api/v1/eventsList eventsNot a Coding agent key
POST /api/v1/webhooksAdd a webhook from your codeNot a Coding agent key
GET /api/v1/webhooksList a key's webhooksNot a Coding agent key
PATCH /api/v1/webhooks/{id}Change a webhookNot a Coding agent key
DELETE /api/v1/webhooks/{id}Delete a webhookNot 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

typeNameSent whenYou could
visitor.reported_bugA visitor reported a bugDeacon decides a question is reporting something broken. Once per chat.Open a ticket.
visitor.requested_featureA visitor asked for a featureDeacon decides a question is asking for something your product doesn't do. Once per chat.Add it to your roadmap.
visitor.asked_for_personA visitor asked for a personA visitor asks to talk to a person.Alert whoever's on support.
visitor.left_emailA visitor left their emailA visitor leaves their email. Once per chat.Add a lead to your CRM.
visitor.wrote_backA visitor wrote back to a teammateA visitor sends a message in a chat a teammate has taken over.Alert that teammate.
visitor.waiting_signed_inA signed-in visitor is waiting for a replyDeacon 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.startedA chat startedA visitor sends the first message in a chat.Count chats in your own analytics.
conversation.endedA chat endedThe 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.

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

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

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",
        "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 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, 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.

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

HeaderHolds
webhook-idThe event's id.
webhook-timestampWhen this attempt was sent, as a Unix timestamp in seconds.
webhook-signaturev1, 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_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 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.

ParameterDoes
typeOnly these types, separated by commas, such as visitor.reported_bug,visitor.left_email.
cursorA page's next_cursor. Returns the next, older events.
ending_beforeAn event's id. Returns the events straight after it.
limitPage size, from 1 to 100. The default is 25.
Request
curl "https://heydeacon.com/api/v1/events?type=visitor.reported_bug,visitor.requested_feature&limit=1" \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

FieldWhat to send
urlA 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.
eventsA list of one or more event types.
Request
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.

Response
{
  "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.

Request
curl https://heydeacon.com/api/v1/webhooks \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

FieldWhat to send
urlA new URL, with the same rules as adding one.
eventsThe new list of event types. This replaces the old list.
Request
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.

Response
{
  "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.

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

ClientWhat to do
Claude CodeRun claude mcp add --transport http deacon https://heydeacon.com/mcp, then claude mcp login deacon
Claude on the webIn 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
CodexRun codex mcp add deacon --url https://heydeacon.com/mcp
CursorPress Add to Cursor on the Integrations page. Cursor asks you to install it, then to sign in
VS CodePress 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.

ClientWhere 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 for a range. Start here to find what broke or confused visitors.
  • search_conversations lists 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 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 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>.

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.

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.

StatusWhen
401The 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.
403A Read or Read and write key, or any call with an Origin header, because a browser should never hold this key.
429Over 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.

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

Request
curl https://heydeacon.com/api/v1/team \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

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

ParameterFinds
searchChats 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.
tagChats with an answer of this type: gap, handoff, failed, capped, answered or dismissed. A message explains them.
topicChats with a question in this topic. Send the topic's id.
waiting_for_replyWith true, chats where a visitor left their email and is still waiting for a reply.
intentChats with a question of this type, such as bug_report. A message lists the types.
updated_sinceChats active at or after this time. A time with an offset is converted, so 09:00:00+05:00 means 04:00 UTC.
emailChats 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, cursorSee Pagination.
Request
curl "https://heydeacon.com/api/v1/conversations?email=sam@example.com" \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

Request
curl https://heydeacon.com/api/v1/conversations/kx7mqprt \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

FieldWhat to send
textThe reply, up to 4,000 characters.
Request
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.

Response
{
  "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_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. 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.

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

Response
{
  "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.

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

Request
curl https://heydeacon.com/api/v1/knowledge \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.
stateMeans
readingDeacon is still reading it, and can't answer from all of it yet.
liveDeacon answers from it.
waitingDeacon 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.
failedDeacon 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.

Request
curl https://heydeacon.com/api/v1/knowledge/t7dkqm3p \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

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

FieldWhat to send
nameIts name, up to 255 characters.
textThe text, up to 1,500,000 characters.
Request
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.

Response
{
  "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.

FieldWhat to send
urlThe page's URL, up to 2,048 characters.
Request
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.

Response
{
  "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 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.

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

Response
{
  "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.

Request
curl https://heydeacon.com/api/v1/help-document \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.
  • 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.

FieldWhat to send
titleIts title, up to 255 characters.
textIts text, up to 4,000 characters.
Request
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.

Response
{
  "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.

FieldWhat to send
titleIts title, up to 255 characters.
textIts text, up to 4,000 characters.
versionThe version you read.
Request
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.

Response
{
  "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.

Request
curl https://heydeacon.com/api/v1/instructions \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

Request
curl "https://heydeacon.com/api/v1/needs-you?range=7d" \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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"
    }
  ]
}
GroupHolds
waiting_for_replyHow 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_productBug reports, and the three JavaScript errors on your site that hit the most visits.
account_and_billingQuestions about a visitor's own account, invoices or payments, which Deacon can't look up.
what_deacon_didnt_knowEvery other question Deacon couldn't answer.
setupWhat'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.

typeMeans
silentDeacon hasn't loaded on your site since since.
allowanceYour plan's answers have run out or are running out.
signed_inpeople 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_stalledA 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.

Request
curl "https://heydeacon.com/api/v1/topics?type=bug" \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

Request
curl -X POST https://heydeacon.com/api/v1/topics/h4mzq8tv/mark-done \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

Request
curl -X POST https://heydeacon.com/api/v1/topics/h4mzq8tv/show-again \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.

FieldWhat to send
textThe better answer, up to 4,000 characters.
Request
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."}'
Response
{
  "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.

Request
curl https://heydeacon.com/api/v1/site-errors \
  -H "Authorization: Bearer $DEACON_API_KEY"
Response
{
  "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.
  • 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.
typedata.message
visitor.reported_bug, visitor.requested_featureThe visitor's question, with its intent, and its topic if it has one yet.
visitor.asked_for_person, visitor.wrote_backThe visitor's message.
visitor.left_email, visitor.waiting_signed_inThe email, as a message of type address.
conversation.startedThe first message.
conversation.endednull. 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.