---
title: Keep another system in sync
description: A step-by-step recipe for copying Receptiva calls and leads into a case-management system with the REST API, using updated_since and stable call ids.
sidebarTitle: Sync guide
section: api
order: 30
updated: 2026-10-06
---

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](/docs/api-keys). 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

1. Poll `GET /v1/calls?updated_since=<as_of from your last successful poll>` every few minutes.
2. Follow `next_cursor` until `has_more` is `false`, sending the same filters each time.
3. Save each call under its `call_id`. If you already have that `call_id`, replace your copy.
4. For calls with a `lead_id`, fetch the lead with `GET /v1/leads/{lead_id}` (with its full intake), or with `GET /v1/leads?call_id=<call_id>` (without it).
5. 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 its `lead_id`.
6. When you write a status back, send `expected_status` with the status you last saw, and your own matter id as `external_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

```bash
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 the `as_of` of the first page of each poll, and once the poll has finished without errors, use it as `updated_since` on the next one. Do not use your own server's clock: it can drift from Receptiva's.
- `as_of` is in UTC with a `Z`, 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_id` makes 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. Its `updated_at` moves 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](/docs/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](#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:

```json
{
  "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:

```json
"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:

```bash
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:

```bash
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:

```bash
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:

```bash
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, or `null`.
- `conversation_status`: `complete`, `partial` or `failed` (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), or `null` when there was no conversation with staff.
- `notice`: a sentence to show with the transcript when part of the conversation was not transcribed, otherwise `null`.

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`:

```bash
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](/docs/whats-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:

```bash
curl https://api.receptiva.ai/v1/calls/A7HdLS3aFzA4/recording \
  -H "Authorization: Bearer $RECEPTIVA_API_KEY" -o call.ogg
```

**In 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:

```bash
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 `url` in an `<audio src>` element. The link sends no CORS headers, so a player that loads audio with `fetch()` or `XMLHttpRequest` cannot 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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_id` is 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.
- `status` is optional. Send only `external_id` or `status_reason` to change just those; at least one of the three is required. `expected_status` applies only when you send `status`.
- `null` clears 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.updated` [webhook](/docs/webhooks), whose `changes` lists 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

- `409` with `status_conflict` (a status write): someone else changed the lead. Save the `lead` in the answer, then decide.
- `429`: wait for the seconds in `Retry-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 the `errors` array 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](mailto: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.

```js
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](#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](/docs/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.

## Next

- [REST API overview](/docs/api-overview)
- [API reference](/docs/api-reference)
