Developer docs
API reference
Endpoints for conversations, drafts, AI drafts, queue reports, tags and customers — plus the JSON object shapes they return.
All paths are relative to https://api.replyplex.com/public/v1. Mutating endpoints are marked
(write) and need a write-scoped key. A few endpoints instead need a per-key
capability — those are marked with the capability they need.
A machine-readable OpenAPI spec is published at
/public/v1/openapi.json, with an interactive
viewer at /public/v1/docs — point your codegen/SDK
tooling at it.
During a restart, newly received requests can return 503 with Retry-After: 1. Wait before
retrying and retain any idempotency key. A dropped connection alone does not prove that an
earlier write failed.
Conversations
List conversations
GET /conversations
Provide one of:
q— a search query (matches subject, customer, and message bodies; supports the samestatus:/assignee:/tag:operators as the console search). Not paginated, butlimitis honoured (default 50, max 100), andinboxnarrows the search to one mailbox.cursorandupdatedAfterare rejected with a 400 rather than ignored.inbox— an inbox id, paginated. Extra params:status=ACTIVE|PENDING|CLOSED|SNOOZED|SPAM,limit(default 50, max 100),updatedAfter(ISO-8601, incremental sync), andcursor.
The body is a plain array of conversation summaries, ordered
newest-created first. When more pages exist, the response carries X-Next-Cursor (pass it
back as cursor) and a GitHub-style Link: …; rel="next". Iterate until there’s no
X-Next-Cursor; for incremental sync, pass the last timestamp you processed as updatedAfter.
# page 1 — read the cursor from the response headers
curl -si -H "Authorization: Bearer $RP_KEY" \
"https://api.replyplex.com/public/v1/conversations?inbox=$INBOX&limit=50" | grep -i x-next-cursor
# page 2
curl -H "Authorization: Bearer $RP_KEY" \
"https://api.replyplex.com/public/v1/conversations?inbox=$INBOX&cursor=$CURSOR"
Get one conversation
GET /conversations/:id
Returns a full conversation including messages, activity, and customFields.
Look up a conversation by an external id
GET /conversations/by-ref/:source/:externalRef
GET /conversations/by-ref/:externalRef (same as :source = "portal")
Resolves an id you already hold in some other system straight to the ReplyPlex conversation —
one indexed lookup, no paging through every conversation to find it. :source is
fluentsupport or helpscout for a ticket id from that migration, or portal for your own
externalRef from portal ingest. 404 while the match
is still queued (portal ingest only — a migration id is either there or it isn’t).
curl -H "Authorization: Bearer $RP_KEY" \
"https://api.replyplex.com/public/v1/conversations/by-ref/fluentsupport/13924"
Create a conversation (write)
POST /conversations
Body — an outbound message that starts a new ticket:
{
"inboxId": "uuid",
"to": "customer@example.com",
"subject": "Welcome!",
"bodyText": "Hi there…",
"bodyHtml": "<p>Hi there…</p>",
"cc": [],
"bcc": [],
"mode": "SEND",
"attachments": []
}
mode is one of SEND, SEND_AND_PENDING, SEND_AND_CLOSE, SEND_AND_SNOOZE. Returns the created
conversation. The inbox’s sending domain must be verified.
Reply to a conversation (write)
POST /conversations/:id/reply
{
"bodyText": "Thanks for reaching out…",
"bodyHtml": "<p>Thanks…</p>",
"cc": [],
"bcc": [],
"mode": "SEND",
"attachments": [],
"scheduledFor": "2026-07-21T13:00:00.000Z",
"savedReplyIds": []
}
Two optional fields:
scheduledFor— ISO 8601 datetime. When set, the reply is held instead of sent immediately (its message returns withdeliveryState: "scheduled"and ascheduledFortimestamp) and dispatches at that time. Must be in the future, at most 30 days ahead. Composes withmode— a scheduledSEND_AND_CLOSEcloses the conversation when the reply actually sends, not when you queue it.savedReplyIds— up to 20 saved-reply UUIDs that were used composing this reply. Recorded on thereply_sentevent and powers the analytics “Saved replies used” report. Insertion semantics: include a saved reply’s id even if the text was edited afterwards.
Add an internal note (write)
POST /conversations/:id/notes
Adds an internal note — no email is sent — and fires a message.created webhook with
internalNote: true.
{
"bodyText": "Called the customer — they'll send logs.",
"mentions": [],
"assigneeId": "…uuid… | null",
"mode": "NOTE"
}
mentions— teammate user ids to@-mention. They must be members of the inbox.assigneeId— hand the ticket over with the note.nullunassigns; omit for no change.mode—NOTE(the default: no status change),NOTE_AND_ACTIVE,NOTE_AND_PENDINGorNOTE_AND_CLOSE.
Both assigneeId and mode are optional and additive — send bodyText alone and the
behaviour is exactly what it always was. When either is present, the note, the assignment and
the status transition are applied in one transaction, so a handoff cannot half-land. An
illegal transition (closing a spam ticket, say) is refused before the note is written.
Assign (write)
POST /conversations/:id/assign
{ "assigneeId": "uuid" }
Send "assigneeId": null to unassign.
Add / remove a tag (write)
POST /conversations/:id/tags { "tagId": "uuid" }
DELETE /conversations/:id/tags/:tagId
Close / reopen (write)
POST /conversations/:id/close
POST /conversations/:id/reopen
Update properties (write)
PATCH /conversations/:id
Set any subset of a conversation’s properties (at least one required). Omitted fields are left
untouched; to unsnooze, call reopen.
{
"priority": "HIGH",
"snoozedUntil": "2026-08-01T09:00:00Z",
"inboxId": "uuid",
"customFields": [{ "fieldId": "uuid", "value": "Enterprise" }]
}
priority—LOW/NORMAL/HIGH/URGENT, ornullto clear.snoozedUntil— ISO 8601, must be in the future.inboxId— move to another inbox.customFields— set values byfieldId(value: nullclears one).
All mutation endpoints return the updated conversation.
Portal ingest
Have your own customer-facing portal, in-app contact form, or help widget? This is how what your customers type there becomes a real ticket — as a genuine inbound message from them, not an agent-authored one.
Record an inbound message (write)
POST /conversations/inbound
{
"inboxId": "uuid",
"customerEmail": "ada@example.com",
"customerName": "Ada Lovelace",
"subject": "Server is down",
"bodyText": "Everything is on fire.",
"bodyHtml": "<p>Everything is on fire.</p>",
"attachments": [
{ "filename": "log.txt", "contentType": "text/plain", "contentBase64": "…" }
],
"conversationId": "uuid",
"externalRef": "your-ticket-42"
}
subject— required when starting a new conversation; ignored on a reply, which keeps the thread’s own subject.customerName,bodyHtml,attachments— optional. HTML is sanitized server-side to the same subset the composer allows.attachments— up to 20 files, bytes inline; no separate upload step. Each is capped at 10 MB measured on the decoded bytes, and must be an allowed type. Both are rejected with a400rather than accepted and quietly stored as metadata only. The whole request body is capped at 30 MB (a413beyond that); base64 inflates bytes by about a third, so that’s roughly 22 MB of files in one call.conversationId— reply into an existing ticket instead of starting one. Must live ininboxId.externalRef— your own id for the ticket. Optional, and worth reading the next section before you skip it.
Returns 202:
{ "accepted": true, "jobId": "…", "externalRef": "your-ticket-42", "conversationId": null }
Getting the conversation id back
The message joins the same durable queue as real email — so deduplication, threading, automations, auto-reply and AI classification all behave exactly as they do for mail, and a Redis outage delays your ticket rather than losing it. The trade is that the conversation does not exist yet when the call returns.
Send an externalRef and you never have to guess which conversation was yours:
- it rides on the
conversation.createdandmessage.createdwebhooks, and on every later event for that conversation; GET /conversations/by-ref/:externalRefresolves it on demand, returning the full conversation. A404in the first second or two after ingest means the message is still queued — not that it failed.
Send it on the first message of a ticket. Replies name their thread with conversationId
instead, and that one is echoed straight back in the response, because there was never
anything to resolve.
A reply into a conversation you name is exempt from the closed-reopen window: your customer opened that specific ticket and pressed reply on it, so it reopens however old it is.
Telling these messages apart later
They are ordinary inbound messages — same queue, same threading, same automations — but they are
not backed by the same thing: real mail carries an SPF/DKIM/DMARC verdict from the receiving
edge, while this endpoint takes your word for customerEmail. So the record says which is which.
The ticket_created / message_received event carries via: "api", the message’s Message-ID
is minted in portal.replyplex.internal (a domain that can never receive mail), and an
externalRef you sent is stored against the conversation.
Retrying safely
This endpoint mints a fresh Message-ID per call, so a blind retry posts a second message on
the ticket. Send an Idempotency-Key and a
retry becomes a no-op that replays the original response.
Attachments
GET /attachments/:id
A short-lived signed download URL for one attachment:
{ "url": "https://…" }
The ids come from attachments[] on each message of a conversation. An
attachment whose scanStatus is blocked — a disallowed file type, whose bytes were never
stored — returns a 400.
Drafts
Stage a reply for a human to review. Nothing here sends anything — that is the whole point, and the
reason a key can hold drafts:write with no write scope at all.
A staged draft belongs to whoever created the API key, and shows up in that person’s Drafts view and in the ticket’s composer. It is a real queue an agent works through, and a named human accountable for what their integration stages.
Create or replace a draft (drafts:write)
POST /conversations/:id/draft
{ "bodyHtml": "<p>Your licence covers 3 sites — staging doesn't count toward it.</p>" }
bodyHtml is sanitized to the same subset the console composer allows; a body that sanitizes to nothing
is a 400. Posting again replaces the draft text and preserves its existing attachments. Returns:
{ "conversationId": "…", "bodyHtml": "<p>…</p>", "updatedAt": "2026-08-16T14:00:00.000Z" }
A write key can do this without the capability — it can already send, so staging is strictly safer.
Withdraw a draft (drafts:write)
DELETE /conversations/:id/draft
Idempotent — withdrawing a draft that isn’t there is a success. Returns { "ok": true }.
AI drafts
Generate a draft (ai:draft)
POST /conversations/:id/ai-draft
Runs your workspace’s own drafting pipeline — the same one the console uses, grounded in your documentation and past replies — and stages the result.
The ai:draft capability works on a read key and is required even on a write key.
Reported charges count toward the workspace’s AI budget and are attributed to the key’s creator.
If that account has been deleted, saveAsDraft: false still records the charge for the workspace;
storing a draft requires an owner. Saving the generated text preserves existing draft attachments.
New generation returns 503 if usage tracking is unavailable before the request is sent.
The AI spend screen distinguishes reported, pending and unknown charges. An unknown charge
is not a free request; reported totals can increase when a missing charge is recovered.
Positive budgets are checked before every provider attempt, including retries and fallback.
A monthly limit permits one outstanding attempt across the workspace; a conversation limit
applies that rule to its conversation. Pending or unknown charges pause that scope, including
unresolved earlier months. Null or zero leaves a scope uncapped. 402 means reported spend
has reached a limit; 409 indicates pending accounting or changed configuration. The specific
409 code AI_BUDGET_BUSY means this invocation dispatched no provider attempt; retry with
backoff after the earlier charge settles. An admitted attempt may still exceed the remaining
allowance, so this is not a guaranteed dollar ceiling.
If a stored workspace provider key cannot be decrypted, generation returns 503 before
sending a request. It does not substitute a deployment provider key. Ask an administrator
to restore the workspace credential configuration before retrying.
{ "instruction": "offer the annual discount", "saveAsDraft": true }
| Field | Default | Meaning |
|---|---|---|
instruction | null | An optional steer — the same field the composer’s Steer box uses. |
saveAsDraft | true | Also store it as the conversation’s draft. Use false to get the text only, when you want to post it elsewhere and would otherwise overwrite an agent’s own draft. |
Returns:
{
"conversationId": "…",
"bodyHtml": "<p>…</p>",
"saved": true,
"confidence": "medium",
"caveats": ["The thread never states which plan they're on"],
"draftId": "…"
}
caveats is what the thread did not establish. Show it to whoever reviews the draft — an
unverifiable claim that reads as sourced is worse than an obvious guess.
Spend is recorded on your AI usage ledger and appears in the eval dashboard alongside console drafts,
so an API-generated draft is never unattributed cost. A 400 means the workspace has no AI model
configured.
Reports
Queue state
GET /reports/queues
The numbers a wallboard or a scheduled report needs: the same figures the console dashboard renders,
from the same queries, so the two cannot disagree in front of a team. Needs only a read key.
Deliberately not an analytics API — queue state, not BI. There are no date ranges and no history;
every count is of the single instant reported in asOf.
{
"asOf": "2026-08-16T14:00:00.000Z",
"inboxes": [
{
"id": "…",
"name": "Support",
"unassigned": 4,
"waiting": 37,
"overdue": 2,
"enquiries": 31,
"oldestWaitingAt": "2026-08-11T09:12:00.000Z"
}
],
"agents": [{ "userId": "…", "name": "Michelle D", "open": 12, "waiting": 5 }],
"oldestWaiting": [
{
"id": "…",
"number": 4821,
"inboxId": "…",
"subject": "Licence question",
"waitingSince": "2026-08-11T09:12:00.000Z",
"breached": true,
"assigneeName": null
}
],
"totals": { "unassigned": 4, "waiting": 37, "overdue": 2 }
}
Two things to read carefully before building on them:
agents[].waitingcounts among that agent’s OPEN tickets, not across all history. A conversation’s waiting timestamp isn’t cleared when the thread closes, so an unscoped count would report a subset larger than the set it belongs to.- Deactivated agents have no row. This answers “who can take more work”, so someone who can’t take any isn’t in it.
oldestWaiting is ordered the way a person would work it — breached first, then longest waiting — and is
capped at a handful of tickets. Page the conversations endpoint for the rest.
Tags
GET /tags
Returns the workspace’s tags.
Customers
GET /customers?q=<name-or-email>
Returns matching customers: an array of { "id", "name", "email" }.
Reference data
Read endpoints for resolving the ids the write endpoints need.
GET /inboxes # the org's live inboxes: { id, name, sendingDomainVerified }
GET /inboxes/:id/members # assignable agents for an inbox (resolve assigneeId)
GET /saved-replies # saved replies available to the key
GET /custom-fields # custom-field definitions: { id, label, type, entity }
Join a conversation’s customFields[].fieldId to the definitions, and use an inbox id as the
inbox / inboxId param when listing or creating.
Object shapes
Conversation summary
{
"id": "uuid",
"number": 1234,
"inboxId": "uuid",
"subject": "Refund not received",
"status": "ACTIVE",
"priority": "HIGH",
"customerEmail": "a@b.com",
"customerName": "James Carter",
"assigneeId": "uuid | null",
"assigneeName": "Emily Turner | null",
"teamId": "uuid | null",
"teamName": "Billing | null",
"tags": ["Billing", "Bug"],
"lastMessageAt": "2026-07-09T12:00:00.000Z | null",
"preview": "…latest message excerpt…",
"createdAt": "2026-07-01T…"
}
status is ACTIVE|PENDING|CLOSED|SNOOZED|SPAM; priority is LOW|NORMAL|HIGH|URGENT or null.
Conversation
The summary above plus:
{
"tags": [ "…full Tag objects…" ],
"following": false,
"snoozedUntil": "… | null",
"csatRating": "GOOD | NEUTRAL | BAD | null",
"csatComment": "… | null",
"messages": [ "…thread messages: direction, sender, to, cc, bodyText, bodyHtml, createdAt, attachments…" ],
"activity": [ "…timeline events…" ],
"customFields": [ { "fieldId": "uuid", "value": "…" } ]
}
Custom-field values are keyed by fieldId; the field’s label/type live in its definition (managed in
Settings → Custom fields).
Tag
{ "id": "uuid", "name": "Billing", "type": "WORKFLOW", "color": "#F79009 | null" }