AcornReply API
Create support tickets in AcornReply from your product or AI agent: one endpoint, idempotent retries, and the end-user context your team sees in the inbox.
On this page
Overview
Create support tickets, manage your knowledge base, and embed live chat from your product or an AI agent. Three ways to integrate: embed live chat, connect an MCP client, or call the REST API.
Live chat embed
Add a chat bubble to your site with one script tag, style it from your workspace settings, swap the bubble for your own button, and tell AcornReply who the visitor is so your agents have context.
Add live chat to your site
Paste this snippet and a chat bubble appears in the bottom-right of every page. Visitors start a conversation, the messages land in your AcornReply inbox, and the replies your team sends show up in the widget in real time — even after the visitor closes and reopens it.
Copy it — with your workspace's key already filled in — from Settings → Channels → Live chat in the app. That's the whole drop-in. To control the widget from JavaScript — your own button, or identifying visitors — add one small line to this snippet, shown in the next two sections.
A floating bubble opens a chat card; on phones it becomes a full-screen sheet.
Replies arrive over a live connection, an unread badge sits on the bubble, and the widget follows the visitor's light/dark system theme.
One conversation per visitor, resumed across page loads — closing the card never drops the thread.
Styling is no-code. The bubble icon, its light and dark colors, the chat header logo, and the greeting and first message are all set under Settings → Appearance in the app — you don't edit them here. This page covers only the code-level integration: where the snippet goes and how to drive the widget from your own UI.
<script src="https://acornreply.com/embed/YOUR_KEY.chat.js" async></script>Hide the chat bubble
Prefer to launch chat from your own "Contact us" button, a nav link, or an existing component instead of the default bubble? Hide the built-in bubble and drive the widget yourself with a small browser-side API. This is all client-side JavaScript; it does not touch the REST API.
The examples below add one line above the script tag — a tiny stub that defines window.AcornReply up front so your calls are safe to make the moment the page loads, before the widget has finished loading.
Hide the bubble and wire your own element
Add data-hide-button to the script tag to suppress the built-in floating bubble. The chat card and the control API still load, so any element on your page — an existing button, a menu item, your own React or Vue component — can open it. Listen for the acorn:chat-state event to keep your trigger in sync when the visitor closes the card from inside it or presses Escape.
<script>
window.AcornReply = window.AcornReply || function () { (window.AcornReply.q = window.AcornReply.q || []).push(arguments) }
</script>
<!-- data-hide-button hides the default bubble; the card + API still load -->
<script src="https://acornreply.com/embed/YOUR_KEY.chat.js" data-hide-button async></script>
<button id="help" type="button" aria-expanded="false">Need help?</button>
<script>
const help = document.getElementById("help")
help.addEventListener("click", () => AcornReply("toggle"))
// detail.open is true when the card is open, false when it closes —
// including closes the visitor triggers from inside the chat or via Escape.
window.addEventListener("acorn:chat-state", (e) => {
help.setAttribute("aria-expanded", String(e.detail.open))
})
</script>Control the widget (JS API)
The control API
The public entry point is a single global function, window.AcornReply(command):
There's one more command, AcornReply("identify", traits), for telling AcornReply who the visitor is — see Identify your visitors below.
When do you need destroy? If you drop the script tag on your site once, never — the widget is meant to live for the whole page. Reach for it only when your own code injected the script and now has to take it back out, namely:
a single-page app (React, Vue, etc.) that mounts the embed on some routes and removes it on others — call destroy in your component's unmount/cleanup;
pulling the widget after the visitor withdraws cookie or chat consent.
It's needed because the script installs once and ignores a second injection while it's live, so removing the <script> tag alone leaves the widget on the page; destroy clears the DOM, the listeners, and window.AcornReply itself. It does not end the conversation — the visitor's chat resumes if you load the widget again later.
| Field | Type | Description |
|---|---|---|
| AcornReply("open") | Open the chat card. | |
| AcornReply("close") | Close the card. The conversation keeps running in the background, so new replies still light up the bubble's unread badge. | |
| AcornReply("toggle") | Open the card if it's closed, close it if it's open — ideal for a single custom button. | |
| AcornReply("destroy") | Remove the widget and all its listeners. Most sites never need this — see the note below. Advanced — for SPA teardown. |
Pin the color scheme
Pin the color scheme
The bubble and card colors come from your workspace appearance settings, but the light/dark scheme follows the visitor's system theme by default. To lock it to one scheme, add ?theme=light or ?theme=dark to the script URL, or set data-theme on the tag.
<!-- Always dark, regardless of the visitor's system setting -->
<script src="https://acornreply.com/embed/YOUR_KEY.chat.js?theme=dark" async></script>
<!-- Equivalent, as an attribute -->
<script src="https://acornreply.com/embed/YOUR_KEY.chat.js" data-theme="dark" async></script>Let AI write the setup
Prefer to let AI write it?
Not a developer? Paste the prompt below into ChatGPT, Claude, or any AI assistant, fill in the one blank describing what you want, and it will write the exact snippet to add to your site. The prompt already contains everything the assistant needs to know about the widget's API, so you don't have to explain it.
I use AcornReply's live chat widget on my website. I install it with this snippet:
<script>
window.AcornReply = window.AcornReply || function () { (window.AcornReply.q = window.AcornReply.q || []).push(arguments) }
</script>
<script src="https://acornreply.com/embed/MY_KEY.chat.js" async></script>
This JavaScript API is available on the page via the global function
window.AcornReply(command, payload). The install snippet defines it up front, so
it is safe to call any time — even on page load — without waiting for anything:
- AcornReply("open") -> open the chat
- AcornReply("close") -> close the chat (the conversation keeps running)
- AcornReply("toggle") -> open if closed, close if open
- AcornReply("destroy") -> remove the widget entirely
- AcornReply("identify", { email, name, externalId, plan }) -> tell AcornReply who the visitor is
- Add the attribute data-hide-button to the second <script> tag to hide the
default chat bubble so I can trigger the chat from my own button instead.
- Listen for the "acorn:chat-state" event on window; event.detail.open is true/false
and tells me whether the chat is currently open.
- Add ?theme=light or ?theme=dark to the script URL to lock the color scheme.
What I want: ______________________________________________
(for example: "open the chat when someone clicks my 'Contact us' button",
or "open the chat automatically 20 seconds after someone lands on my pricing page")
Give me the complete HTML and JavaScript to paste into my site, and tell me exactly
where on the page each piece goes.Identify your visitors
If you already know who a visitor is, tell AcornReply with one call so the chat skips the email prompt and your agents see them — email, name, and any attributes you send — in the sidebar. Call AcornReply("identify", ...) as soon as you know who they are — on page load or later, after a client-side login. The same call covers both: it's safe any time. Identity is applied when the visitor opens the chat and isn't stored between page loads, so include the call on each page.
Reserved keys: email, name, and externalId (also accepted as external_id) become identity fields. Any other key with a primitive value (string, number, boolean) becomes sidebar metadata. These values come from the browser and are unverified — treat them with the same trust as an email the visitor types themselves.
<script>
window.AcornReply = window.AcornReply || function () { (window.AcornReply.q = window.AcornReply.q || []).push(arguments) }
</script>
<script src="https://acornreply.com/embed/YOUR_KEY.chat.js" async></script>
<script>
// Call as soon as you know who the visitor is — on page load or after login.
AcornReply("identify", {
email: "[email protected]",
name: "Ada Lovelace",
externalId: "u_123",
plan: "Pro",
})
</script>MCP
Connect an AI agent (MCP)
The knowledge base API is also available as a remote MCP server at https://mcp.acornreply.com — the same entries and search tools as the REST API, including set_published to publish or unpublish an entry, plus full category management (list_categories, create_category, update_category, delete_category, reorder_categories) and images (upload_image, create_image_upload, finalize_image_upload) for hosting images, exposed to any MCP client. It works with any MCP client that supports remote servers with a custom auth header: Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, and more.
Authenticate the same way as the REST API: a workspace API key as a Bearer token in the Authorization header. The fastest way to connect is the Claude Code CLI:
claude mcp add --transport http acorn-reply \
https://mcp.acornreply.com \
--header "Authorization: Bearer ak_YOUR_KEY"Connect any other MCP client
Clients that read a JSON config instead of a CLI (Claude Desktop, Cursor, Windsurf, VS Code, and others) use the same URL and header — add this to the client's mcpServers configuration:
{
"mcpServers": {
"acorn-reply": {
"url": "https://mcp.acornreply.com",
"headers": { "Authorization": "Bearer ak_YOUR_KEY" }
}
}
}Tools
The MCP server exposes the same knowledge base operations as the REST API, grouped into tool families that map onto the resources below: search (search_kb), entries (create/list/get/update/delete, plus set_published to publish or unpublish), categories (create/list/update/delete/reorder), images (upload_image, create_image_upload, finalize_image_upload), format (get_format_guide) for the content-format contract, and analytics (article_stats) for per-article help-center view stats.
API
Common
Authentication
Every request carries a workspace API key as a Bearer token. Create keys in Settings > API keys (admins only). Keys are server-side secrets — never ship one to a browser or mobile app.
Authorization: Bearer ak_YOUR_KEYBase URL & conventions
All endpoints are under https://acornreply.com/api/v1. Requests and responses are JSON with snake_case keys; ids are UUIDs unless a field accepts a slug.
Errors
Errors return { "error": { "message", "type", "code", "fields": [...] } }. 401 means a missing or invalid key; 422 lists the failing fields; 5xx are safe to retry with the same external_id.
Rate limits
30 requests per minute per key. Beyond that you get HTTP 429 with a retry-after header (seconds). Back off and retry; idempotency keys make retries safe.
Idempotency
external_id is the idempotency key. Reusing one returns the existing ticket with HTTP 200 (a fresh ticket returns 201) — retries and double-submits cannot create duplicates; no new ticket or message is created.
On a replay, body, subject, tags, priority, and metadata are ignored, and context only fills gaps in the stored environment (never overwriting). The customer record is refreshed, though: customer.name updates the contact's display name, and customer.external_id backfills if it wasn't already set. A genuinely new ticket needs a new external_id.
Pagination
List endpoints return a next_cursor; pass it back as ?cursor= to fetch the next page. A null next_cursor means the last page.
Tickets
Create a ticket
One endpoint creates a conversation in your inbox from an end-user message. Only customer.email and body are required; everything else enriches what your team sees.
curl -X POST https://acornreply.com/api/v1/tickets \
-H "Authorization: Bearer ak_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer": { "email": "[email protected]", "external_id": "usr_42" },
"subject": "Export failing",
"body": "My export has been stuck for an hour.",
"priority": "normal",
"tags": ["billing"],
"metadata": { "plan": "trial", "user_id": 42 },
"context": { "locale": "en-US", "timezone": "America/Chicago", "country": "US" },
"external_id": "tkt_abc123"
}'| Method | Path | Summary |
|---|---|---|
| POST | /api/v1/tickets | Create a ticket |
| Field | Type | Required | Description |
|---|---|---|---|
| customer | object | yes | The end user the ticket belongs to. Matched/created by email. |
| customer.email | string | yes | Customer's email (required). |
| customer.name | string | no | Display name; derived from the email when omitted. |
| customer.external_id | string | no | Your ID for this customer, stored for cross-referencing. |
| body | string | yes | The first message text (required). |
| subject | string | no | Synthesized from body when omitted. |
| priority | low | normal | high | urgent | no | Workflow priority shown in the inbox and sidebar. |
| tags | string[] | no | Up to 20 labels shown on the conversation. |
| metadata | object | no | Flat business data (string/number/boolean values, max 50 keys). Shown in the conversation sidebar Details section and usable in quick-link URL templates. |
| external_id | string | no | Idempotency key. Reuse returns the same ticket (HTTP 200) instead of creating a duplicate. |
| context | object | no | Observed end-user environment (not the calling server's). All fields optional; invalid values are dropped without failing the request. country is an ISO 3166-1 alpha-2 code derived by the caller — never send an IP. Shown to agents in the conversation sidebar's Environment section. |
| context.user_agent | string | no | End user's browser User-Agent. |
| context.locale | string | no | BCP 47 tag, e.g. en-US. |
| context.timezone | string | no | IANA zone, e.g. America/Chicago. |
| context.country | string | no | ISO 3166-1 alpha-2, e.g. US. |
| context.page_url | string | no | Page the user was on (https). |
| context.referrer | string | no | Referrer URL (https). |
Example response
{
"ticket": {
"id": "498b650b-c46c-4b66-a0f4-dbf5a8d8a803",
"external_id": "tkt_abc123",
"subject": "Export failing",
"status": "open",
"priority": "normal",
"tags": [
"billing"
],
"source": "api",
"url": "https://acornreply.com/conversations/498b650b-c46c-4b66-a0f4-dbf5a8d8a803",
"created_at": "2026-06-12T00:00:00.000Z",
"customer": {
"id": "166ac1e0-3013-4304-b4f6-a04c0374f8be",
"email": "[email protected]",
"external_id": "usr_42"
}
}
}| Status | Description |
|---|---|
| 200 | Idempotent replay (existing ticket returned) |
| 201 | Ticket created |
| 401 | Missing or invalid API key |
| 422 | Validation error |
| 429 | Rate limit exceeded; the retry-after header carries the wait in seconds. |
Fill the context object from the browser
The context object is the end user's environment — it renders in the conversation sidebar so your team sees device, language, local time, and country next to the message. Collect it client-side, then create the ticket from your server where the API key lives.
Derive country yourself (for example from Cloudflare's CF-IPCountry header on your own request). Never send the user's IP — the API has no field for it and we never store one.
// 1) In the browser: collect the end user's environment with their message.
// No API key here — this is plain data collection.
const context = {
user_agent: navigator.userAgent,
locale: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
page_url: location.href,
referrer: document.referrer || undefined,
}
await fetch('/support/tickets', { // your own backend endpoint
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, context }),
})
// 2) On your server: create the ticket. The API key stays server-side.
const res = await fetch('https://acornreply.com/api/v1/tickets', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ACORN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
customer: { email: user.email, external_id: user.id },
body: message,
metadata: { plan: user.plan },
context: { ...context, country: req.headers['cf-ipcountry'] },
external_id: ticketId, // idempotency key
}),
})
const { ticket } = await res.json() // ticket.url links into your inboxmetadata vs context
metadata is business data you tell us: plan, order id, feature flags — flat string/number/boolean values, max 50 keys. It shows in the sidebar's Details section and powers quick-link URL templates like {metadata.order_id}.
context is the observed environment: user agent, locale, timezone, country, page URL. It shows in the sidebar's Environment section. The two never mix.
Knowledge base
Manage the knowledge base your AI drafts and help center pull from: list, create, update, and delete entries; search; and full category management — create, update, delete, and reorder categories (POST/PATCH/DELETE /api/v1/kb/categories and POST /api/v1/kb/categories/reorder). Entries are faq or article — send Markdown in body (articles are converted to sanitized HTML; pass body_html instead if you already have HTML), and file an entry under a category by passing its slug or id as category. Write responses (create/update) include a warnings[] array listing anything the sanitizer changed or dropped.
Entries
Create, read, update, and delete FAQ and article entries. Send Markdown in body (articles are converted to sanitized HTML, or pass body_html), and file an entry under a category by passing its slug or id as category. Write responses include a warnings[] array of anything the sanitizer changed.
Pass dry_run: true on create or update to validate and preview warnings[] without writing — the response comes back with a null id and timestamps. See https://acornreply.com/api/v1/kb/format for the exact list of supported Markdown features and the article HTML/iframe-host allowlist.
| Method | Path | Summary |
|---|---|---|
| GET | /api/v1/kb/entries | List KB entries |
| POST | /api/v1/kb/entries | Create a KB entry |
| GET | /api/v1/kb/entries/{id} | Get a KB entry |
| PATCH | /api/v1/kb/entries/{id} | Update a KB entry |
| DELETE | /api/v1/kb/entries/{id} | Delete a KB entry |
| Field | Type | Required | Description |
|---|---|---|---|
| type | faq | article | yes | Entry type (required). |
| title | string | yes | Entry title (required). |
| body | string | no | Markdown source. Faqs render it as-is; articles are converted to sanitized HTML. Provide body or body_html, not both — unsupported constructs are stripped and reported in the response’s warnings[] array. See GET /api/v1/kb/format for the supported feature/tag list. |
| body_html | string | no | Raw HTML for callers that already have HTML instead of Markdown. Sanitized against the article allowlist before storage (see GET /api/v1/kb/format); anything dropped is reported in warnings[]. Provide body or body_html, not both. |
| published | boolean | no | Whether the entry is eligible for AI drafting and the help center. Defaults to unpublished. |
| slug | string | no | URL slug; derived from the title when omitted. |
| category_id | string | null | no | Category to file the entry under. |
| category | string | no | Slug or id of the category to file the entry under. Alternative to category_id — send one, not both (sending both is a 422). Unlike category_id (uuid only), this accepts a slug. |
| dry_run | boolean | no | When true, validates and renders the entry — including warnings[] — without writing to the database. The response comes back with a null id, created_at, and updated_at. |
| Field | Type | Description |
|---|---|---|
| id | string | null | Null on a dry_run response. |
| type | faq | article | past_conversation | Entry type. Only faq and article are creatable through this API; past_conversation entries are indexed automatically from resolved conversations. |
| title | string | |
| body | string | Rendered/stored body: Markdown for faq, sanitized HTML for article. |
| published | boolean | Whether the entry is eligible for AI drafting and the help center. |
| status | verified | unverified | |
| slug | string | null | |
| category_id | string | null | |
| origin_link | string | null | Source URL for entries imported from crawling, if any. |
| source_ref_id | string | null | Cross-reference id for entries derived from another record. |
| created_at | string | null | |
| updated_at | string | null |
Example request
{
"type": "faq",
"title": "How do refunds work?",
"body": "We refund within **30 days** of purchase.",
"published": true
}Categories
Create, update, reparent, reorder, and delete categories (a category with child sections or filed entries returns an impact preview on delete until you re-call with confirm). Reference a category by slug or id anywhere an id is accepted.
| Method | Path | Summary |
|---|---|---|
| GET | /api/v1/kb/categories | List KB categories |
| POST | /api/v1/kb/categories | Create a KB category |
| PATCH | /api/v1/kb/categories/{id} | Update a KB category |
| DELETE | /api/v1/kb/categories/{id} | Delete a KB category |
| POST | /api/v1/kb/categories/reorder | Reorder KB categories |
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | yes | Category title (required). |
| description | string | no | |
| parent | string | no | Slug or id of a top-level category to nest under (makes this a section). |
Images
Upload an image with POST /api/v1/kb/images (multipart file, WebP/PNG/JPEG/GIF, max 5 MB) to host it on your CDN; the response returns url, sha256, width, and height. To upload raw bytes without multipart, POST /api/v1/kb/images/presign (mime + sha256) for a presigned PUT URL, PUT the bytes, then POST /api/v1/kb/images/finalize with the returned key — the server verifies the bytes against your sha256 before committing. URLs are content-addressed by SHA-256 and immutable, but may 404 for a few seconds at the CDN edge right after upload. Uploads are burst-limited (60/min per workspace) and count against a daily per-plan quota (lowest on the free plan); over either you get HTTP 429.
| Method | Path | Summary |
|---|---|---|
| POST | /api/v1/kb/images | Upload a KB image |
Search
Semantic search over your published knowledge base — the same retrieval the AI drafting uses. Pass q (the query, required) and an optional limit; results come back ranked by relevance.
| Method | Path | Summary |
|---|---|---|
| GET | /api/v1/kb/search | Search the KB |
Article analytics
Per-article view stats for your published help-center articles — for automating content pruning, such as finding articles nobody has read. Only published articles are included, and only unique-visitor counts — never any visitor identifiers — are exposed. views and uniques (distinct visitors) are counted within the optional since/until window (all-time by default); last_viewed_at is always all-time. total is the full count matching your filters and truncated tells you whether more rows matched than the page returned. Example: ?max_views=0&since=2026-04-16&sort=views lists the articles with no views since April.
All filters are optional query parameters:
| Method | Path | Summary |
|---|---|---|
| GET | /api/v1/kb/analytics/articles | List published-article view stats |
| Field | Type | Description |
|---|---|---|
| since | string | ISO date or datetime (UTC). Lower bound (inclusive) for counting views/uniques. |
| until | string | ISO date or datetime (UTC). Upper bound (exclusive); must be after since. |
| min_views | integer | Keep articles whose in-window view count is at least this. |
| max_views | integer | Keep articles whose in-window view count is at most this (max_views=0 finds dead articles). |
| last_viewed_before | string | ISO date/datetime (UTC). Keep articles last viewed before this, or never viewed. |
| sort | views | last_viewed_at | Sort key. Defaults to views. |
| order | asc | desc | Sort direction. Defaults to asc (fewest-viewed / stalest first). |
| limit | integer | Page size, default 50. Check truncated in the response to know if you hit it. |
Example response
{
"articles": [
{
"id": "0f2c8b3a-2b1e-4c7a-9f10-6d5e4c3b2a10",
"title": "Deactivating your account",
"slug": "deactivating-your-account",
"category": "Account",
"views": 0,
"uniques": 0,
"last_viewed_at": "2026-01-03T09:12:00.000Z"
}
],
"total": 12,
"truncated": false
}Format guide
Pass dry_run: true on create or update to validate and preview warnings[] without writing — the response comes back with a null id and timestamps. See https://acornreply.com/api/v1/kb/format for the exact list of supported Markdown features and the article HTML/iframe-host allowlist.
| Method | Path | Summary |
|---|---|---|
| GET | /api/v1/kb/format | Get the KB content-format contract |