← Developers

API reference

Build on Reapdat from your own systems. One key, one base URL, and an integration per product area.

Basehttps://api.reapdat.com/api/v1

Overview#

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.

Base URL
https://api.reapdat.com/api/v1
Request bodyapplication/json, except where a file is being uploaded
TimestampsISO 8601, UTC. A naive value on input is read as UTC
ErrorsAlways { "detail": "..." }, with a meaningful status code
VersioningThe /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_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

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

StatusMeans
400The request was understood and refused. detail says why
401No credential, or a malformed / expired browser session
403The API key is unknown, revoked, expired, or is not an admin key
404No such record on your account. Never confirms one exists elsewhere
405Wrong method for that path — check GET vs POST vs DELETE
422The body did not validate. detail names the offending field
503A part we depend on is down, not your request. Retry
429Rate limited. Back off and retry
403
{
  "detail": "Admin API key required"
}
live

Additional links — a shareable chat page per campaign, role or product, each with its own isolated knowledge base.

Questionnaires API

live

Ask 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/bundle with 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 with GET /questionnaires/links/{token}/result.
  • 5. Keep or remove. Download the PDF, stream the recording, or delete the media early.

Create a session#

POST/questionnaires/bundle

Creates the questionnaire and the person's link in one call, and returns the URL to send.

Request
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
  }'
200
{
  "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
  }
}
fieldtypenotes
person.namestringRequired. The agent greets them by it. There is no default — a missing name is a mapping bug, not a blank.
person.emailstringFor 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.phonestringSame.
questionsarrayA string, or {text, points}. Send this or questionnaire_id, never both. Maximum 25.
pointsstring[]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_idstringReuse a saved list instead of creating one.
titlestringInternal name, and part of the readable URL. Defaults to "Questions for {name}".
aboutstringOne line shown to the person before they start.
recording_modestringaudio (default), audio_video, or none. An unknown value is refused, not downgraded.
monitor_activitybooleanDefault false. See Activity tracking — it does not control the camera.
expected_minutesintegerShown to the person as a time estimate. Does not cut the session off.
context_textstringA job description, a brief, background notes. The agent may read it to understand answers, and is forbidden from asking about anything in it.
expiry_hoursintegerDefault 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.

GET/questionnaires

Every saved questionnaire on the account, with its full question list — how you read back what you sent.

200
{
  "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
CameraOnOn
Frames analysedNoYes, about one a second
Someone else in frameNot checkedChecked
Tab switches, window blurNot recordedRecorded
monitoring in the resultnullA colour, a headline and findings
flags in the resultWhat the agent noticedStill only the agent and the browser — the watcher's findings are in `monitoring.findings`, never in `flags`

Finding what is new#

GET/questionnaires/links/all

Every 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"
parameternotes
statusComma-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_idOnly 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".
sinceISO 8601. Matches the last time anything happened to the link — completed, else started, else opened, else created. See the note below.
limitDefault 200, 1 to 500. Outside that, or not a number, is a 400 with a plain sentence — not a field-level 422.
offsetSkip 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_activityNot 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#

GET/questionnaires/links/{token}/result

One link, and what came out of it once the session has happened.

After the session
{
  "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 } ]
    }
  }
}
fieldnotes
statusissued → 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.
resultnull 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 none404. That one IS an absent record: either it was never recorded (recording_mode: "none") or it has been deleted.
duration_secondsInteger seconds from Start to hang-up.
summaryA few sentences of prose. Always a string, "" if the write-up failed.
strengths, gaps, follow_upArrays 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 pointsTwo 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-effortIt 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.
confidencelow | 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.
monitoringnull 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 countswindows 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_urlAbsolute, 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.
token32 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.
urlThe 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.bytesnull 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 situationsquestionnaire.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.
GET/questionnaires/links/{token}/recording

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

GET/questionnaires/links/{token}/report.pdf

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

Limits#

limitat the edge
Questions per questionnaire25400, nothing created
Expected points per question6400, nothing created
Link lifetime1 hour – 90 days400, nothing created
Session length once started90 minutesThe link stops accepting a resume
Audio recording50 MB (about 90 minutes)Recording stops growing; everything captured so far is kept
Video recording500 MBSame
Links per accountNo capQuestionnaire links are not under the 20-link chat-link quota — one URL per person is a different shape
Listing500 per pageUse offset

The whole flow, as a script#

Create, then collect
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

live

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

POST/knowledge/portal/ingest

A Q&A pair (question + answer) or free text (content). One of the two is required; sending neither is a 400.

POST/knowledge/portal/upload

Multipart. The file is extracted, chunked and embedded; the file itself is not served back.

POST/knowledge/crawl

Start a crawl. Returns a job_id — the work happens after the response.

GET/knowledge/crawl/{job_id}/status

Poll it. A crawl of a real site takes minutes, not seconds.

A Q&A pair
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#

GET/knowledge

Every source on the account, grouped, with chunk counts and where each came from.

GET/knowledge/documents/{parent_doc_id}/text

What was actually extracted from one document. Worth reading before you trust a PDF.

DELETE/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.

GET/questionnaires?templates=true

The library. Without the flag you get sends instead; the two lists never overlap.

POST/questionnaires

