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 same status: / assignee: / tag: operators as the console search). Not paginated, but limit is honoured (default 50, max 100), and inbox narrows the search to one mailbox. cursor and updatedAfter are 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), and cursor.

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 with deliveryState: "scheduled" and a scheduledFor timestamp) and dispatches at that time. Must be in the future, at most 30 days ahead. Composes with mode — a scheduled SEND_AND_CLOSE closes 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 the reply_sent event 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. null unassigns; omit for no change.
  • modeNOTE (the default: no status change), NOTE_AND_ACTIVE, NOTE_AND_PENDING or NOTE_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" }]
}
  • priorityLOW / NORMAL / HIGH / URGENT, or null to clear.
  • snoozedUntil — ISO 8601, must be in the future.
  • inboxId — move to another inbox.
  • customFields — set values by fieldId (value: null clears 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 a 400 rather than accepted and quietly stored as metadata only. The whole request body is capped at 30 MB (a 413 beyond 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 in inboxId.
  • 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.created and message.created webhooks, and on every later event for that conversation;
  • GET /conversations/by-ref/:externalRef resolves it on demand, returning the full conversation. A 404 in 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 }
FieldDefaultMeaning
instructionnullAn optional steer — the same field the composer’s Steer box uses.
saveAsDrafttrueAlso 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[].waiting counts 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" }