API reference
Build on Reapdat from your own systems. One key, one base URL, and an integration per product area.
https://api.reapdat.com/api/v1Overview#
One REST API, one credential, JSON in and JSON out. Every endpoint is scoped to your account by the credential you send, which is why no request in this reference carries an account id — there is no way to ask for someone else's data.
https://api.reapdat.com/api/v1| Request body | application/json, except where a file is being uploaded |
| Timestamps | ISO 8601, UTC. A naive value on input is read as UTC |
| Errors | Always { "detail": "..." }, with a meaningful status code |
| Versioning | The /v1 prefix. Breaking changes ship under a new prefix |
Authentication#
Send an admin API key on every request. One header — no login call, no token to refresh, no expiry to handle.
X-API-Key: ua_admin_xxxxxxxxxxxxxxxxxxxxxxxxxxxxA key may also be sent as Authorization: Bearer ua_admin_... — keys are recognised by their ua_ prefix, so either header carries one.
Find your key under Portal → Integrations → API Keys. It is shown once, because only a hash is stored: a lost key is regenerated, never recovered. Revoking one takes effect immediately and leaves your other keys working.
A browser session (the portal's own sign-in) is accepted too, on every endpoint here.
Errors#
| Status | Means |
|---|---|
| 400 | The request was understood and refused. detail says why |
| 401 | No credential, or a malformed / expired browser session |
| 403 | The API key is unknown, revoked, expired, or is not an admin key |
| 404 | No such record on your account. Never confirms one exists elsewhere |
| 405 | Wrong method for that path — check GET vs POST vs DELETE |
| 422 | The body did not validate. detail names the offending field |
| 503 | A part we depend on is down, not your request. Retry |
| 429 | Rate limited. Back off and retry |
{
"detail": "Admin API key required"
}Chat Links API
liveAdditional links — a shareable chat page per campaign, role or product, each with its own isolated knowledge base.
What a chat link is#
A chat link is a self-contained assistant at its own URL. It starts empty and only knows what you give it, so a link built for one role never answers from another's material — and it can inherit your main knowledge base on top when you want both.
- The URL is the credential. It carries a 192-bit token, and the page behind it needs no login.
- Revocable and expiring. Revoke a link and the URL 404s immediately, for everyone holding it.
- Isolated by default. Set
inherit_main_kbto also answer from your main knowledge base.
The Link object#
Returned by every management endpoint except DELETE.
{
"id": "e92c2046-c5c7-46e0-883b-13538feb6037",
"label": "Toronto campaign",
"tag": "q4",
"tags": ["q4", "toronto"],
"token": "bZY3eWQ80eOn-Pn3F6BPjLlq43q-yUbF",
"url": "https://agent.reapdat.com/acme/bZY3eWQ80eOn-...",
"agent_name": null,
"greeting": null,
"primary_color": null,
"language": null,
"channels": ["chat"],
"inherit_main_kb": false,
"created_via": "api",
"status": "active",
"is_active": true,
"is_expired": false,
"revoked_at": null,
"expires_at": "2026-12-31T23:59:59+00:00",
"use_count": 0,
"last_used_at": null,
"created_at": "2026-09-12T15:41:55.380471+00:00",
"kb_sources": {},
"kb_chunks": 0,
"kb_docs": 0
}| field | type | notes |
|---|---|---|
| id | string | UUID. Used in the path of every per-link endpoint. |
| label | string | Internal name. Not shown to visitors. |
| tag | string | null | Legacy single tag. Always equals tags[0], or null. |
| tags | string[] | Shown on Needs You items sourced from this link. |
| token | string | 32 chars. The credential in the public URL. |
| url | string | The shareable link, fully assembled. Do not build it yourself. |
| agent_name | string | null | null inherits the account's. |
| greeting | string | null | null inherits the account's welcome message. |
| primary_color | string | null | #RRGGBB. null inherits. |
| language | string | null | null inherits. |
| channels | string[] | null | Subset of chat, call, book. null inherits all enabled channels. Can only narrow them — a link cannot enable a channel the account has disabled. |
| created_via | string | api when created with an API key, embed when created from the embedded panel in one of your customers' products, portal when created in the browser. Never null. Set at creation and never changed — renaming an API-made link in the portal does not re-attribute it. |
| inherit_main_kb | boolean | false — answers only from this link's own knowledge. true — its own and the account's main one. See Knowledge scope. |
| status | string | active | expired | revoked. Derived; see Lifecycle. |
| is_active | boolean | status == "active" |
| is_expired | boolean | status == "expired" |
| revoked_at | string | null | Set when revoked. |
| expires_at | string | null | null = permanent. |
| use_count | integer | Times the public page has been opened. |
| last_used_at | string | null | |
| created_at | string | |
| kb_sources | object | {source: chunk_count} for this link's own documents. |
| kb_chunks | integer | Searchable passages in this link's own knowledge base. |
| kb_docs | integer | Documents in this link's own knowledge base. |
Create a link#
/chat-linksCreates an empty link. Returns the Link object, including the url to share.
Every field is optional — posting {} creates a working link that inherits the account's persona, colour, language and channels.
{
"label": "Careers",
"tags": ["careers", "hiring"],
"agent_name": "Alex",
"greeting": "Hi - ask me about the roles we have open.",
"primary_color": "#7C3AED",
"language": "en",
"channels": ["chat", "call"],
"inherit_main_kb": false,
"expires_at": "2026-12-31T23:59:59Z"
}| field | type | constraint |
|---|---|---|
| label | string | ≤120. Defaults to "Untitled link". |
| tags | string[] | ≤8 entries, ≤40 chars each. Trimmed, de-duplicated case-insensitively, then capped. |
| tag | string | ≤60. Legacy. Used only when tags is absent. |
| agent_name | string | ≤120 |
| greeting | string | ≤2000 |
| primary_color | string | ≤7, #RRGGBB |
| language | string | ≤8 |
| channels | string[] | Subset of chat, call, book. Omit to inherit. `[]` is rejected with a 400. |
| inherit_main_kb | boolean | Default false. |
| expires_at | string | ISO 8601. Omit or null for permanent. |
Create a link and its knowledge at once#
/chat-links/bundleCreates the link and loads its knowledge base in one call. multipart/form-data.
Three parts: link (the link itself, as JSON), knowledge (an array of entries, as JSON) and files (up to five documents). Only link is required.
POST /api/v1/chat-links/bundle
X-API-Key: ua_admin_...
Content-Type: multipart/form-data
link = {"label":"Lead iOS Developer",
"tags":["REQ-43674","ios"],
"channels":["chat"],
"inherit_main_kb":true}
knowledge = [{"content":"Contract, remote. Denver CO. 8+ years."},
{"question":"Do you sponsor visas?",
"answer":"Not for this role."}]
files = @role-spec.pdf{
"link": {
"id": "b444447b-8fab-4dc9-ad69-9dfa1d20d03b",
"label": "Lead iOS Developer",
"tags": ["REQ-43674", "ios"],
"token": "EDFIqR3asRlCSL0FY0kB7_cLMGxGF0_B",
"url": "https://agent.reapdat.com/your-slug/EDFIqR3asRlC...",
"channels": ["chat"],
"inherit_main_kb": true,
"status": "active",
"kb_docs": 0
},
"knowledge": {
"job_id": "105e015e-e8ab-4b7e-8fbb-ce313691692d",
"status": "running",
"queued": 3
}
}Watch the knowledge load#
/chat-links/bundle/{job_id}Progress of the background ingest. Scoped to your account.
{ "status": "running", "queued": 3, "ingested": 0, "failed": 0, "errors": [] }Add knowledge later#
Pass the link's id as link_id and the content is scoped to that link alone. Omit it and the content goes to your main knowledge base instead.
/knowledge/portal/ingestA question and answer, or a block of text.
POST /api/v1/knowledge/portal/ingest
X-API-Key: ua_admin_...
Content-Type: application/json
{
"content": "Interviews are two rounds: a screen then a panel.",
"link_id": "b444447b-8fab-4dc9-ad69-9dfa1d20d03b"
}{
"success": true,
"doc_id": "4bb31953-...-62aee3da4241",
"chunks": 1
}/knowledge/portal/uploadA document. PDF, DOCX, TXT, CSV, XLSX, JSON or an image. multipart/form-data.
curl -s -X POST "https://api.reapdat.com/api/v1/knowledge/portal/upload" \
-H "X-API-Key: ua_admin_..." \
-F file=@benefits.pdf \
-F "link_id=b444447b-8fab-4dc9-ad69-9dfa1d20d03b"What you can upload#
| formats | max size | |
|---|---|---|
| Documents | .pdf .docx .txt .csv .xlsx .json | 10 MB |
| Images | .jpg .jpeg .png .webp .gif | 5 MB |
Images are checked against their magic bytes, so a renamed extension is rejected, and are auto-captioned so the agent can answer about what is in them.
- CSV and XLSX need either
questionandanswercolumns, or acontentcolumn. - JSON must be an array of objects, or a single object.
- TXT is split per non-empty line; PDF and DOCX per paragraph.
- A scanned PDF with no extractable text is rejected — there is no OCR.
| code | cause |
|---|---|
| 400 | Unsupported extension, an empty file, a CSV/XLSX missing its columns, or an unreadable / image-only PDF |
| 402 | Plan document limit reached — counted across the whole account, not per link |
| 404 | link_id unknown, or it belongs to another account |
| 413 | Over the size cap. The message names the limit and the actual size |
| 503 | Embedding service not configured — server side, retry later |
Knowledge scope#
inherit_main_kb decides what a link can read. It is the one setting that changes what the agent is able to say.
| value | the link answers from |
|---|---|
| false *(default)* | only the documents loaded onto this link |
| true | this link's documents and the account's main knowledge base |
When true, both are searched and merged by relevance, with the link's own material winning an equal score — the specific answer outranks the general one. At most 5 passages reach the agent per turn, before and after merging.
Scoping applies on chat and on voice alike. On chat the page sends the link with every message; on a voice call the link is stamped onto the knowledge tool when the call is created, so the system prompt summary and every mid-call lookup stay scoped.
Either way the link still knows the account-level basics — your agent instructions, tone, and business facts such as hours, services and contact details. Those are not knowledge-base content and are never link-scoped.
List, update, revoke#
/chat-linksEvery link on the account, newest first, with its knowledge-base counts. Not paginated — all links are returned.
{
"links": [ /* Link, newest first */ ],
"count": 3,
"limit": 20 // -1 = unlimited
}/chat-links/{id}Applies only the keys you send. An explicit null clears a field.
/chat-links/{id}/revokeThe URL stops working at once. Sibling links are untouched.
/chat-links/{id}Deletes the link and its isolated knowledge base.
POST /api/v1/chat-links/b444447b-.../revoke
X-API-Key: ua_admin_...{
"label": "Lead iOS Developer",
"status": "revoked",
"is_active": false,
"revoked_at": "2026-09-17T02:52:32.122406+00:00"
}status is derived and is one of active, expired or revoked — expiry beats the active flag. Setting a future expires_at on an expired link revives it, unless it was revoked by hand.
Lifecycle#
create ──▶ active ─── expires_at passes ──▶ expired ──▶ future/null expires_at ──▶ active
│ (unless revoked by hand)
└── revoke, or is_active:false ──▶ revoked ──▶ is_active:true ──▶ active| expired | revoked | |
|---|---|---|
| caused by | the date passing | an API call |
| revoked_at | null | stamped |
| public URL | 404 | 404 |
| recovers on a new expires_at | yes | no — needs is_active: true |
status is computed on every read. When both apply, revoked wins — so a link that expired and was then revoked does not come back by extending the date.
The public side#
No authentication. The token in the path is the credential.
| endpoint | |
|---|---|
| GET agent.reapdat.com/{slug}/{token} | The link's chat page. This is the value of url. |
| GET /chat-page/l/{token} | The same page, legacy URL form. Still supported. |
| GET /chat-page/l/{token}/qr | QR code for a link. |
| GET /chat-page/{slug}/qr | QR code for the account's main chat page. |
| GET /chat-page/{slug}/info | { name, agent_name, enabled, slug }. Account-level. |
| QR param | values | default |
|---|---|---|
| format | png | svg | png |
| size | 100–1000 | 400 |
The QR encodes the url form, so codes printed before the URL style changed still resolve. An unknown, revoked, expired or inactive link returns the same 404 Chat page not found in every case — the response never distinguishes them.
Limits#
| Links per account | 20 by default. The effective value is limit on GET /chat-links; -1 is unlimited. |
| Documents | Your plan's cap, counted across the whole account |
| Files per bundle | 5. Send the rest to /knowledge/portal/upload with the link's id |
| Tags per link | 8, ≤40 chars each |
| Channels | At least one; [] is rejected, null inherits |
| code | cause |
|---|---|
| 400 | channels: [] — select at least one of chat, call or book |
| 403 | Chat link limit reached. Delete one, or ask an admin to raise the cap |
| 404 | Unknown link id, or it belongs to another account |
| 422 | Malformed JSON, or a field of the wrong type or over its length |
The whole flow, as a script#
BASE=https://api.reapdat.com/api/v1
AUTH="X-API-Key: ua_admin_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# create a link AND load its knowledge in one call
OUT=$(curl -s -X POST "$BASE/chat-links/bundle" -H "$AUTH" \
-F 'link={"label":"Careers","tags":["careers"],"channels":["chat","call"]}' \
-F 'knowledge=[{"content":"We hire across engineering and delivery."}]' \
-F 'files=@roles.pdf')
ID=$(echo "$OUT" | jq -r .link.id)
echo "$OUT" | jq -r .link.url # <- store this now
# watch the knowledge load
curl -s "$BASE/chat-links/bundle/$(echo "$OUT" | jq -r .knowledge.job_id)" -H "$AUTH"
# add to it later - same key, no login
curl -s -X POST "$BASE/knowledge/portal/upload" -H "$AUTH" \
-F file=@more-roles.pdf -F "link_id=$ID"
# revoke when the role closes
curl -s -X POST "$BASE/chat-links/$ID/revoke" -H "$AUTH"Questionnaires API
liveAsk one person a set of questions in a recorded voice session, and collect what they said — from your own system, with no portal step.
What this is#
You send a set of questions and a person's name. We give you a URL. They open it in a browser, agree to being recorded, and have a short spoken conversation with an agent that asks your questions. When it ends you get a written record of what was asked and what they answered.
- Two nouns. A *questionnaire* is the reusable list of questions. A *link* is one send to one person. One link, one person — not a form many people fill.
- The URL is the credential. It carries a 192-bit token, and there is no password, no account and no login for the person answering.
- Opening it spends nothing. A mail scanner that pre-fetches the URL does not consume the link; only pressing Start does.
- The session runs in their browser. It needs a microphone and a camera, so it cannot be driven from a server — that part is theirs, not yours.
- The result is pulled. Poll for it, or fetch one link when you know it finished. See Collecting the result.
The flow, once#
- 1. Create.
POST /questionnaires/bundlewith the questions and the person. You get back a URL. - 2. Send it. By your own email, your ATS, your CRM — we do not send it for you.
- 3. They answer. They open the URL, agree to the notice, and talk to the agent. Typically five to fifteen minutes.
- 4. Collect. Poll
GET /questionnaires/links/all, then fetch each one withGET /questionnaires/links/{token}/result. - 5. Keep or remove. Download the PDF, stream the recording, or delete the media early.
Create a session#
/questionnaires/bundleCreates the questionnaire and the person's link in one call, and returns the URL to send.
curl -s -X POST "https://api.reapdat.com/api/v1/questionnaires/bundle" \
-H "X-API-Key: ua_admin_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"person": { "name": "Anita Rao", "email": "anita@example.com" },
"title": "Support engineer - first round",
"questions": [
"Tell me about your support experience.",
{ "text": "Which Spring Boot version do you run?", "points": ["Spring Boot 3"] }
],
"recording_mode": "audio_video",
"monitor_activity": true,
"expected_minutes": 8,
"expiry_hours": 48
}'{
"ok": true,
"questionnaire": {
"id": "e4b4e816-7d2e-45c2-b6c8-3f0aa0c1fef6",
"title": "Support engineer - first round",
"questions": [
{ "text": "Tell me about your support experience.",
"kind": "open", "points": [] },
{ "text": "Which Spring Boot version do you run?",
"kind": "expected", "points": ["Spring Boot 3"] }
],
"question_count": 2,
"link_count": 1
},
"link": {
"token": "Xm4Qd-Ls7PZv0KcR9TbYh2NwEg6UAj1f",
"url": "https://reapdat.com/q/acme/support-engineer-first-round/Xm4Qd-Ls7PZv...",
"status": "issued",
"person": { "name": "Anita Rao", "email": "anita@example.com", "phone": "" },
"expires_at": "2026-09-23T15:11:37+00:00",
"result": null
}
}| field | type | notes |
|---|---|---|
| person.name | string | Required. The agent greets them by it. There is no default — a missing name is a mapping bug, not a blank. |
| person.email | string | For your records. We do not email them, and it can come back as "" if you send none — the token is the only reliable join key. |
| person.phone | string | Same. |
| questions | array | A string, or {text, points}. Send this or questionnaire_id, never both. Maximum 25. |
| points | string[] | What a good answer covers, up to 6 per question. Their presence is what makes a question *expected* rather than *open* — see Two kinds of question. |
| questionnaire_id | string | Reuse a saved list instead of creating one. |
| title | string | Internal name, and part of the readable URL. Defaults to "Questions for {name}". |
| about | string | One line shown to the person before they start. |
| recording_mode | string | audio (default), audio_video, or none. An unknown value is refused, not downgraded. |
| monitor_activity | boolean | Default false. See Activity tracking — it does not control the camera. |
| expected_minutes | integer | Shown to the person as a time estimate. Does not cut the session off. |
| context_text | string | A job description, a brief, background notes. The agent may read it to understand answers, and is forbidden from asking about anything in it. |
| expiry_hours | integer | Default 168 (7 days). 1 to 2160 (90 days). |
Two kinds of question#
A question with no points is open: it is asked, and whatever they say is recorded. A question with points is expected: the same question, asked the same way, but the write-up afterwards reports which of those points their answer actually covered, with their own words as the evidence.
/questionnairesEvery saved questionnaire on the account, with its full question list — how you read back what you sent.
{
"questionnaires": [
{
"id": "e4b4e816-7d2e-45c2-b6c8-3f0aa0c1fef6",
"title": "Support engineer - first round",
"about": "",
"questions": [
{ "text": "Tell me about your support experience.",
"kind": "open", "points": [] },
{ "text": "Which Spring Boot version do you run?",
"kind": "expected", "points": ["Spring Boot 3"] }
],
"question_count": 2,
"recording_mode": "audio_video",
"expected_minutes": 8,
"context_text": "",
"monitor_activity": true,
"link_count": 1,
"created_at": "2026-09-21T15:11:37+00:00"
}
]
}If your questions live in a document, read them first and send back what you got. POST /questionnaires/parse takes {text} and POST /questionnaires/parse-document takes a multipart/form-data file (PDF, DOCX, TXT, MD, CSV). Both return a proposed questions array plus note, expected and open counts, and store nothing — so your code can check what was understood before anything exists, which is the same confirmation step the browser builder puts in front of a person. A document that cannot be read is a 400 with a detail sentence; when the file extracted but the questions did not parse, that 400 also carries the extracted text, so a bad parse is recoverable by hand instead of a dead end. If the reader itself is down you get a 503 saying so — retry that one rather than reformatting a document that was never the problem.
Activity tracking#
monitor_activity turns the proctoring on or off. Off is the default, and is the right choice for most uses — an intake call, a pre-visit questionnaire, a vendor check.
| false (default) | true | |
|---|---|---|
| Camera | On | On |
| Frames analysed | No | Yes, about one a second |
| Someone else in frame | Not checked | Checked |
| Tab switches, window blur | Not recorded | Recorded |
monitoring in the result | null | A colour, a headline and findings |
flags in the result | What the agent noticed | Still only the agent and the browser — the watcher's findings are in `monitoring.findings`, never in `flags` |
Finding what is new#
/questionnaires/links/allEvery link on the account, newest first by CREATION — a link that gets answered does not move to the top, so a page stays stable while you walk it. This is the polling endpoint.
curl -s -G "https://api.reapdat.com/api/v1/questionnaires/links/all" \
-H "X-API-Key: ua_admin_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
--data-urlencode "status=completed" \
--data-urlencode "since=2026-09-21T00:00:00Z"| parameter | notes |
|---|---|
| status | Comma-separated, case-insensitive, matched against the DERIVED status: issued, opened, in_progress, completed, expired, revoked. Omit it — or send it empty — for everything. An unknown value is a `400`, not an empty page: a typo in that constant would otherwise report "nothing new" for ever. |
| questionnaire_id | Only the links made from that one questionnaire. Combine with status and since freely. An id that is not on your account is a 404, for the same reason an unknown status is a 400 — a stale constant must not read as "nothing new". |
| since | ISO 8601. Matches the last time anything happened to the link — completed, else started, else opened, else created. See the note below. |
| limit | Default 200, 1 to 500. Outside that, or not a number, is a 400 with a plain sentence — not a field-level 422. |
| offset | Skip this many matches, 0 to 1,000,000. There is no total count and no has_more: a page shorter than limit is the last page. |
| monitor_activity | Not a parameter — a field on every row, so you can tell which sessions were watched without fetching each result. Same name you sent it as. |
Collecting the result#
/questionnaires/links/{token}/resultOne link, and what came out of it once the session has happened.
{
"ok": true,
"link": {
"token": "Xm4Qd-Ls7PZv0KcR9TbYh2NwEg6UAj1f",
"url": "https://reapdat.com/q/acme/support-engineer-first-round/Xm4Qd-Ls7PZv...",
"status": "completed",
"person": { "name": "Anita Rao", "email": "anita@example.com", "phone": "" },
"questionnaire": {
"id": "e4b4e816-...",
"title": "Support engineer - first round",
"question_count": 2,
"recording_mode": "audio_video",
"monitor_activity": true
},
"created_at": "2026-09-21T15:11:37+00:00",
"expires_at": "2026-09-23T15:11:37+00:00",
"opened_at": "2026-09-21T16:02:11+00:00",
"started_at": "2026-09-21T16:02:40+00:00",
"completed_at": "2026-09-21T16:09:02+00:00",
"recording": {
"available": true,
"bytes": 4823104,
"url": "https://api.reapdat.com/api/v1/questionnaires/links/Xm4Qd-Ls7PZv.../recording",
"delete_after": "2026-12-20T16:09:02+00:00"
},
"report_pdf_url": "https://api.reapdat.com/api/v1/questionnaires/links/Xm4Qd-Ls7PZv.../report.pdf",
"record_delete_after": "2027-03-20T16:09:02+00:00",
"result": {
"duration_seconds": 382,
"summary": "Answered both questions. Named a version without being asked twice ...",
"confidence": "medium",
"confidence_reason": "Two questions, short answers, one inaudible stretch.",
"answers": [
{ "n": 1,
"question": "Tell me about your support experience.",
"summary": "She said she has four years, mostly second line." }
],
"coverage": [
{ "n": 2,
"answered": "full",
"note": "Named the major version.",
"quote": "we're on Boot 3 now",
"points": [
{ "point": "Spring Boot 3", "state": "covered",
"quote": "we're on Boot 3 now" }
] }
],
"strengths": ["Specific about the escalation path"],
"gaps": ["Did not say which version they upgraded from"],
"follow_up": ["Ask what the upgrade involved"],
"transcript": [
{ "speaker": "agent", "text": "Hello Anita, thanks for joining ...", "at_seconds": 2 },
{ "speaker": "person", "text": "Sure, so I've been ...", "at_seconds": 11 }
],
"monitoring": {
"colour": "amber",
"headline": "No sustained indicator. A second person in frame once, briefly.",
"findings": [
{ "key": "another_person", "level": "amber", "label": "another person",
"windows": 1, "share": 2.9, "seen_by": ["invigilator"],
"at_seconds": [95], "why": "seen in 1 of 34 checks" }
],
"windows": 34, "windows_observed": 31, "coverage_pct": 91,
"frames_captured": 512, "red": 0, "amber": 1,
"looking_away_windows": 4,
"looking_away_note": "Recorded and deliberately not counted towards the colour ...",
"alone_confirmed": false,
"thresholds": { "sustain_windows": 2, "window_sec": 10, "coverage_for_green": 90 }
},
"flags": [
{ "kind": "another_person", "detail": "A second voice was heard.",
"source": "agent", "at_seconds": 95 }
],
"evidence_frames": [ { "at_seconds": 0 }, { "at_seconds": 95 } ]
}
}
}| field | notes |
|---|---|
| status | issued → opened → in_progress → completed, plus expired and revoked. Always derived when you read it: nothing runs on a timer, so a link that quietly passed its expiry still reports expired the next time you look. |
| result | null until the session ends, on a 200. That is the normal case for most of a link's life, not an error — do not treat it as one. |
| …/report.pdf before it finishes | `400`, not 404 — the link is yours and real, there is simply nothing to render yet. A 404 on this API always means "not on your account", so a poller can keep that mapping. |
| …/recording when there is none | 404. That one IS an absent record: either it was never recorded (recording_mode: "none") or it has been deleted. |
| duration_seconds | Integer seconds from Start to hang-up. |
| summary | A few sentences of prose. Always a string, "" if the write-up failed. |
| strengths, gaps, follow_up | Arrays of short strings, always present, frequently [], never null. follow_up is what a human might ask next — not advice about the person. |
| answers[] | {n, question, summary} — one row per question you sent, in order, with summary: null for anything never reached. See the warning above. |
| coverage[] | {n, answered, note, quote, points}. answered is full | partial | not_answered | not_asked. Every key is always present: nothing to quote is "", no points is []. |
| coverage[].points[] | {point, state, quote}, one per expected point, state being covered | not_covered | unclear. Present and empty on an open question — and also on an expected question the session never reached, so an empty list is not the same as "none of them were covered". |
answered vs points | Two different axes, and they can disagree legitimately. answered is whether they responded to the question; points is whether the response contained the substance. A row can be answered: "full" with every point not_covered — they talked, and did not say the thing. |
not_asked is best-effort | It is the debrief model's read of a finished transcript, and on a session that ended early it sometimes says not_answered for a question nobody put to them. `answers[].summary === null` is the reliable signal that a question was never reached — it comes from the agent, during the conversation. Do not count not_answered against someone without checking it. |
| confidence | low | medium | high, always with a non-empty confidence_reason — shipped together because a confidence with no reason is a number people round up. It rates the write-up, not the answers: a short session full of refusals can be high confidence, because the model is sure of what it read. It is a statement about the evidence, never about the person. |
| transcript[] | {speaker, text, at_seconds}, speaker being agent or person, coalesced into one row per turn. |
| monitoring | null when activity tracking was off — not an empty object. "Nothing watched" and "watched, saw nothing" are different findings, and questionnaire.monitor_activity in the same response always agrees with which one you got. When present: colour (green/amber/red), headline, findings[], looking_away_note, and the counts behind them — windows, windows_observed, coverage_pct, frames_captured, red, amber, looking_away_windows, alone_confirmed, thresholds. |
| monitoring.findings[] | {key, label, level, windows, share, seen_by, at_seconds, why}. level is amber or red; seen_by is a list (invigilator, agent, or both — they can notice the same thing); `at_seconds` is a list too, one entry per window it was seen in, even when there is only one. |
| monitoring, the counts | windows is how many ten-second checks ran; windows_observed how many of them actually had frames to look at. `coverage_pct` is the share of the session's SECONDS that fell inside an observed window — not windows_observed / windows — so a session whose last stretch ran past the final check reads below 100 with every window observed. frames_captured is images received. red and amber count findings at each level, and thresholds reports the numbers that produced the colour, including coverage_for_green, which coverage_pct is measured against. |
| flags[] | {kind, detail, source, at_seconds}. source is agent when the agent raised it mid-conversation, or browser when the page reported something the person's own browser knows for free (leaving the tab, losing focus). Present whether or not tracking was on. |
| evidence_frames[] | Stills evenly spaced across the session — not the flagged moments. The times are here; the pictures are in the PDF, and they stay in this list even after the media is deleted. Empty is common and means only "no stills available": no camera frames arrived, or the session predates this feature. It is not evidence of anything either way. |
| recording.url, report_pdf_url | Absolute, built from the host you called, and both take the same admin key. "" before the session completes. The PDF is generated per request and never stored. |
| token | 32 URL-safe base64 characters with no prefix, and it can begin with a hyphen — quote it in a shell and never pass it as a bare CLI argument. |
| url | The person's link, and the one field that is not on your API host: it points at the public site the session runs on, which is configured per account and is a different hostname from the API. Send it as given; do not rebuild it against the host you called. |
| recording.bytes | null when there is no recording — not 0, and delete_after is null alongside it rather than describing a file that is not there. |
| "no recording" is two situations | questionnaire.recording_mode tells you which: none means nothing was ever recorded, while audio or audio_video with available: false means it was recorded and has since been deleted — by you, or by the 90-day purge. |
/questionnaires/links/{token}/recordingThe media itself, streamed. audio/webm or video/webm depending on how it was recorded, with byte-range support so a player can seek. 404 when there is none.
/questionnaires/links/{token}/report.pdfThe written record as a PDF, built per request. Comes back as an attachment named after the person; the filename is sent twice, ASCII and RFC 5987, so a non-Latin name survives the download.
Consent, recordings and deletion#
- Consent is taken before anything is captured. The person is shown one screen naming what is recorded, that both sides of the conversation are in the file, who sees it and how long it is kept, and ticks one box. What they were shown is stored with a version stamp, so the record can say *which* wording was agreed to.
- The recording carries the agent too, not only them. That is said on the screen, because a person told "you are being recorded" pictures their own voice.
- Media is deleted at 90 days and the written record at 180, stamped on the row when it is written rather than worked out later by a nightly job.
/questionnaires/links/{token}/revokeKills a link immediately. Anyone holding the URL gets a dead page. Returns {"ok": true}.
/questionnaires/links/{token}/recordingRemoves the audio, the video and the stills now, and keeps the written record. This is what to call when someone asks. Returns {"ok": true} — including when there was no recording to remove, so it is safe to call blind.
/questionnaires/links/{token}Removes one send and everything it produced — the link, the answers, the transcript, the recording and the stills. This is the erasure call: reach for it when the data should not exist, not when the person should simply stop being able to open the link. Returns {"ok": true}; a 404 if it is already gone, so two people clearing the same request do not get a 500 on the second one.
/questionnaires/{questionnaire_id}Retires a questionnaire so it stops appearing in the list. Links already sent keep working and every response already collected is kept — a hard delete would take the records of people you have already screened with it.
- Revoke is idempotent, and it does not destroy anything: revoking a link that was already answered leaves
resultexactly as it was.statusbecomesrevokedand stays there. - Deleting the recording leaves the record. Afterwards
recording.availableisfalse,bytesanddelete_afterarenull,urlis"", andreport_pdf_urlstill works — the PDF is built from the written record, not from the media. The evidence stills go with the recording, so that PDF no longer carries the pictures;evidence_framesstill lists the times. - Three deletes, three blast radii, and picking by name alone will get it wrong.
DELETE .../recordingtakes the media and keeps the written record.DELETE .../links/{token}takes the whole send, record included.DELETE /questionnaires/{id}retires the QUESTIONNAIRE and keeps every response ever collected from it — the one that sounds the most destructive is the least. - None of them can be undone. There is no restore and no bin. The files go before the row on the link delete, so a failure leaves everything in place rather than a record that claims the media is gone while it is still on disk.
Limits#
| limit | at the edge | |
|---|---|---|
| Questions per questionnaire | 25 | 400, nothing created |
| Expected points per question | 6 | 400, nothing created |
| Link lifetime | 1 hour – 90 days | 400, nothing created |
| Session length once started | 90 minutes | The link stops accepting a resume |
| Audio recording | 50 MB (about 90 minutes) | Recording stops growing; everything captured so far is kept |
| Video recording | 500 MB | Same |
| Links per account | No cap | Questionnaire links are not under the 20-link chat-link quota — one URL per person is a different shape |
| Listing | 500 per page | Use offset |
The whole flow, as a script#
AUTH="X-API-Key: ua_admin_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
API="https://api.reapdat.com/api/v1/questionnaires"
# 1 — create the session and get the URL to send
CREATED=$(curl -s -X POST "$API/bundle" -H "$AUTH" -H "Content-Type: application/json" \
-d '{"person":{"name":"Anita Rao","email":"anita@example.com"},
"title":"Support engineer - first round",
"questions":["Tell me about your support experience.",
{"text":"Which Spring Boot version do you run?",
"points":["Spring Boot 3"]}],
"expiry_hours":48}')
echo "$CREATED" | jq -r '.link.url' # send this yourself, however you reach people
TOKEN=$(echo "$CREATED" | jq -r '.link.token')
# 2 — later, collect whatever has finished since the last run
curl -s -G "$API/links/all" -H "$AUTH" \
--data-urlencode "status=completed" \
--data-urlencode "since=2026-09-21T00:00:00Z" | jq -r '.links[].token'
# 3 — the write-up, their own words, and a PDF to keep
curl -s "$API/links/$TOKEN/result" -H "$AUTH" | jq '.link.result.coverage'
curl -s "$API/links/$TOKEN/result" -H "$AUTH" | jq -r '.link.result.transcript[]
| select(.speaker=="person") | .text'
curl -s "$API/links/$TOKEN/report.pdf" -H "$AUTH" -o "anita-rao.pdf"Knowledge API
liveEverything your agent knows, from your own systems — add a document, crawl a site, write a Q&A pair, publish a question set, and take any of it away again.
What this is#
The knowledge base is what the agent answers from. Everything in it is retrievable by whoever is talking to the agent — a visitor on the widget, a caller on the phone — so treat adding to it as publishing, not as storage.
- Five kinds of source. Documents you upload, pages we crawl, Q&A pairs you write, photo albums, and question sets. They differ in how they arrive and not in what happens next: every one is chunked, embedded and searched the same way.
- Source priority decides ties, not truth. When two sources could answer, the ranked order breaks it. It does not make a wrong page right.
- Deleting is immediate. The chunks go and the agent stops answering from them on the next question. There is no soft-delete tier to sweep later.
Add knowledge#
Three shapes, one destination. link_id scopes a write to a single chat link's isolated knowledge; omit it and the write lands in the Main KB that the vanity slug and the voice agent read.
/knowledge/portal/ingestA Q&A pair (question + answer) or free text (content). One of the two is required; sending neither is a 400.
/knowledge/portal/uploadMultipart. The file is extracted, chunked and embedded; the file itself is not served back.
/knowledge/crawlStart a crawl. Returns a job_id — the work happens after the response.
/knowledge/crawl/{job_id}/statusPoll it. A crawl of a real site takes minutes, not seconds.
curl -X POST https://api.reapdat.com/api/v1/knowledge/portal/ingest \
-H "X-API-Key: $REAPDAT_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"question": "What are your opening hours?",
"answer": "Monday to Friday, 9am to 6pm. Closed on public holidays."
}'List and remove#
/knowledgeEvery source on the account, grouped, with chunk counts and where each came from.
/knowledge/documents/{parent_doc_id}/textWhat was actually extracted from one document. Worth reading before you trust a PDF.
/knowledge/documents/{parent_doc_id}Removes every chunk of one source. 404 when there is nothing to remove.
parent_doc_id is the handle for a whole source — a file, a crawled page, one Q&A pair — and the chunks under it are an implementation detail you never address directly.
Question sets#
A named list of questions, written once and reused. They live in the knowledge base because they are the same kind of thing as the rest of it — material a business writes down and reuses — and the Questionnaires API copies one when it creates a session.
/questionnaires?templates=trueThe library. Without the flag you get sends instead; the two lists never overlap.
/questionnairesis_template: true writes a library entry. Honoured on create only — an existing row never changes side.
What the agent knows#
/knowledge/agent-summaryA plain-language summary of what is in there, generated from the content.
/knowledge/quality-scoreCoverage and gaps — what a visitor is likely to ask that nothing answers.
/knowledge/source-priorityThe ranked order used to break ties between sources.
/knowledge/source-priorityReorder it. An unknown source name is a 400 rather than a silent no-op.
These are the endpoints worth polling if you are showing a health widget in your own product. None of them are cheap enough to call per page view.
Communication
liveSend email, WhatsApp and SMS to your own customers. You supply the contacts and the wording; we supply the sending, the gates in front of it, and the record of what came back.
What this is#
Three nouns and one pipe. A template is what to say, on one channel. An audience is who to say it to. A broadcast ties them together and sends. Everything else in this reference is a detail of one of those three.
POST /communication/templates -> template_id
POST /communication/audiences -> audience_id
POST /communication/audiences/{id}/contacts
POST /communication/broadcasts -> broadcast_id
POST /communication/broadcasts/{id}/preview <- required
POST /communication/broadcasts/{id}/send- One message per person, not one per channel. A broadcast lists channels in order and each contact gets the first one they qualify for.
- A message to one person is a broadcast with one contact.
POST /send-oneis a shortcut over the same machinery, not a second path — so it passes the same checks. - Nothing is silently dropped. Every contact a check excludes becomes a durable row with a reason you can read back.
- A send cannot start until its dry run has been read. That is enforced, not advised.
Credentials, and the account switch#
Every endpoint takes either a portal session or an admin API key, and both resolve to the same account. Server-to-server, use the key.
X-API-Key: ua_admin_...
Authorization: Bearer <portal session>Outbound messaging is on for every account by default. It can be switched off per account, and while it is off every endpoint here answers 403 with "Communication is not enabled for this account yet." — with one exception: GET /communication/readiness always answers, so your own UI can ask whether to show anything at all. Being enabled is not permission to message anyone: consent is still per contact per channel, and a channel with no sender configured still reports itself as not ready.
curl https://api.reapdat.com/api/v1/communication/readiness \
-H "X-API-Key: ua_admin_..."{
"enabled": true,
"channels": {
"email": { "ready": true, "reason": "", "sender": "Bright Smile Dental" },
"whatsapp": { "ready": true, "reason": "", "sender": "+14375240673" },
"sms": { "ready": true, "reason": "", "sender": "+14375240673" },
"voice": { "ready": true, "reason": "", "sender": "+14165550123" }
},
"segments": {
"leads": "Everyone your AI has captured as a lead",
"bookings": "Anyone who has booked an appointment",
"callers": "Anyone who has called you",
"chatters": "Anyone who has chatted with your assistant"
}
}Read channels[x].ready before you build anything around a channel. A channel that is not ready still accepts templates — you can write them now — but every message on it is blocked at send time with channel_not_ready, and reason is the sentence to show your user.
Templates, and your own fields#
A template belongs to exactly one channel. Write as many as you like — the limit is 200 per account. Variables are named, in double braces, and any name works: nothing registers a field anywhere.
curl -X POST https://api.reapdat.com/api/v1/communication/templates \
-H "X-API-Key: ua_admin_..." -H "Content-Type: application/json" \
-d '{
"name": "Invoice due",
"channel": "email",
"subject": "Invoice {{invoice_no}} for {{business.name}}",
"body": "Hi {{contact.name}}, {{amount}} is due on {{due_date}}. Pay here: {{login_link}}",
"category": "utility",
"language": "en"
}'{
"id": "1f95d26c-…",
"name": "Invoice due",
"channel": "email",
"variables": ["invoice_no","business.name","contact.name","amount","due_date","login_link"],
"provider_status": null,
"is_active": true
}variables is discovered from what you wrote, in first-seen order, and returned so you can show a user which values a template needs. You never declare it.
| Variable | Resolves to |
|---|---|
{{contact.name}} | The contact's name |
{{contact.email}} · {{contact.phone}} | Their address or number |
{{contact.attributes.plan}} | One of your own fields, explicitly |
{{plan}} | Shorthand — a contact column first, then one of your own fields |
{{business.name}} | Your business name. Also .email, .phone, .website |
Optionally, say where each value comes from. variable_sources maps a placeholder to its source. Leave it out and resolution is exactly as above — a contact column, then one of your own fields. Set it and you can pin a value, ask for one from the page a panel is embedded in, or leave a blank for whoever sends the message to fill.
curl -X POST https://api.reapdat.com/api/v1/communication/templates \
-H "X-API-Key: ua_admin_..." -H "Content-Type: application/json" \
-d '{
"name": "Interview moved",
"channel": "sms",
"body": "Hi {{name}}, your {{job_title}} interview moved to {{new_time}}. We are at {{office}}.",
"variable_sources": {
"name": { "from": "recipient.name" },
"job_title": { "from": "page" },
"new_time": { "from": "typed" },
"office": { "from": "fixed", "value": "Andheri East, Mumbai" }
}
}'{
"id": "7c41e9a8-…",
"variables": ["name","job_title","new_time","office"],
"variable_sources": { "…": "as sent" },
"context_keys": ["job_title"],
"typed_keys": ["new_time"]
}| Source | Filled by |
|---|---|
recipient.name · recipient.phone · recipient.email | The contact being messaged. Automatic. |
page | Whatever calls the send — the values object, or a matching field on the contact. |
typed | Whoever sends the message. An embedded panel draws a box for it. |
fixed | The value you stored. Resolved server-side on every send. |
booking | Your own booking page, as a link. Takes nothing from anybody — not the contact, not your page, not the sender. Stores no URL: it is resolved at send time, and the send is refused rather than sent with a dead link if bookings are paused or your page has no Book tab. |
context_keys and typed_keys are derived from the mapping and returned so you can show a user exactly which values to supply and which will be typed. Do not keep your own copy of that list — it changes when the mapping does.
Every rendered value is sanitised: newlines, tabs and control characters collapse to a single space. A URL is unaffected; a pasted multi-line value cannot split an SMS or break a WhatsApp parameter.
| Channel | Required | Body limit |
|---|---|---|
email | subject and body | 100,000 characters |
sms | body | 1,600 characters |
whatsapp | body, category | 1,024 characters |
voice | body (a brief, not a script) | 4,000 characters |
PATCH /communication/templates/{id} edits one. DELETE deactivates rather than removes it — a sent message references its template, and the log has to keep being able to say what was sent. A template in use by a running broadcast refuses to be deleted with 409.
POST /communication/templates/{id}/preview renders one against sample values and returns {subject, body, params, missing, sms} — useful for a live preview in your own editor. missing is the same list the send-time check uses.
WhatsApp: approval, and why your variables are renumbered#
Meta approves WhatsApp templates before they can send, and it approves them with positional placeholders — {{1}}, {{2}} — not names. A send whose parameter count differs from the approved template is rejected outright.
You never deal with that. Write named variables like any other channel; on submit we translate to positional, send that to Meta, and store the mapping on the template. At send time the parameters are built from the stored map, so the count always matches by construction.
you: Hi {{contact.name}}, your {{plan}} is due {{renewal_date}}.
Meta: Hi {{1}}, your {{2}} is due {{3}}.
map: { "1": "contact.name", "2": "plan", "3": "renewal_date" }
send: ["Priya", "Gold", "14 Mar"]- A repeated variable takes one position. Meta counts distinct placeholders; passing a duplicate twice is a count mismatch.
- A body may not begin or end on a variable. Meta rejects that, so we refuse it at save time with a
400rather than days later with an opaque rejection. - Editing the wording drops the template back to draft. Meta approved specific text; a changed body has to be approved again.
- A reviewer sees an example, and we build it from your own data. Meta requires a sample value for every placeholder and refuses the submission without one. We render the example from one of your contacts through the same path a real send uses, so what the reviewer judges is what your customer would actually receive. With no contacts uploaded yet the values are padded — it still submits, it just reviews on weaker evidence.
- Approval is pulled, not pushed. Meta has no callback for template review, so
provider_statusupdates when something asks: listing templates, or a queued broadcast reaching its send. A broadcast queued while its template is still in review sends itself once it clears — you do not have to come back and start it.
| `provider_status` | Meaning |
|---|---|
draft | Written, not submitted. Cannot send. |
pending | With Meta. Usually a day or two. Cannot send. |
approved | Ready. |
rejected | Meta refused it; provider_error carries the reason. |
/communication/templates/{id}/submitTranslates and sends the template to Meta for approval. Goes out on your own WhatsApp Business account if you have connected one, otherwise on the Reapdat platform account — 400 only when neither exists. Review takes a day or two; provider_status moves pending → approved or rejected on its own, and GET /communication/templates refreshes it. Sending before approval is not an error — every message is simply blocked with template_not_approved, and a broadcast queued while a template is still in review sends itself once it clears.
category is a billing decision as much as a policy one — WhatsApp charges marketing several times the rate of a utility message. Pick the one that honestly describes the send: utility, marketing or authentication.
SMS: segments, and sender registration#
A template's response carries sms_characters, sms_segments and sms_unicode so you can show the cost before it is written. One non-GSM character — a curly quote pasted from a word processor, an emoji, an accented name — drops the per-segment budget from 160 characters to 70, and a message that looked like one segment becomes three.
dlt_template_id is on the template for India, where every SMS body must be pre-registered on a carrier DLT portal. Unregistered traffic there is dropped silently rather than rejected loudly, which is why the id lives on the template it belongs to rather than in account settings.
- It sends from YOUR number, not ours. Whatever is registered to the account is the From, so the recipient sees a number they recognise and the traffic runs on your carrier registration rather than the platform's.
- We append the opt-out line — “Reply STOP to opt out” — unless your body already says something equivalent. Two opt-out sentences in one message reads as a mistake and wastes a segment, so we check before adding.
- An inbound STOP clears that contact's SMS consent on your account, immediately and for every future send. It is matched on the number that texted and the number it texted, so it clears the right account's contact and nobody else's.
Calls: the channel that rings someone#
voice places a real phone call from your number, and the assistant that answers your inbound line is the one that speaks. It is not a recorded announcement and not a text-to-speech read-out: the person can interrupt, ask what it costs, and be booked in — the knowledge base and the booking engine are available mid-call, the same as on any other call you take.
Because of that, a voice template's body is a brief, not a script. Write what the call should achieve; the assistant phrases it live. A verbatim script gets read aloud badly and throws away the one advantage a call has over an SMS.
curl -X POST https://api.reapdat.com/api/v1/communication/templates \
-H "X-API-Key: ua_admin_..." -H "Content-Type: application/json" \
-d '{
"name": "6-month recall call",
"channel": "voice",
"body": "Remind {{contact.name}} their check-up is due and offer to book a time. If they are not interested, thank them and ring off."
}'{
"id": "…",
"channel": "voice",
"variables": ["contact.name"],
"is_active": true
}There is no way to make a cold AI call through this API, and that is structural rather than a policy. The country matrix refuses cold AI voice in every market we operate in, and the voice channel is mapped to the opt-in rule, so a contact without consent_voice is blocked before the matrix is even consulted.
- The assistant says it is an AI before anything else. That opening is added to every campaign brief by the platform, not left to your wording — a tenant who forgets it is the account that gets the letter.
- It knows the call is outbound. Without that it opens with the inbound welcome and waits for a request that is never coming.
- Calls are paced, not fanned out. Roughly five a minute. Each one occupies a carrier leg, a voice session and a model for its whole duration, and a burst would collide with the customers trying to ring the same number.
- Readiness needs calling switched on for the account. The number it dials from is your own registered one if you have it, and the Reapdat platform number otherwise —
readinessreports which asvoice.sender. Briefs can be written before either exists.
| State | What it means for a call |
|---|---|
sent | The call was placed and the carrier accepted it |
delivered | Somebody picked up |
failed + no_answer | Nobody picked up, or the line was busy |
failed | The call could not be placed at all |
Getting contacts in#
An audience is a named list. Four ways to fill one, and they all land on the same normalizer — a contact added by any route is identical to one added by any other.
| Endpoint | For |
|---|---|
POST /audiences/{id}/contacts | Your own system, JSON. The integrator's door. |
POST /audiences/parse → /import | A CSV: parse, confirm the mapping, then store. |
POST /audiences/parse-text → /import | Pasted text, any shape. |
POST /audiences/{id}/sync-segment | People Reapdat already knows about. |
curl -X POST https://api.reapdat.com/api/v1/communication/audiences/{id}/contacts \
-H "X-API-Key: ua_admin_..." -H "Content-Type: application/json" \
-d '{
"rows": [
{ "name": "Priya Sharma", "email": "priya@example.com", "phone": "+919876543210",
"plan": "Gold", "renewal_date": "14 Mar", "login_link": "https://pay.example.com/t/9fA2xQ" },
{ "name": "Broken Row", "email": "not-an-email" }
],
"consent": { "channels": ["email"], "source": "ticked the box on our booking form" }
}'{
"created": 1,
"updated": 0,
"rejected": 1,
"rejected_rows": [
{ "row": 2, "reason": "'not-an-email' is not a valid email address",
"data": { "name": "Broken Row", "email": "not-an-email" } }
],
"total_in_audience": 1
}- Any key that is not a known field becomes one of your own.
plan,renewal_dateandlogin_linkabove are now{{plan}},{{renewal_date}}and{{login_link}}in a template. No schema change, no registration. - Known fields are
name,email,phone,country,timezone,external_idand the threeconsent_*flags. Everything else is yours. - A bad row is reported, never repaired. You get the row number, the reason and an echo of the data. A near-miss is a miss — we will not guess at a malformed address.
- Re-importing is idempotent. Contacts are keyed on email, falling back to phone. The same file twice updates; it does not duplicate.
- A duplicate inside one upload is collapsed, because the same customer appearing twice in an export is ordinary rather than an error.
| Limit | |
|---|---|
| Rows per import | 50,000 |
| Audiences per account | 100 |
| Your own fields per contact | 40, each up to 500 characters |
| CSV upload size | 10 MB |
POST /audiences/parse reads an uploaded CSV and stores nothing — it returns the headers, a suggested mapping, the variable name each unmapped column would become, and the rows. Show that to a user, let them confirm, then post it to /import with the mapping they chose. Guessing the mapping silently is how an import tool earns a support queue: “Contact” means a phone number at one business and a person at the next.
curl -X POST https://api.reapdat.com/api/v1/communication/audiences/parse-text \
-H "X-API-Key: ua_admin_..." -H "Content-Type: application/json" \
-d '{"text": "Priya Sharma, priya@example.com, +91 98765 43210\nRaj K <raj@example.com>\njoined 2026-09-25"}'{
"contacts": [
{ "name": "Priya Sharma", "email": "priya@example.com", "phone": "+919876543210" },
{ "name": "Raj K", "email": "raj@example.com", "phone": null }
],
"skipped": [
{ "line": 3, "text": "joined 2026-09-25",
"reason": "no email address or phone number on this line" }
],
"count": 2
}The paste parser handles one contact per line, the Name <address> form a mail client copies, and a blob of addresses separated by commas. It is regex and validation, not a language model: extracting exact strings is where a model is least reliable, and one changed character sends a stranger somebody else's reminder. Date-shaped runs are excluded before phone detection, or a joined-on date becomes a customer's number.
/communication/audiences/{id}/sync-segment?segment=leadsFills an audience from what Reapdat already recorded: leads, bookings, callers or chatters. They arrive with NO permission unless you pass channels and consent_source — being a lead is not, by itself, permission to message them.
GET /audiences/{id}/contacts lists them with q, limit and offset. PATCH .../contacts/{contact_id} edits one — every value is re-validated and a near-miss is a 400, never a silent store. Changing an address changes the identity, so the dedupe key is recomputed and a collision with another contact in the same list is a 409.
Consent#
Consent is recorded per contact, per channel, and checked on every send. It is the first of the checks and there is no override.
{
"consent_email": true,
"consent_sms": false,
"consent_whatsapp": false,
"consent_voice": false,
"consent_source": "ticked the box on our booking form",
"consent_at": "2026-09-25T07:03:08Z"
}- An import can only GRANT. The
consentblock on an import never turns a permission off. A revocation has to come from the person, not from a spreadsheet. - **A row's own
consent_*column wins** where present; the import-wide declaration fills the rest. - `consent_source` is evidence, not decoration. It is what you show when somebody asks why they were messaged, so write what actually happened.
- A segment pull grants nothing by default. Someone having called you is not permission to market to them.
/communication/suppressOpt somebody out across every audience on the account, by email or phone. Optionally narrow to channels. For email it also writes the account's suppression list, which the rest of the platform's mail already honours.
Sending, and what stops a message#
curl -X POST https://api.reapdat.com/api/v1/communication/broadcasts \
-H "X-API-Key: ua_admin_..." -H "Content-Type: application/json" \
-d '{
"name": "February recall",
"audience_id": "8a4b5821-…",
"channel_order": ["whatsapp", "email"],
"template_ids": { "whatsapp": "…", "email": "…" },
"allow_replies": true
}'{
"id": "62ab9a48-…",
"status": "draft",
"channel_order": ["whatsapp","email"],
"allow_replies": true,
"send_window_start": null,
"send_window_end": null,
"previewed_at": null
}channel_order is tried per contact and the first channel they qualify for wins — one message per person, never one per channel. Every channel you list needs a template of that channel, or the create is a 400.
/communication/broadcasts/{id}/previewThe dry run. Writes nothing, runs every check, and reports who is excluded and why. The same code backs this and the real send, so it is not an estimate.
{
"counts": { "total": 1240, "sending": 690, "held": 188, "blocked": 362 },
"by_channel": { "whatsapp": 500, "email": 190 },
"by_reason": {
"no_consent": { "count": 312, "label": "They have not agreed to be contacted on this channel" },
"unsubscribed": { "count": 44, "label": "They opted out" },
"missing_variable": { "count": 6, "label": "The template needs a value this contact does not have" }
},
"samples": [ { "name": "Priya Sharma", "channel": "email",
"subject": "…", "body": "Hi Priya Sharma, your Gold plan…" } ],
"readiness": { "email": { "ready": true, "reason": "", "sender": "…" } }
}| Check | Block reason | What it means |
|---|---|---|
| 1 · Consent | no_consent | No recorded permission for this channel |
| 2 · Stop list | unsubscribed | They opted out |
| 3 · Country | channel_not_allowed | That channel is not permitted where they are |
| 4 · Render | missing_variable | The template needs a value this contact lacks |
| 5 · Hour | — (held, not blocked) | Outside the send window, if you set one |
| — | channel_not_ready | The channel is not set up on this account yet |
| — | template_not_approved | WhatsApp has not approved this template |
| — | invalid_address | No usable address or number on the contact |
| — | no_reachable_channel | No listed channel could reach this person |
| after the call | no_answer | A call was placed and nobody picked up |
POST /broadcasts/{id}/send returns immediately with the summary; delivery runs behind it and is resumable, so a restart mid-send loses nothing. Poll GET /broadcasts/{id} to watch the counters. POST /broadcasts/{id}/cancel stops anything not yet handed to a provider — what has already gone is out of our hands.
There is no send window by default: a broadcast goes out as soon as it is built. Pass send_window_start and send_window_end (local hours, 0–24) if you want quiet hours — they are applied in each recipient's own timezone, with daylight saving handled, and affected messages are reported as held rather than blocked.
One message to one person#
curl -X POST https://api.reapdat.com/api/v1/communication/send-one \
-H "X-API-Key: ua_admin_..." -H "Content-Type: application/json" \
-d '{
"template_id": "1f95d26c-…",
"name": "Priya Sharma",
"email": "priya@example.com",
"attributes": { "invoice_no": "INV-2291", "login_link": "https://pay.example.com/t/9fA2xQ" },
"send_now": true
}'{
"broadcast_id": "…",
"status": "completed",
"message": { "state": "sent", "to_address": "priya@example.com",
"rendered_subject": "Invoice INV-2291 for Bright Smile Dental" }
}- `attributes` is where your own fields go — exactly the same names a template uses, no CSV required.
- Consent is granted automatically for that template's channel, recorded as “direct send by the business”. You named this person deliberately; override the wording with
consent_source. - No quiet hours. An individual send is a deliberate act at a moment you chose, so it ignores any window and goes immediately.
- `send_now: false` builds it and returns the preview without sending — the way to show a confirmation screen.
- It is still a broadcast underneath, with an audience of one. Same checks, same log row, same delivery receipts, same reply matching.
What happened: states, receipts and replies#
Every message is a durable row, including the ones a check excluded. “We sent 690 of 1,240” is only actionable when the other 550 are enumerable.
queued -> sending -> sent -> delivered -> read -> replied
| | |
| +-> failed +-> failed (a provider accepted, then could not deliver)
+-> blocked (a check refused it; it never left)
+-> cancelled (the broadcast was stopped)Sent is not delivered. Sending means a provider accepted the message. A dead number, a landline that cannot take SMS, a handset that is off — all accept, then fail. The real outcome arrives later on the provider's own webhook and is matched back by the id we stored at send time.
Replies are matched exactly, not guessed. We mint the Message-ID when the mail goes out and store it; the customer's reply carries it in In-Reply-To, so the reply names the row rather than resembling it. Sender address is the fallback for channels with no such header. Both are scoped to your account.
{
"state": "replied",
"to_address": "priya@example.com",
"replied_at": "2026-09-25T07:16:41Z",
"reply_snippet": "Yes please, can we do Saturday morning?",
"reply_conversation_id": "…"
}reply_snippet is the first reply's words, whitespace collapsed and capped at 400 characters — the full text lives on the conversation. The first reply wins, not the latest: a thread that runs on is still one answer to one send.
/communication/broadcasts/{id}/repliesTurn reply handling on or off for one send, before or after it goes. It governs HANDLING, not deliverability — the mail leaves from your own address either way, so a customer can always press reply. Off means those replies are not attached to the send. It applies to replies arriving from then on; it cannot reach back and claim one that came in while it was off.
GET /communication/messages is the account-wide log, filterable by state, channel and q (name, address or number). GET /broadcasts/{id}/messages is one send's rows. GET /communication/overview?days=30 gives headline counts plus a breakdown of what was blocked and why.
Every endpoint#
| Method | Path | |
|---|---|---|
GET | /communication/readiness | Which channels can send, and why not. Never gated. |
GET | /communication/overview | Headline counts. ?days= (1–365, default 30) |
GET | /communication/messages | The account-wide log. ?state= &channel= &q= &limit= &offset= |
GET | /communication/templates | ?channel= &include_inactive= |
POST | /communication/templates | Create one |
PATCH | /communication/templates/{id} | Edit. A WhatsApp body change returns it to draft |
DELETE | /communication/templates/{id} | Deactivate. 409 if a running send uses it |
POST | /communication/templates/{id}/submit | Send to Meta for approval (WhatsApp) |
POST | /communication/templates/{id}/preview | Render against sample values |
GET | /communication/audiences | List |
POST | /communication/audiences | Create |
PATCH | /communication/audiences/{id} | Rename, or change its source config |
DELETE | /communication/audiences/{id} | Delete with its contacts. 409 if a send is running |
POST | /communication/audiences/parse | Read an uploaded CSV. Stores nothing |
POST | /communication/audiences/parse-text | Read pasted text. Stores nothing |
POST | /communication/audiences/{id}/import | Persist mapped rows |
POST | /communication/audiences/{id}/contacts | Add rows directly (the API door) |
GET | /communication/audiences/{id}/contacts | ?q= &limit= &offset= |
PATCH | /communication/audiences/{id}/contacts/{cid} | Edit one. Re-validated; 400 on a near-miss |
DELETE | /communication/audiences/{id}/contacts/{cid} | Remove one |
POST | /communication/audiences/{id}/sync-segment | ?segment= &channels= &consent_source= |
GET | /communication/broadcasts | ?status= &limit= |
POST | /communication/broadcasts | Create |
GET | /communication/broadcasts/{id} | One, with live counters |
POST | /communication/broadcasts/{id}/preview | The dry run. Required before sending |
POST | /communication/broadcasts/{id}/send | Fan out and start. 409 without a preview |
POST | /communication/broadcasts/{id}/cancel | Stop anything not yet handed to a provider |
PATCH | /communication/broadcasts/{id}/replies | Reply handling on or off for this send |
GET | /communication/broadcasts/{id}/messages | ?state= &limit= &offset= |
POST | /communication/send-one | One message to one person |
POST | /communication/suppress | Opt somebody out across every audience |
Errors, limits and what is not here yet#
| Status | When |
|---|---|
400 | A template a provider would reject; a body with no usable identifier; an edit that would drop a value you typed |
403 | Outbound messaging is not enabled on this account |
404 | The template, audience, contact or broadcast is not on your account |
409 | Sending without a preview; sending twice; deleting something a running send uses; an edit that collides with another contact |
413 | A CSV over 10 MB, or an import over 50,000 rows |
Every error carries detail as a sentence written for a person, not a code. It is safe to show your users directly — "'not-an-email' is not a valid email address." rather than ERR_VALIDATION_402.
Something missing, or an endpoint behaving differently from this page? Tell us — the reference is written from the code, so a mismatch is a bug in one of them.