is_template: true writes a library entry. Honoured on create only — an existing row never changes side.

What the agent knows#

GET/knowledge/agent-summary

A plain-language summary of what is in there, generated from the content.

GET/knowledge/quality-score

Coverage and gaps — what a visitor is likely to ask that nothing answers.

GET/knowledge/source-priority

The ranked order used to break ties between sources.

PUT/knowledge/source-priority

Reorder 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

live

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

The whole flow
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-one is 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.

Either of these
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.

bash
curl https://api.reapdat.com/api/v1/communication/readiness \
  -H "X-API-Key: ua_admin_..."
json
{
  "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.

bash
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"
  }'
json
{
  "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.

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

bash
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" }
    }
  }'
json
{
  "id": "7c41e9a8-…",
  "variables": ["name","job_title","new_time","office"],
  "variable_sources": { "…": "as sent" },
  "context_keys": ["job_title"],
  "typed_keys": ["new_time"]
}
SourceFilled by
recipient.name · recipient.phone · recipient.emailThe contact being messaged. Automatic.
pageWhatever calls the send — the values object, or a matching field on the contact.
typedWhoever sends the message. An embedded panel draws a box for it.
fixedThe value you stored. Resolved server-side on every send.
bookingYour 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.

ChannelRequiredBody limit
emailsubject and body100,000 characters
smsbody1,600 characters
whatsappbody, category1,024 characters
voicebody (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.

What you write, and what Meta sees
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 400 rather 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_status updates 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
draftWritten, not submitted. Cannot send.
pendingWith Meta. Usually a day or two. Cannot send.
approvedReady.
rejectedMeta refused it; provider_error carries the reason.
POST/communication/templates/{id}/submit

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

bash
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."
  }'
json
{
  "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 — readiness reports which as voice.sender. Briefs can be written before either exists.
StateWhat it means for a call
sentThe call was placed and the carrier accepted it
deliveredSomebody picked up
failed + no_answerNobody picked up, or the line was busy
failedThe 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.

EndpointFor
POST /audiences/{id}/contactsYour own system, JSON. The integrator's door.
POST /audiences/parse → /importA CSV: parse, confirm the mapping, then store.
POST /audiences/parse-text → /importPasted text, any shape.
POST /audiences/{id}/sync-segmentPeople Reapdat already knows about.
bash
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" }
  }'
json
{
  "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_date and login_link above 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_id and the three consent_* 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 import50,000
Audiences per account100
Your own fields per contact40, each up to 500 characters
CSV upload size10 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.

bash
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"}'
json
{
  "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.

POST/communication/audiences/{id}/sync-segment?segment=leads

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

Sending, and what stops a message#

bash
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
  }'
json
{
  "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.

POST/communication/broadcasts/{id}/preview

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

A preview
{
  "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": "…" } }
}
CheckBlock reasonWhat it means
1 · Consentno_consentNo recorded permission for this channel
2 · Stop listunsubscribedThey opted out
3 · Countrychannel_not_allowedThat channel is not permitted where they are
4 · Rendermissing_variableThe template needs a value this contact lacks
5 · Hour— (held, not blocked)Outside the send window, if you set one
—channel_not_readyThe channel is not set up on this account yet
—template_not_approvedWhatsApp has not approved this template
—invalid_addressNo usable address or number on the contact
—no_reachable_channelNo listed channel could reach this person
after the callno_answerA 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#

bash
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
  }'
json
{
  "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.

The state machine
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.

A replied message
{
  "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.

PATCH/communication/broadcasts/{id}/replies

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

MethodPath
GET/communication/readinessWhich channels can send, and why not. Never gated.
GET/communication/overviewHeadline counts. ?days= (1–365, default 30)
GET/communication/messagesThe account-wide log. ?state= &channel= &q= &limit= &offset=
GET/communication/templates?channel= &include_inactive=
POST/communication/templatesCreate 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}/submitSend to Meta for approval (WhatsApp)
POST/communication/templates/{id}/previewRender against sample values
GET/communication/audiencesList
POST/communication/audiencesCreate
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/parseRead an uploaded CSV. Stores nothing
POST/communication/audiences/parse-textRead pasted text. Stores nothing
POST/communication/audiences/{id}/importPersist mapped rows
POST/communication/audiences/{id}/contactsAdd 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/broadcastsCreate
GET/communication/broadcasts/{id}One, with live counters
POST/communication/broadcasts/{id}/previewThe dry run. Required before sending
POST/communication/broadcasts/{id}/sendFan out and start. 409 without a preview
POST/communication/broadcasts/{id}/cancelStop anything not yet handed to a provider
PATCH/communication/broadcasts/{id}/repliesReply handling on or off for this send
GET/communication/broadcasts/{id}/messages?state= &limit= &offset=
POST/communication/send-oneOne message to one person
POST/communication/suppressOpt somebody out across every audience

Errors, limits and what is not here yet#

StatusWhen
400A template a provider would reject; a body with no usable identifier; an edit that would drop a value you typed
403Outbound messaging is not enabled on this account
404The template, audience, contact or broadcast is not on your account
409Sending without a preview; sending twice; deleting something a running send uses; an edit that collides with another contact
413A 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.