This page is a recipe for keeping a case-management system, CRM or spreadsheet in step with your firm's Receptiva calls and leads. It is written so a developer, or an AI coding assistant, can build the integration from it.
You need an API key. The Read set covers everything on this page except writing lead statuses back, which needs Read and update leads. A sync that needs no transcripts, recordings or full lead intake can use Read without transcripts, and update leads: it reads calls and leads and writes statuses back, and nothing more.
The short version#
- Poll
GET /v1/calls?updated_since=<as_of from your last successful poll>every few minutes. - Follow
next_cursoruntilhas_moreisfalse, sending the same filters each time. - Save each call under its
call_id. If you already have thatcall_id, replace your copy. - For calls with a
lead_id, fetch the lead withGET /v1/leads/{lead_id}(with its full intake), or withGET /v1/leads?call_id=<call_id>(without it). - Poll
GET /v1/leads?updated_since=<as_of from your last successful poll>too, to catch lead status changes made outside your system. Save each lead under itslead_id. - When you write a status back, send
expected_statuswith the status you last saw, and your own matter id asexternal_id.
Identify a call by call_id#
Every call has a call_id that never changes. Use it as the key in your own system. The same call can come back on a later poll when its record changes, so write your sync as "insert or replace by call_id", never "always insert".
Poll for changes#
curl "https://api.receptiva.ai/v1/calls?updated_since=2026-10-02T14:00:00Z&limit=100" \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"updated_since returns every call whose record changed at or after that time. Each call carries updated_at.
- Every list response carries
as_of: Receptiva's own clock when the list was read, set half a minute early on purpose. Keep theas_ofof the first page of each poll, and once the poll has finished without errors, use it asupdated_sinceon the next one. Do not use your own server's clock: it can drift from Receptiva's. as_ofis in UTC with aZ, so it goes into the URL as it is. If you send a time with an offset instead, write+as%2B.- The filter is deliberately broad. A call's record also changes when Receptiva finishes processing it shortly after it ends, so you may receive a call again with nothing important changed. That is expected, and replacing by
call_idmakes it harmless. - A call appears a short time after it ends. It is not available while it is in progress. Until Receptiva has finished processing it, the call has
processing_complete: false, and its lead may not exist yet. Itsupdated_atmoves when processing finishes, so the next poll returns it again, complete. - Missed calls, where the caller hung up before the receptionist spoke, are listed too, with
answered: false.
A poll every 5 minutes uses a small fraction of the limit of 60 requests per minute.
What each call gives you#
| Field | Use |
|---|---|
call_id |
Your key for the call |
started_at, duration_s |
When and how long. timezone on the response is the firm's timezone. |
from, callback_number |
The caller's number and the number they asked to be called back on. Match these to a client in your system, or find the caller's other calls with caller_number (below). |
callback_extension |
The extension the caller gave with callback_number, digits only ("12"), or null. Dial callback_number, then the extension. It is never part of callback_number. |
answered |
Whether the receptionist answered. false is a missed call: the caller hung up before the receptionist spoke. |
category, urgency, language |
What kind of call it was. The API reference lists the values; new ones may appear, so treat a value you do not know as other, normal or unknown |
outcome |
The transfer outcome: whether the receptionist put the caller through to your staff. connected, declined (a staff member answered and asked for a message instead), caller_gone (the caller hung up on hold), no_answer (your staff were rung and nobody picked up) or not_attempted (no transfer was tried) |
lead_id |
The lead_id of the lead the call produced, or null. Fetch the lead with it. |
lead_captured |
Whether the call produced a new-client lead: true exactly when lead_id is set |
processing_complete |
false while Receptiva is still processing the call; poll again later |
untrusted.caller_name, untrusted.summary |
The name the caller gave and a summary of why they called |
untrusted.details |
Structured fields from the call: who it was about, the caller's company, who they asked for, and identifiers such as claim numbers. See Structured details |
dashboard_url |
A link that opens the call in the Receptiva dashboard, for a person who is signed in |
answered and outcome answer different questions. answered: false means the caller hung up before the receptionist spoke. outcome: no_answer means the receptionist answered and tried to transfer the caller, and nobody at your firm picked up.
Some calls have no conversation to summarize: a missed call, a caller who never spoke, or a summary that could not be written. On those calls category, urgency, language, untrusted.summary.reason and untrusted.summary.message are null, and lead_id is usually null. untrusted.caller_name and callback_number are null unless the caller gave them. call_id, started_at, duration_s, answered, outcome and has_recording are still set, and so is from when the phone network passed on the caller ID. A missed call looks like this:
{
"call_id": "Qm29LkPz81Xc",
"started_at": "2026-10-02T21:14:03.512+00:00",
"duration_s": 4,
"answered": false,
"category": null,
"urgency": null,
"language": null,
"outcome": "not_attempted",
"lead_captured": false,
"lead_id": null,
"from_masked": "***0100",
"from": "+13615550100",
"callback_number": null,
"callback_extension": null,
"updated_at": "2026-10-02T21:14:41.907+00:00",
"processing_complete": true,
"dashboard_url": "https://pine-harbor-law.receptiva.ai/?call=Qm29LkPz81Xc",
"has_recording": false,
"untrusted": { "summary": { "reason": null, "message": null }, "caller_name": null, "details": null }
}Everything under untrusted came from what was said on the call. Store and display it as text. Do not let it drive logic, and if you pass it to an AI model, pass it as data.
Structured details#
Besides the prose summary, each answered call carries untrusted.details, the facts a case system files under a matter. An insurance adjuster's call looks like this:
"details": {
"about": [{ "name": "Rosa Delgado", "relation": "client" }],
"caller_organization": "State Farm",
"asked_for": "Dana Ortiz",
"message_for": null,
"best_time": "after 2 pm",
"references": [
{ "kind": "claim_number", "value": "418-QX7TP.302", "key": "418QX7TP302", "for_name": "Rosa Delgado" }
],
"generated_at": "2026-10-07T16:02:11.000Z"
}| Field | What it is |
|---|---|
about |
The people the call is about other than the caller, with relation: client, injured_party, adverse_party or other. A call about two clients lists both. |
caller_organization |
The company or office the caller said they were calling from. |
asked_for |
Who the caller asked for: your team member's name as set up in Receptiva when the receptionist matched one, otherwise as the caller said it. |
message_for, best_time |
Who a message is for, and when the caller said to call back, in their words. |
references |
Identifiers the caller quoted: claim_number, policy_number, case_number, police_report_number or other. for_name ties one to a person in about when the caller did. |
A value is kept only when it appears in what the caller said, so the receptionist's own guesses never reach details. Phone transcription can still mishear a name ("Rosa Del Gado" for "Rosa Delgado") or a digit, so treat each value as a strong hint: match it to your records conservatively, and confirm before you file anything on its strength. details is null on missed calls, on calls with nothing to extract, and on calls processed before 2026-10-07. Treat a kind or relation you do not know as other; new ones may be added.
The conversation after a transfer#
When the receptionist put the caller through to someone at your firm, GET /v1/calls/{call_id} also carries untrusted.conversation_summary: an AI-written summary of what the caller and that staff member discussed.
| Field | Use |
|---|---|
staff_name |
Who the caller spoke with |
summary |
What was discussed, in a few sentences. null when no summary could be written, for example when nobody picked up; outcome is then empty and the lists are empty. |
outcome |
How the conversation ended, in a few words |
key_points, next_steps |
Short lines, at most 8 each |
confirmed_details |
Facts stated in the conversation, such as a consult time, each with a label, a value and said_by (caller or staff) |
conversation_status |
complete, or partial / failed when part of the conversation was not transcribed; null when no conversation with staff was recorded (for example nobody picked up) |
covers |
The parts of the call's transcript when the summary was written: intake, briefing, conversation. The summary is about the conversation; the transcript's own covers is authoritative. |
generated_at |
When the summary was written |
It is written from that conversation only. untrusted.summary is still the summary of the caller's call with the receptionist, and the lead is unchanged. conversation_summary is null on calls where nobody was briefed or put through, on calls from before full call records, and on calls made while full call records were off. It is only on one call's detail, never on the calls list, so fetch the call when you want it. It is in place once processing_complete is true.
Fetch several known calls at once#
To refresh calls you already know about, pass up to 50 ids:
curl "https://api.receptiva.ai/v1/calls?ids=A7HdLS3aFzA4,Qm29LkPz81Xc" \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"An id that matches no call for your firm is left out of the result.
Find a caller's earlier calls#
To see everything one person has called about, filter the calls list by the number the call came from:
curl "https://api.receptiva.ai/v1/calls?caller_number=%2B12105550147" \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"caller_number takes E.164 (+12105550147) or a 10-digit US number in any common format ((210) 555-0147, 210-555-0147); a number without + is read as a US number. On calls it matches from, the caller ID. It does not match callback_number, the number a caller asked to be called back on, which may be someone else's phone. To find leads by the number the caller gave, use the same filter on the leads list: GET /v1/leads?caller_number=%2B12105550147. Pages, limit and cursor work as on any list.
Find calls by claim or case number#
reference finds the calls where a caller quoted an identifier:
curl "https://api.receptiva.ai/v1/calls?reference=418-QX7TP.302" \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"It matches details.references[].key: case, spaces and punctuation are ignored, so 418-QX7TP.302, 418 qx7tp 302 and 418QX7TP302 find the same calls. A misheard character does not match. To catch near-misses, fetch the calls you care about (by caller_number, or by date) and compare key values in your own code, conservatively. A value with no letters or digits is a 400 invalid_request.
Transcripts#
Fetch a transcript only when a person needs it:
curl https://api.receptiva.ai/v1/calls/A7HdLS3aFzA4/transcript \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"A transcript has up to three parts, in this order. Each turn says which one it belongs to in segment:
segment |
What it holds |
|---|---|
intake |
The caller and the receptionist |
briefing |
The receptionist briefing a member of your staff before putting the call through, one block for each person the receptionist briefed. briefings lists each block with the person's name, how the attempt ended (outcome), its first turn (start_turn) and its turn_count. |
conversation |
The caller and that staff member, after the call was put through |
Each turn has a speaker (caller, receptionist or staff), speaker_name (the staff member's name, as it appears in your receptionist's settings, on a staff turn; null otherwise), the segment, the text, and start_s, the seconds from the start of the call. start_s is null on calls recorded before turn times were added. Consecutive pieces of speech from the same speaker in the same part are joined into one turn.
Beside the turns, the response says what the transcript holds:
covers: the parts this call has, for example["intake"]or["intake", "briefing", "conversation"].connected_name: who the caller was put through to, ornull.conversation_status:complete,partialorfailed(part or all of the conversation was not transcribed),not_enabled(your firm has full call records turned off, so only the intake is transcribed), ornullwhen there was no conversation with staff.notice: a sentence to show with the transcript when part of the conversation was not transcribed, otherwisenull.
If your integration was built for transcripts with only caller and receptionist turns, handle the new staff value. To keep only the caller's call with the receptionist, pass segment=intake:
curl "https://api.receptiva.ai/v1/calls/A7HdLS3aFzA4/transcript?segment=intake" \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"segment=conversation returns only what the caller and your staff member said. With segment, turn_count and start_turn count that part's turns only, while covers and briefings still describe the whole call (a briefing's start_turn is its place in the whole transcript). For example, with briefings[0].start_turn of 12, ?start_turn=12 (no segment) starts at that briefing; do not combine it with segment=briefing, where start_turn=12 would skip the first 12 briefing turns. Because pieces of speech are now joined into whole turns, an intake can have slightly fewer turns than before. See What's new for the full list of changes.
The transcript pages by turn rather than by cursor. Without limit, one response holds every turn from start_turn (default 0) to the end, and turn_count says how many turns the whole transcript has. To skip turns you already have, pass start_turn, for example ?start_turn=40. For smaller pages, add limit (at most 500 turns): ?limit=50 returns the first 50 turns with has_more: true and next_start_turn: 50, which you pass as start_turn for the next page with the same limit and segment.
Recordings are the caller's call only. The receptionist's briefing to your staff member is included in the transcript as segment: briefing; neither the briefing nor your staff member's side of the conversation is in the recording.
Recordings#
Each call carries has_recording. There are two ways to play one, and both need the calls:recording permission.
From a server. Download the audio with your key. The response is the audio file, Opus audio in an Ogg file (audio/ogg), and Range requests work:
curl https://api.receptiva.ai/v1/calls/A7HdLS3aFzA4/recording \
-H "Authorization: Bearer $RECEPTIVA_API_KEY" -o call.oggIn a browser. A web page must never hold your API key. When a person presses play, have your server ask for a link and hand it to the page's audio player:
curl -X POST https://api.receptiva.ai/v1/calls/A7HdLS3aFzA4/recording-link \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"The response has a url and an expires_at. The link plays that one call, needs no key, and stops working after one hour, or sooner if the key that created it is revoked. Create a link when someone presses play. Do not store links.
Playing a link in a browser:
- Put the
urlin an<audio src>element. The link sends no CORS headers, so a player that loads audio withfetch()orXMLHttpRequestcannot read it. Use the browser's own audio element. - The audio is Ogg Opus. Current Chrome, Edge and Firefox play it. Safari's support depends on the version, so test the browsers your team uses.
- If the page has a Content Security Policy, allow the audio's origin:
media-src https://api.receptiva.ai.
Each call also has a dashboard_url, which opens the call in the Receptiva dashboard for a person who is signed in. Use it when you would rather link out than build a player.
Some calls have no recording. Those answer 404 with the code no_recording.
Leads#
GET /v1/leads lists new-client leads with the callback number (phone, and phone_extension when the caller gave an extension), email, status and the call_id of the call each came from. Save each lead under its lead_id. GET /v1/leads/{lead_id} adds the full intake the receptionist gathered: incident, injuries, treatment, insurance and the case narrative. It needs the leads:narrative permission, which the Read sets have and the without-transcripts sets do not.
A call that produced a lead carries its lead_id. With a key that can read intake, fetch the lead directly:
curl https://api.receptiva.ai/v1/leads/LEAD_ID \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"Without intake, pass the call's call_id to the list instead. A call has at most one lead, so the list holds one lead or none:
curl "https://api.receptiva.ai/v1/leads?call_id=A7HdLS3aFzA4" \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"A lead's status can change after your first copy of it, for example when someone at your firm marks it contacted through the Claude or ChatGPT connector or another API key. To catch those changes, poll the leads list with updated_since, the same way as calls:
curl "https://api.receptiva.ai/v1/leads?updated_since=2026-10-02T14:00:00Z&limit=100" \
-H "Authorization: Bearer $RECEPTIVA_API_KEY"Each lead carries updated_at, which moves on every status change. Like the calls filter, this one is deliberately broad, so replace each lead by lead_id rather than inserting it again. Use the leads list's own as_of as the next leads poll's updated_since.
A lead can disappear: when Receptiva re-processes a call and finds it was not a new-client lead after all, the lead is withdrawn and the lists stop returning it, and the call's lead_id turns null. Your sync must tolerate a lead it saved no longer being returned; mark it withdrawn rather than treating its absence as an error.
In an intake, a yes-or-no detail such as "hospitalized" reads false both when the caller said no and when it never came up. Treat false as "not confirmed".
When your team acts on a lead, write the status back so the dashboard agrees with your system:
curl -X PATCH https://api.receptiva.ai/v1/leads/LEAD_ID \
-H "Authorization: Bearer $RECEPTIVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "contacted", "expected_status": "new"}'The status can be contacted, signed or rejected. Sending the status a lead already has changes nothing and is safe to repeat.
Write your own matter id back#
When your system opens a matter for a lead, store its id on the lead as external_id, in the same request as the status if you like. Add status_reason for a short note on why, up to 500 characters:
curl -X PATCH https://api.receptiva.ai/v1/leads/LEAD_ID \
-H "Authorization: Bearer $RECEPTIVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "signed", "expected_status": "contacted", "external_id": "MAT-2026-0142", "status_reason": "Retainer signed"}'external_idis yours: one line, up to 200 characters, stored as you sent it and returned on the lead. Receptiva never changes it, and re-processing a call never overwrites it.statusis optional. Send onlyexternal_idorstatus_reasonto change just those; at least one of the three is required.expected_statusapplies only when you sendstatus.nullclears a field; a field you leave out is kept.- Find the lead behind a record in your system later with
GET /v1/leads?external_id=MAT-2026-0142, an exact match. - Each change fires a
lead.updatedwebhook, whosechangeslists the fields that changed (status,external_id,status_reason).
Send expected_status with the status you last saw, so you never overwrite a change made elsewhere. If someone has changed the lead since, for example through the Claude or ChatGPT connector, nothing is changed and the API answers 409 with the code status_conflict. The answer's detail names the lead's status now, and its lead member is the lead as it is now. Re-read it, decide whether your change still applies, and send it again with the new expected_status if it does.
Handle errors#
409withstatus_conflict(a status write): someone else changed the lead. Save theleadin the answer, then decide.429: wait for the seconds inRetry-After, then continue.503: retry with a growing delay, for example 5, 15 and then 60 seconds.401: the key was revoked or mistyped. Stop and alert a person. Retrying will not help.400: the request is wrong. Log theerrorsarray and fix the code.
Log the X-Request-Id header of any failed request. It lets us find the request if you email hello@receptiva.ai.
A reference sync loop#
This is the whole recipe in plain JavaScript (Node 18 or later, no libraries). db stands for your own storage. Run syncOnce every few minutes.
const API = "https://api.receptiva.ai";
const KEY = process.env.RECEPTIVA_API_KEY;
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
async function api(method, path, body) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, ...(body ? { "Content-Type": "application/json" } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) throw new Error(`Receptiva key rejected; alert a person (${res.headers.get("x-request-id")})`);
if (res.status === 429) { await sleep(Number(res.headers.get("retry-after") ?? 5)); continue; }
if (res.status === 503 && attempt < 3) { await sleep([5, 15, 60][attempt]); continue; }
return { status: res.status, json: await res.json() };
}
}
// Every page with the SAME filters; returns the items and the first page's as_of.
async function listAll(path, key) {
let cursor = null, asOf = null, items = [];
do {
const sep = path.includes("?") ? "&" : "?";
const { status, json } = await api("GET", path + (cursor ? `${sep}cursor=${encodeURIComponent(cursor)}` : ""));
if (status !== 200) throw new Error(`${json.code}: ${json.detail}`);
asOf ??= json.as_of;
items.push(...json[key]);
cursor = json.has_more ? json.next_cursor : null;
} while (cursor);
return { items, asOf };
}
async function syncOnce(db) {
const since = await db.getWatermark("calls"); // null on the first run
const calls = await listAll(`/v1/calls?limit=100${since ? `&updated_since=${since}` : ""}`, "calls");
for (const call of calls.items) {
await db.upsertCall(call.call_id, call); // insert or replace, never append
if (call.lead_id) {
// GET /v1/leads/{lead_id} returns the full intake (needs leads:narrative).
const { status, json } = await api("GET", `/v1/leads/${call.lead_id}`);
if (status === 200) await db.upsertLead(json.lead.lead_id, { ...json.lead, intake: json.intake });
} else {
// The call came back without a lead: any lead you saved for it was withdrawn.
await db.withdrawLeadsForCall(call.call_id);
}
}
await db.setWatermark("calls", calls.asOf); // only after the whole run succeeded
const leadsSince = await db.getWatermark("leads");
const leads = await listAll(`/v1/leads?limit=100${leadsSince ? `&updated_since=${leadsSince}` : ""}`, "leads");
for (const lead of leads.items) await db.upsertLead(lead.lead_id, lead);
await db.setWatermark("leads", leads.asOf);
}
// Write a status back without overwriting a change made elsewhere.
// matterId: your own id for the lead, stored on it as external_id (optional).
async function setLeadStatus(db, lead, status, matterId) {
const { status: code, json } = await api("PATCH", `/v1/leads/${lead.lead_id}`, {
status,
expected_status: lead.status, // the status we last saw
...(matterId ? { external_id: matterId } : {}),
});
if (code === 409 && json.code === "status_conflict") return db.upsertLead(json.lead.lead_id, json.lead); // re-read: decide again
if (code !== 200) throw new Error(`${json.code}: ${json.detail}`);
await db.upsertLead(json.lead.lead_id, json.lead);
}A 404 for a lead you saved means it was withdrawn (see Leads). Calls also carry processing_complete; a call that is still being processed comes back on a later poll, so the loop needs no special case for it.
Webhooks instead of frequent polling#
Receptiva can notify your system the moment a call is processed or a lead changes. See Webhooks. A webhook carries ids, not caller details, so on each event your system fetches the call or lead from the endpoints on this page and saves it under its id, exactly as a poll would.
Keep the updated_since poll as a safety net, once a day or after downtime. With both in place a missed delivery never leaves your system out of step.