# Totem API documentation > Read-only export of a Totem Studio team: leads, calls, conversations, bookings and a changes feed. Every page of the English edition in navigation order, for API version 1.0a. Index: https://totemstudio.ai/developers/llms.txt --- # Totem API > Read the leads, calls, conversations and bookings of your Totem teams from your own systems, and keep a copy in sync. Source: https://totemstudio.ai/developers The Totem API lets your server read the CRM data of your Totem teams: leads with their place in the pipeline, their origin and their contact permissions, what happened to each lead, calls, conversations and bookings. With it you can: - **Export your CRM** to another CRM, or to files and tables you control. - **Sync a BI tool or a data warehouse**, and report on leads, calls and bookings next to the rest of your business. - **Automate follow-up in your own systems**, for example when a lead reaches a stage, while honoring each lead's contact permissions. The [Quickstart](https://totemstudio.ai/developers/quickstart) takes you from a new key to your first leads in four steps. ## What version 1.0a is - **Read-only.** No endpoint creates or changes anything in Totem. - **Beta.** Available to eligible Totem customers. Create a key in **Settings → Developers**; no invitation is needed. [Conventions](https://totemstudio.ai/developers/conventions#what-beta-means) explains what can change during the Beta. - **Contact details, no content.** The API returns contact details, such as names, email addresses and phone numbers, and the metadata of the CRM: stages, calls, conversations and bookings. It never returns message text, call transcripts, summaries, recordings, notes, free-text answers or custom form fields. Treat every response as personal data. - **Polling, no webhooks.** You keep a copy current by reading the changes feed every 1 to 5 minutes. The [changelog](https://totemstudio.ai/developers/changelog) announces any new capability. - **Administrator-level access.** A key reads everything an administrator of its teams can read, including channels and records marked Private. The administrator who creates the key confirms this. ## What you can read Every request goes to `https://api.totemstudio.ai/v1` over HTTPS, and the API answers in JSON. | Data | Endpoints | |---|---| | Your integration and its teams | [`GET /v1/me`](https://totemstudio.ai/developers/reference-account#me.get), [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list), [`GET /v1/teams/{id}`](https://totemstudio.ai/developers/reference-account#teams.get) | | Team members | [`GET /v1/members`](https://totemstudio.ai/developers/reference-account#members.list) | | Boards, with their stages | [`GET /v1/boards`](https://totemstudio.ai/developers/reference-leads#boards.list) | | Leads, with their attribution and response | [`GET /v1/leads`](https://totemstudio.ai/developers/reference-leads#leads.list), [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/reference-leads#leads.get), [`POST /v1/leads/search`](https://totemstudio.ai/developers/reference-leads#leads.search) | | What happened to a lead | [`GET /v1/leads/{id}/activities`](https://totemstudio.ai/developers/reference-leads#leads.activities) | | Calls | [`GET /v1/calls`](https://totemstudio.ai/developers/reference-calls#calls.list), [`GET /v1/calls/{id}`](https://totemstudio.ai/developers/reference-calls#calls.get) | | Conversations, without their messages | [`GET /v1/conversations`](https://totemstudio.ai/developers/reference-conversations#conversations.list), [`GET /v1/conversations/{id}`](https://totemstudio.ai/developers/reference-conversations#conversations.get) | | Bookable services | [`GET /v1/booking_services`](https://totemstudio.ai/developers/reference-bookings#booking_services.list) | | Bookings | [`GET /v1/bookings`](https://totemstudio.ai/developers/reference-bookings#bookings.list), [`GET /v1/bookings/{id}`](https://totemstudio.ai/developers/reference-bookings#bookings.get) | | Every change, team by team | [`GET /v1/sync/changes`](https://totemstudio.ai/developers/reference-sync#sync.changes) | | Whether the API is up | [`GET /v1/health`](https://totemstudio.ai/developers/reference-account#health) | ## Before you build - **Keep the key on your server.** Never put it in a browser, a mobile app or a public repository. [Authentication](https://totemstudio.ai/developers/authentication) explains where a key may live. - **Apply contact permissions first.** Copy each lead's `contact_permissions` before any of your systems contacts that person. - **Apply removals.** When the changes feed removes a record, delete it from every system you copied it to, as soon as you can. - **Protect what you copy.** [Security and personal data](https://totemstudio.ai/developers/security) explains what Totem protects and what to do with the personal data you receive. - **Test with fictitious data.** [Testing and go-live](https://totemstudio.ai/developers/testing) explains how. ## Next steps - [Quickstart](https://totemstudio.ai/developers/quickstart): make your first requests. - [Keep a copy in sync](https://totemstudio.ai/developers/sync): copy a team with the changes feed and keep the copy current, with a tested receiver to start from. - [Authentication](https://totemstudio.ai/developers/authentication): keys, teams and the `Totem-Team` header. - [Errors](https://totemstudio.ai/developers/errors): what every error code means and what to do about it. - [Conventions](https://totemstudio.ai/developers/conventions): the rules every response follows, before the endpoint reference. - [Security and personal data](https://totemstudio.ai/developers/security): keys, activity records, where the API runs and how to handle the data you copy. --- # Quickstart > Make your first requests to the Totem API in a few minutes. Check your key, find your team, list leads and start a sync. Source: https://totemstudio.ai/developers/quickstart This guide takes you from a new key to your first leads in four steps. ## Before you begin You need: - **A Totem team on Pro or a higher plan**, or with API access agreed with Totem, whose legal setup is saved in [**Privacy & agreements**](https://app.totemstudio.ai/privacy-setup). - **A secret key.** A team administrator opens [**Settings → Developers**](https://app.totemstudio.ai/settings?tab=developers), chooses **Create API key** and confirms read-only access to the team. Totem shows the key once: copy it straight into your server's secret store. - **`curl`**, and [`jq`](https://jqlang.org) for step 2. If **Create API key** is missing or disabled, [Create a key](https://totemstudio.ai/developers/authentication#create-a-key) lists every requirement and what Settings says about each. To follow this guide, load the key into an environment variable without typing it on the command line, so it never stays in your shell history. Run this, paste the key and press Enter; nothing is shown: ```bash read -rs TOTEM_API_KEY && export TOTEM_API_KEY ``` On a server, have your secret store provide `TOTEM_API_KEY` to the process instead. > **Warning:** The key reads administrator-level data. Keep it on your server: never in a browser, a mobile app, a URL or a public repository. ## 1. Check your key Call [`GET /v1/me`](https://totemstudio.ai/developers/reference-account#me.get): ```bash curl https://api.totemstudio.ai/v1/me \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` The answer describes your key: the integration it belongs to, the last four characters of the key, the teams it reads and its permissions. ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` Your answer carries your own values and `"livemode": true`. The examples in these docs are fictitious, so they say `"livemode": false`. The same request from Node.js 22 or later, as an ES module, and from Python 3 with the `requests` library (`pip install requests`), which the samples of the reference use too: ```js [me.mjs] const response = await fetch('https://api.totemstudio.ai/v1/me', { headers: { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }, signal: AbortSignal.timeout(30_000), }) // Read the body as text: a refusal from the firewall in front of the API is not JSON. console.log(response.status, await response.text()) ``` ```python [me.py] import os import requests response = requests.get( "https://api.totemstudio.ai/v1/me", headers={"Authorization": f"Bearer {os.environ['TOTEM_API_KEY']}"}, timeout=30, ) # Print the body as text: a refusal from the firewall in front of the API is not JSON. print(response.status_code, response.text) ``` A `401` means the key is missing, mistyped or no longer valid. [Authentication](https://totemstudio.ai/developers/authentication) lists every case. ## 2. Find your team [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list) lists the teams your key reads: ```bash curl https://api.totemstudio.ai/v1/teams \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```json { "object": "list", "data": [ { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ], "has_more": false, "next_cursor": null } ``` Keep the first team's id for the next steps. If the request fails, the command says so instead of keeping a wrong value: ```bash TOTEM_TEAM_ID=$(curl -sS https://api.totemstudio.ai/v1/teams \ -H "Authorization: Bearer $TOTEM_API_KEY" | jq -er '.data[0].id') \ && export TOTEM_TEAM_ID \ || { unset TOTEM_TEAM_ID; echo 'Step 2 failed: run the curl above to see the error.' >&2; } ``` Requests that read one team's data, such as lists and the changes feed, name the team in the `Totem-Team` header. It only matters when a key reads several teams: a key with one team can leave it out, and a key with several gets `400 team_required` without it. The next steps always send it, so they work for both. > **Note: Just added the team?** > > Totem prepares a team's data for the API when the team is added to an integration. Until that is done, requests for the team answer `409 capture_not_ready`, and **Settings → Developers** shows the team as **Preparing data** instead of **Ready**. Wait a few minutes, then try again; a team with many records takes longer. ## 3. List leads Ask [`GET /v1/leads`](https://totemstudio.ai/developers/reference-leads#leads.list) for the first five leads: ```bash curl "https://api.totemstudio.ai/v1/leads?limit=5" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` The answer is a list: the leads are in `data`. A lead has many more fields than this example shows; the [reference](https://totemstudio.ai/developers/reference-leads#leads.list) lists them all. ```json { "object": "list", "data": [ { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "phone": "+12025550123", "status": "contacted", "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoibGVhZCIsImEiOiJsZWFkX3I3MkVmYlJOVXp1cU9SRkpSME84SHhBd0VYZDkzakdVVFlNYU1NWEtTRjBRYkxTOENpVUtKcEVQMzRMRlFHIiwicyI6ImYzNTI3NzI2MTkwYiJ93iHyRg" } ``` `has_more: true` means there are more leads. Send `next_cursor` back as the `cursor` parameter, with the same filters and the same `Totem-Team`, to read the next page. [Lists and pagination](https://totemstudio.ai/developers/pagination) explains paging and filters. ## 4. Start a sync A list is a snapshot. To keep a copy of a team that stays current, read the changes feed, [`GET /v1/sync/changes`](https://totemstudio.ai/developers/reference-sync#sync.changes). The first request has no cursor and starts the initial sync of the whole team: ```bash curl https://api.totemstudio.ai/v1/sync/changes \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` Each item in `data` is a record of one `kind`, such as a lead, a call or a booking, or a removal that tells you to delete a record. From there, your sync runs a simple loop: 1. Apply every item of the page to your copy. Skip an item whose `version` is not greater than the one you already hold. 2. Once the whole page is saved, store `next_cursor`. 3. While `has_more` is `true`, ask again, sending the stored cursor as `cursor`. 4. When `has_more` is `false`, your copy is current. Keep asking with the stored cursor every 1 to 5 minutes, and store the `next_cursor` of every page, even an empty one. [Keep a copy in sync](https://totemstudio.ai/developers/sync) covers the rest: removals, merges and transfers, crash recovery and a full resync. It also includes a [tested reference client](https://totemstudio.ai/developers/sync#tested-reference-client), in TypeScript, that you can copy into your server. ## Next steps - [TypeScript SDK](https://totemstudio.ai/developers/typescript): use typed methods and automatic pagination from Node.js. - [Keep a copy in sync](https://totemstudio.ai/developers/sync): build a sync that survives restarts. - [Authentication](https://totemstudio.ai/developers/authentication): roll and revoke keys, and limit them to your IP addresses. - [Security and personal data](https://totemstudio.ai/developers/security): what Totem protects, and what to do with the data you copy. - [Errors](https://totemstudio.ai/developers/errors): what each error code means and what to do about it. - [Testing and go-live](https://totemstudio.ai/developers/testing): what to check before your integration goes live. - [Leads reference](https://totemstudio.ai/developers/reference-leads): every field of a lead, and how to search by phone or email. --- # Authentication > Secret keys and where they may live, the Totem-Team header for keys with several teams, IP allowlists, and how to roll or revoke a key. Source: https://totemstudio.ai/developers/authentication ## Secret keys Every request carries a secret key in the `Authorization` header: ```http GET /v1/me HTTP/1.1 Host: api.totemstudio.ai Authorization: Bearer tsk_live_… ``` A secret key starts with `tsk_live_`, followed by a lookup id and the secret itself. Totem shows it once, when it is created, and keeps only a one-way digest, so a lost key cannot be recovered: roll it instead. [**Settings → Developers**](https://app.totemstudio.ai/settings?tab=developers) shows the last four characters of each key so you can tell them apart. A key does not expire on its own: it works until you roll it or revoke it. Keys that start with `tsk_test_` are reserved for future test environments. Today the API refuses them with `401 invalid_credentials`. ### Create a key A team administrator opens [**Settings → Developers**](https://app.totemstudio.ai/settings?tab=developers), chooses **Create API key**, gives the integration a name and confirms read-only access to the current team. **Advanced options** hold the optional description, IP restrictions and, for a family administrator, more teams of the organization ([Teams and the Totem-Team header](https://totemstudio.ai/developers/authentication#teams-and-the-totem-team-header) explains who may manage what). If the administrator has not signed in recently, Totem asks them to sign in again first. Totem then shows the integration's secret key once. Copy it straight into your server's secret store. The API is in **Beta** and available without an invitation. **Create API key** works when: - the person creating the key has the administrator role on the team and a verified email address; - the organization is on Pro or a higher plan, or has API access agreed with Totem; - the team holds data (a billing-only account does not) and is neither disabled nor archived; - the team has completed its legal setup: a team administrator saves its privacy policy, processing purposes and business sector in [**Privacy & agreements**](https://app.totemstudio.ai/privacy-setup); - the team is not, for now, in a sensitive business sector, such as health care. If **Create API key** does not appear, check the role, the email address and that the team is not a billing-only account; when the plan is the reason, Settings says so. The button is disabled once the organization holds 10 active integrations. If Totem refuses a team, Settings names it and says why. Otherwise, [contact support](https://totemstudio.ai/developers/support). ### Where a key may live A key reads administrator-level data, so keep it on a server you control: - in a secret manager, or an environment variable that only the process calling the API can read; - never in a browser, a mobile app, client-side JavaScript or a public repository; - never in a URL, a log line, a command you type in a shell or a support request. The API sends no CORS headers, and it answers `400 secret_key_in_browser` to a request that carries both a secret key and an `Origin` header. If a key has ever been in a web page, a mobile app or a browser extension, treat it as leaked: roll it and tick **This key may have been compromised**, as [Roll and revoke](https://totemstudio.ai/developers/authentication#roll-and-revoke) explains. ### What a key can read Every v1.0a key is read-only and carries the same fixed set of read permissions: there is nothing to choose. [`GET /v1/me`](https://totemstudio.ai/developers/reference-account#me.get) lists them in `scopes`: `teams:read`, `crm:read`, `calls:read`, `messages:read` and `calendar:read`. `messages:read` covers conversation metadata only; no endpoint returns the text of a message. A key reads everything an administrator of its teams can read, including channels and records marked Private. The administrator who creates the integration confirms this before the key is issued. Give an integration only the teams it needs. ## Teams and the Totem-Team header An integration covers one team, or a list of teams of the same organization (a billing account and its sub-teams; in **Settings → Developers**, the access scope **Selected family teams**). The list is explicit: a new team is never added on its own. An administrator adds it, and the administrators who manage the integration, and those of every team it covers, are told. Who may manage an integration depends on what it covers: - A team administrator creates and manages an integration for their own team. - Only a family administrator can create or change an integration that covers several teams: an administrator of the billing account who is also listed on it as a family administrator. The administrators of each team it covers see it in Settings, read-only. - An organization, or a team outside any organization, can hold up to 10 active integrations, counting all of its teams. Revoke one you no longer use to free a place. [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list) lists the teams a key can read, each with an id that starts with `team_`. - `GET /v1/me` and `GET /v1/teams` describe the key and never take the header. - Requests about one record, such as `GET /v1/leads/{id}`, need no header: the record's id already names its team. - Requests that read one team's collection (lists such as `GET /v1/leads`, `GET /v1/boards` or `GET /v1/members`, `POST /v1/leads/search` and `GET /v1/sync/changes`) use the key's only team. When the key covers several teams, name one in the `Totem-Team` header: ```bash curl "https://api.totemstudio.ai/v1/leads?limit=50" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ``` The samples in the API reference send `Totem-Team: $TOTEM_TEAM_ID` on these requests. With a one-team key, leave `TOTEM_TEAM_ID` unset and they leave the header out: cURL drops a header whose value is empty, and the Node.js and Python samples check the variable. A key with one team may send the header too, as long as it names that team. What each answer means for your copy of the team: - Without the header, a key with several teams gets `400 team_required`. A header that is not a team id, or that appears twice, gets `400 invalid_request`. - A team the key does not cover, or that no longer exists, gets `404 team_not_accessible`. Read `GET /v1/teams`: if the team is no longer listed, delete its data and its cursor. - A one-team key covers its team only while the team stays where it was when the key was created: in the same organization, or outside any organization. If the team joins an organization, leaves it or moves to another one, the key gets `404 team_not_accessible` and `GET /v1/teams` no longer lists the team. Revoke the key in **Settings → Developers** and create a new one. - A team that is disabled or archived, or whose API access Totem has paused, gets `403 team_state_restricted`. Keep the team's data and its cursor, skip it for this run and try again later. A disabled team or paused access can come back; an archived team stays restricted unless Totem restores it. While a team is restricted, the changes feed delivers nothing for it, removals included, so if the restriction lasts longer than your retention policy allows, delete your copy. - For now, Totem does not export teams in a sensitive business sector, such as health care. Such a team cannot be added to an integration, and a key that already covers one gets `403 sector_restricted` for it. Skip the team: waiting does not change it. The changes feed delivers nothing for it, removals included, so keep what you already have only as long as your retention policy allows, then delete your copy. ## IP allowlist An integration can be limited to up to 20 IP addresses or ranges in CIDR notation. A request from any other address gets `403 ip_not_allowed`. Set the list in **Settings → Developers**, under **Advanced options** when you create the key or with **Edit integration** later; changing it asks for a recent sign-in. - The API is reachable over IPv4 only, so list the public IPv4 addresses your requests leave from. An IPv6 entry never matches today. - Write one address or range per line. A range must start at its network address, such as `203.0.113.0/24`, and `/0` is refused. An empty list allows any address. - If your platform has no fixed outgoing address, as with many serverless hosts, send its traffic through a static IP first. - After you save the list, call `GET /v1/me` from the deployed server to check it. Totem checks the address only after the key, so a wrong key gets `401 invalid_credentials` wherever it comes from. ## Roll and revoke **Roll** a key with **Roll key** to replace it. Totem issues a new key and keeps the old one working for the time you choose in the dialog: 1 hour, 24 hours (the default) or 7 days. Deploy the new key within that time and nothing stops. When that time is over, the old key answers `401 key_expired`. Rolling again revokes any old key still in its grace period, and the dialog asks you to confirm it. To stop the old key at once, tick **This key may have been compromised** when you roll, or choose **End old key now** later. The old key then answers `401 invalid_credentials`. **If a key may have leaked**, roll it and tick **This key may have been compromised**: the old key is revoked at once, with no grace period. Then tell us, as [Support](https://totemstudio.ai/developers/support) explains. **Revoke** an integration with **Revoke access** to stop all of its keys. A revoked key stops working within 5 seconds, and revoking cannot be undone. The data you already copied stays in your systems and receives no more removals from Totem: delete it, or keep it only as long as the customer's own rules allow. [Security and personal data](https://totemstudio.ai/developers/security#personal-data-you-receive) explains what to do with it. Creating or rolling a key, adding a team and changing the IP allowlist ask the administrator to sign in again if they have not done so recently. Every change appears in the integration's **Recent activity**, and the administrators who manage the integration, and those of every team it covers, are told about each one, in Totem and by email. Revoking and **End old key now** never ask for a sign-in: anyone allowed to manage the integration can stop it at once. ## Failed attempts Totem counts failed attempts: requests with a key that is missing, malformed, unknown or wrong. After as few as 60 failures from one IP address in a minute, or 20 with one key, Totem can lock that address or that key for 600 seconds. While it is locked, requests get `429 rate_limited` with `Retry-After` set to the full lock length; the lock may end sooner. A revoked or expired key, an integration that is no longer active and a request from outside the allowlist are not counted. While a key is locked, requests with its real secret keep working: only guesses are refused. While an address is locked, every request from it is refused. If you are locked out, fix the key first, then wait for `Retry-After` before you try again. Many requests without a well-formed key from one address can also make the firewall in front of the API answer `429` itself, with no `Retry-After` and no JSON body, for up to 10 minutes. Stop the loop, fix the header, then retry with exponential backoff, as [Errors](https://totemstudio.ai/developers/errors) explains. ## Authentication errors Every code links to its full entry on the [Errors](https://totemstudio.ai/developers/errors) page. | Status | `code` | What to do | |---|---|---| | 400 | [`secret_key_in_browser`](https://totemstudio.ai/developers/errors#secret_key_in_browser) | The request came from a browser. Call the API from your server, and if the key was ever in a web page or an app, roll it as compromised. | | 400 | [`team_required`](https://totemstudio.ai/developers/errors#team_required) | The key covers several teams. Send `Totem-Team`. | | 401 | [`invalid_credentials`](https://totemstudio.ai/developers/errors#invalid_credentials) | The key is missing, malformed, unknown, wrong or revoked. Check the header and the key. | | 401 | [`key_expired`](https://totemstudio.ai/developers/errors#key_expired) | The key was rolled and its grace period is over. Use the new key. | | 403 | [`ip_not_allowed`](https://totemstudio.ai/developers/errors#ip_not_allowed) | The request came from an address outside the integration's allowlist. The API is reachable over IPv4 only. | | 403 | [`plan_not_entitled`](https://totemstudio.ai/developers/errors#plan_not_entitled) | The organization's plan does not include the API. Keep your data and talk to the customer; the feed delivers no removals until access returns, so if that lasts longer than your retention policy allows, delete your copy. | | 403 | [`insufficient_scope`](https://totemstudio.ai/developers/errors#insufficient_scope) | The key lacks a permission this endpoint needs. Every v1.0a key has the same permissions, so tell us, with the request id. | | 403 | [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted) | The team is disabled, archived or paused. Keep its data and its cursor, skip it for this run and try again later; if the restriction lasts longer than your retention policy allows, delete your copy. | | 403 | [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted) | For now, Totem does not export teams in a sensitive sector, such as health care. Skip the team: waiting does not change it. The feed delivers no removals for it, so keep what you have only as long as your retention policy allows, then delete your copy. | | 404 | [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible) | The key does not cover this team. If `GET /v1/teams` no longer lists it, delete its data and its cursor. | | 429 | [`rate_limited`](https://totemstudio.ai/developers/errors#rate_limited) | Too many failed attempts, or too many requests. Wait for the seconds in `Retry-After`. A `429` without a JSON body comes from the firewall: back off exponentially. | --- # TypeScript SDK > Use the official TypeScript client to read your Totem data, paginate lists and handle API errors from your server. Source: https://totemstudio.ai/developers/typescript The official TypeScript SDK, `@totemstudioai/sdk`, covers every endpoint of the read-only **Beta** API. It runs on your server with Node.js 22.12 or later. A team administrator creates its key in [**Settings → Developers**](https://app.totemstudio.ai/settings?tab=developers); public API access is included from Pro. ## Install the TypeScript SDK Install the release tarball from the [official GitHub repository](https://github.com/kiketordera/totem-studio-typescript). It contains the built package, so it needs no build step and works with install scripts disabled. Download it with its checksum, check it, then install the file you checked: ```bash curl -fLO https://github.com/kiketordera/totem-studio-typescript/releases/download/v0.1.0-beta.2/totemstudioai-sdk-0.1.0-beta.2.tgz curl -fLO https://github.com/kiketordera/totem-studio-typescript/releases/download/v0.1.0-beta.2/totemstudioai-sdk-0.1.0-beta.2.tgz.sha256 shasum -a 256 -c totemstudioai-sdk-0.1.0-beta.2.tgz.sha256 npm install ./totemstudioai-sdk-0.1.0-beta.2.tgz ``` The checksum shows that your download is complete and unchanged; it comes from the same [release page](https://github.com/kiketordera/totem-studio-typescript/releases) as the tarball, so it does not prove who built the file. npm records the dependency as a `file:` path to that tarball, so keep the tarball where you installed it from. You can also install the Git tag with `npm install github:kiketordera/totem-studio-typescript#v0.1.0-beta.2`, which builds the package during installation and therefore needs install scripts enabled. Either way the package is imported as `@totemstudioai/sdk`. It is not published on the npm registry yet, so a package with that name on npm is not Totem's: never give it your key, and install the SDK only in one of the two ways above. Keep your key in your server's secret store and provide it as `TOTEM_API_KEY`. Never bundle this client or its key into a browser or mobile app. ## Make a typed request The examples on this page are ES modules and use `await` at the top level. In a CommonJS project, put them inside an `async` function. ```ts import { TotemClient } from '@totemstudioai/sdk' const apiKey = process.env.TOTEM_API_KEY if (!apiKey) throw new Error('Set TOTEM_API_KEY on your server') const client = new TotemClient({ apiKey }) const integration = await client.me.get() const teams = await client.teams.list() console.log(integration.id, teams.data.map(team => team.id)) ``` For a key that reads several teams, select one with the client's `team` option. You can also pass `team` per request on the routes that read one team's collection: ```ts const team = process.env.TOTEM_TEAM_ID if (!team) throw new Error('Set TOTEM_TEAM_ID on your server') const client = new TotemClient({ apiKey, team }) const page = await client.leads.list({ limit: 100 }) const matching = await client.leads.search({ email: 'alex.rivera@example.com' }) console.log(page.data.length, matching.data.length) ``` A route that reads one record by its id, such as `client.leads.get(id)`, takes the team from the id and has no `team` option: compare the record's `team` with the one you expect. The client supplies `Authorization` and `Totem-Team`, encodes filters and returns typed response objects. Field names stay the same as the API's JSON. Fields with a fixed set of values are typed so that a value added later still compiles, because [enums can grow](https://totemstudio.ai/developers/conventions). ## Read every page with the SDK Use `items` to read a list without managing its page cursors: ```ts for await (const lead of client.items('leads.list', { query: { limit: 100 } })) { console.log(lead.id, lead.version) } ``` Use `pages` when you need the whole page envelope. It stops once `has_more` is `false`; it is not a scheduler. `timeoutMs` applies to each page; pass `signal: AbortSignal.timeout(ms)` to bound a whole traversal. Read the changes feed with `pages` or `client.sync.changes({ cursor, limit: 100 })`, never with `items`: apply each page before saving its `next_cursor`, including on an empty page with `has_more: false`. A `cursor` that is `undefined` or `null` starts from the beginning. Keep one cursor per team and resume polling every 1 to 5 minutes. Use `isFeedRemoval(item)` to tell a removal from a record's current state, and apply an item only when its `version` is greater than the one you hold. [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains removals, versions and full resync; the SDK does not store your data or cursors for you. > **Note: SDK or reference client?** > > Use the SDK for typed requests, lookups and pagination. To keep a copy of a team, start from the [tested reference client](https://totemstudio.ai/developers/sync#tested-reference-client) of the sync guide, which implements the tombstones, the mark and sweep of a full resync and one outcome per team, or follow the SDK's [`examples/sync.ts`](https://github.com/kiketordera/totem-studio-typescript/blob/main/examples/sync.ts), which syncs one team and leaves the full resync to your store. ## SDK errors and response metadata ```ts import { TotemApiError, TotemError } from '@totemstudioai/sdk' try { const response = await client.requestWithResponse('me.get') console.log(response.requestId, response.data.id) } catch (error) { if (error instanceof TotemApiError) { console.error(error.status, error.code, error.action, error.param, error.requestId) } else if (error instanceof TotemError) { console.error(error.name) } throw error } ``` Every error the SDK raises for a request it sent extends `TotemError`: - `TotemApiError` for an error the API answered. It exposes the HTTP status, the API error `code`, its recommended `action`, the `param` at fault, `title`, the request id and `retryAfterMs` when supplied. - `TotemProtocolError` for a response the contract does not allow, such as a successful answer whose body is not JSON, a list page without `data`, or a missing or repeated cursor during `pages()` or `items()`. - `TotemConnectionError` for a network failure that retries could not recover, and `TotemTimeoutError` for the request's deadline. Invalid options or arguments, such as an empty `apiKey` or `team`, throw a plain `TypeError` before any request is sent, and your own `signal`'s abort reason is rethrown unchanged. Branch on `action`, the SDK's grouping of error codes: `fix_request`, `check_credentials`, `resync`, `wait_capture`, `drop_team`, `keep_and_retry`, `retry_after`, `backoff` or `contact_support`. The exported `errorCatalogue` maps every documented code to its action, and the [error catalogue](https://totemstudio.ai/developers/errors) explains each code; handle an unknown code by its HTTP status. On the changes feed, `action === 'resync'` (`resync_required` or `sync_cursor_expired`) means a full resync; `capture_not_ready` means keep your data and cursor and try again later; `team_not_accessible` means read `GET /v1/teams` again and drop the team only if it is no longer listed. Without the SDK, branch on the response's `code`. Keep the request id for [support](https://totemstudio.ai/developers/support). The client makes bounded, jittered retries for temporary failures and respects `Retry-After`. A wait beyond `maxRetryDelayMs`, or beyond the time left before the request's deadline, is returned to your code at once, rather than shortened. Use `timeoutMs`, `maxRetries`, `maxRetryDelayMs` and `signal` to fit your job's deadline. Authentication, authorization and invalid-request errors need a correction, not a retry loop. ## Verify your SDK integration Inject a `fetch` implementation through `new TotemClient({ apiKey, fetch })` for deterministic HTTP tests. Test your receiver's version handling, removals and cursor persistence separately, then run real requests with your own key. [Testing and go-live](https://totemstudio.ai/developers/testing) lists the behavior to verify; a mocked response does not prove a live key or server works. --- # Keep a copy in sync > Copy a team with the changes feed and keep the copy current. Apply pages safely, recover from errors, and start from a reference client that Totem tests. Source: https://totemstudio.ai/developers/sync The changes feed, [`GET /v1/sync/changes`](https://totemstudio.ai/developers/reference-sync#sync.changes), is how you keep your own copy of a team. Your first request copies every record. After that, each request returns only what changed since the last one, so a sync every few minutes is cheap and never misses a change. 1. Start a team without a cursor: your first request to GET /v1/sync/changes begins its initial sync. 2. Call GET /v1/sync/changes with the team in Totem-Team and your saved cursor. 3. Apply the page: skip any item whose version you already hold, store each record, and delete each removed record while keeping its version as a tombstone. 4. Save next_cursor, only after the whole page is stored. 5. If has_more is true, ask again at once. If it is false, you are up to date: wait 1 to 5 minutes, then ask again with the saved cursor. 6. If the request fails: - `410 sync_cursor_expired` or `409 resync_required`: Full resync: forget the versions, then mark and sweep. - `409 capture_not_ready`: Totem is still preparing the team. Retry in a few minutes. - `403`: Keep the data and the cursor. Try again later. - `404 team_not_accessible`: Confirm with GET /v1/teams, then drop the team. - `429`: Wait for Retry-After, then retry the same cursor. - `5xx`: Retry with exponential backoff. Never save a cursor after an error. ## How the feed works There is one feed per team. A key that reads several teams names the team in the [`Totem-Team` header](https://totemstudio.ai/developers/authentication#teams-and-the-totem-team-header) and keeps one cursor per team. Each item of the feed is the current state of one record, of one `kind`: - `lead`, `lead_attribution` and `lead_response`: a lead and its two facets; - `activity`: a change of a lead's stage, status or assigned members; - `call`, `conversation` and `booking`; - `board`, with its stages inside it. A lead's attribution and response travel as kinds of their own, with the lead's id and a `version` of their own. That is why a new message changes the lead's response without changing the lead. Members and bookable services are not in the feed: you [read them separately](https://totemstudio.ai/developers/sync#refresh-boards-members-and-services). Items come in the order Totem recorded the changes, oldest first. An item is never a diff: it carries the whole record as it is now. ### Records and removals Most items carry the record under `object`. It is the same object the list endpoints return, shortened here: ```json { "kind": "call", "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "version": 2, "object": { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "direction": "inbound", "outcome": "answered_ai", "version": 2 } } ``` A **removal** carries `removed` instead of `object`. It tells you to delete the record: ```json { "kind": "call", "id": "call_m1pf4APjJWVi3Z7ulyNaW8y3lUi7V", "version": 5, "removed": { "reason": "deleted" } } ``` `reason` is `erased` when the record was erased: for privacy, for example at the person's request, or because someone permanently deleted a lead, a call or a conversation in Totem, which always erases it. It is `deleted` when the record went away in any other way, for example when it no longer belongs to the team's API data, or when Totem now names it with a new id. Handle both the same way, as [Apply a page](https://totemstudio.ai/developers/sync#apply-a-page) explains. A record that comes back is a new record with a new id ([Conventions](https://totemstudio.ai/developers/conventions#ids)), so match people across ids by phone or email with [search](https://totemstudio.ai/developers/reference-leads#leads.search), never by id. ## Plan your storage A copy built this way can apply every page safely, whatever order the records arrive in: - **One row per record**, keyed by team, `kind` and `id`, with its `version`. A lead and its two facets share an id, so the `kind` is part of the key. - **One tombstone per removal**: the `kind`, `id` and `version` of a record you deleted, without its data. - **One cursor per team**, saved together with the page it follows when your database allows it. - **Boards, members and bookable services** in lists of their own, replaced as a whole each time you read them. - **No foreign keys between records while you apply pages.** A record can name another that has not reached you yet, such as a lead naming a stage of a board, or an activity naming its lead. Then run one sync per key every 1 to 5 minutes, and watch for a team that keeps being skipped or postponed, or whose cursor has not been saved for a day. ## Run the initial sync Your first request for a team has no cursor. It starts the **initial sync**: the feed pages through every record of the team, oldest change first. ```bash curl "https://api.totemstudio.ai/v1/sync/changes?limit=100" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` Apply the page, store its `next_cursor`, and ask again with `cursor` set to it. Repeat while `has_more` is `true`. When `has_more` is `false`, your copy is complete. Ask for the largest page, 100 items, so the initial sync takes as few requests as possible. Two things are normal during an initial sync. A record that changes while it runs comes again later, with a higher version. And after a retry, you can receive the same page twice. The version check in the next section makes both harmless. ## Apply a page For each item of the page, in order: 1. **Skip what you already have.** If you hold this `kind` and `id` with the same or a higher `version`, skip the item. Compare with the versions of your tombstones too. 2. **Store a record.** If the item has `object`, insert your copy or replace it. For a lead, store its `contact_permissions` before anything else can act on it. 3. **Apply a removal.** If the item has `removed`, delete your copy, but keep its `kind`, `id` and `version` as a **tombstone**. When the removed record is a lead, also delete its attribution, its response and its activities, and ignore any of them that arrives later. > **Warning: Keep the tombstone** > > Without the tombstone, step 1 has nothing to compare with, and an older copy of the record, delivered again by a replayed page, would bring it back. For a record erased at a person's request, that is a privacy incident, not a sync glitch. ## Save the cursor Store `next_cursor` only after the whole page is saved. If your process stops in between, the next run reads the same page again, and step 1 skips what you already applied. When your database allows it, save the page and its cursor in one transaction. A cursor is opaque: store it exactly as you received it, and never build or edit one. It belongs to one team. Sent with another team, it gets `400 invalid_request`. A cursor expires 30 days after the point in the feed it reached. While an initial sync is still running, that age counts from when the initial sync started. A team you poll regularly never reaches it. ## Poll for changes Once `has_more` is `false`, ask again with your stored cursor every 1 to 5 minutes. When nothing has changed, you get an empty page with a new `next_cursor`. Store it too: an empty page moves the cursor forward, so the cursor of a quiet team never grows old. The feed holds back the last 10 seconds of changes, so that a change still being written is never skipped. A change shows up in the feed shortly after it happens in Totem. Run at most one sync process per key and team. Two processes applying pages of the same team can save their cursors out of order. ## Removals, merges and transfers ### Removals Apply a removal as soon as you receive it: delete the record from your copy and from every system you copied it to. Removals stay in the feed for 45 days, longer than a cursor lives, so a sync that keeps reading the feed successfully never misses one. While the API refuses a team, it delivers nothing for that team, removals included; [Recover from errors](https://totemstudio.ai/developers/sync#recover-from-errors) says what to do then. When a lead is removed, its attribution, response and activities are removed with it, and the feed delivers their removals too. What happens to the lead's other records depends on the `reason`: - **`erased`**, which includes a lead deleted permanently in Totem: its calls and conversations are erased too, and so are the archived leads whose `merged_into` names it, with their own calls and conversations. Each record arrives as a removal of its own, with `reason: "erased"`. Its bookings are not removed: they arrive again with `lead` set to `null`. - **`deleted`**: its calls, conversations and bookings are not removed. They arrive again with `lead` set to `null`. ### Merges When two leads of the same person are merged in Totem, the lead that is not kept is archived, not removed. Its `archive_reason` is `merged`, and its `merged_into` names the lead that was kept. Link your copy to that lead instead of creating the person twice. The lead that is kept also takes the stricter contact permissions of the two. `merged_into` is `null` when the lead it named no longer exists. ### Transfers When a lead moves to another team of the same organization, the original lead is archived with `archive_reason` set to `family_transfer`, and `transferred_to` says where it went: - `transferred_to.team` is always set. It can be a team your key does not read. - `transferred_to.lead` is the lead in that team, or `null` when Totem cannot name it. If your key reads the destination team, the new lead arrives in that team's feed. If it does not, [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/reference-leads#leads.get) on the destination lead answers `404 resource_missing`. Keep the archived lead as the record of where the person went, and do not guess. ## Contact permissions first Before any of your systems contacts a lead, store its `contact_permissions` and apply them: - `do_not_contact: true`: the person asked never to be contacted. Stop every channel. - `email_opt_out: true`: the person unsubscribed from marketing and follow-up email. - `whatsapp_consent: false`: the person refused WhatsApp. `null` means nobody recorded an answer, so never treat it as consent. A lead in the feed is data, not permission to contact the person. The legal basis for contacting someone stays your responsibility. The API is read-only, so Totem cannot learn about an opt-out recorded in your systems. When a person opts out there, make sure it is also recorded on the lead in Totem, so that Totem stops contacting them too. ## Recover from errors When a request for a page fails, do not save a cursor. The next request with the same cursor asks for the same page again. | Answer | What it means | What to do | |---|---|---| | `410 sync_cursor_expired` | The cursor is more than 30 days old. | Run a [full resync](https://totemstudio.ai/developers/sync#full-resync). | | `409 resync_required` | Totem cannot continue from this cursor, for example after restoring a backup. | Run a [full resync](https://totemstudio.ai/developers/sync#full-resync). | | `409 capture_not_ready` | Totem is still preparing this team's data. It happens once, when a team is first added to a key. | Keep everything and try again in a few minutes. Never resync for it. The key's other teams keep syncing. | | `403` | Access is refused for now: for example, the team is restricted (`team_state_restricted`) or the endpoint is switched off (`capability_disabled`). | Keep the team's data and cursor, and skip the team in this run. The [Errors](https://totemstudio.ai/developers/errors) page says, code by code, whether waiting helps. `plan_not_entitled` refuses the whole key, so the run stops at `GET /v1/teams`. While access is refused, the feed delivers nothing for the team, removals included: if the refusal lasts longer than your retention policy allows, delete the team's copy. | | `404 team_not_accessible` | The key may no longer read this team. | Read [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list). If the team is not listed, delete its data and its cursor. If it is, keep everything and try again on the next run. | | `400 invalid_request` | The cursor was changed, or it belongs to another team. | Use the cursor you stored for this team. | | `429` | You reached the [rate limit or the daily quota](https://totemstudio.ai/developers/rate-limits). | Wait the seconds in `Retry-After`, then retry with the same cursor. A `429` from the firewall has no `Retry-After`: back off exponentially. | | `5xx` | Something failed inside Totem. | Retry with exponential backoff. | Every code is explained on the [Errors](https://totemstudio.ai/developers/errors) page. ### Full resync A full resync rebuilds your copy of one team, by **mark and sweep**: 1. Forget the versions and the cursor you stored for the team. Keep the records themselves: your copy stays usable while the resync runs. 2. Run the initial sync again, without a cursor. Apply each page as usual, and mark every record the feed delivers. 3. When `has_more` is `false`, delete every record of the team that you did not mark. Store the marks durably. If the process stops halfway, the next run must continue the same resync, not sweep with half the marks. Forgetting the versions first matters too: after a restore, the feed can deliver lower versions than the ones you held. ## When teams change Read [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list) at the start of every run. It lists every team your key reads, including teams that are restricted for now. - A team you have not synced yet: run its initial sync. - A team you hold that is no longer listed: it has left the key. Delete its data and its cursor. - A listed team that answers `403`: keep it, as above. ## Refresh boards, members and services Members and bookable services are not in the feed. Boards are, but a lead can name a new stage before the change to its board reaches you. So read all three: - when you start syncing a team; - when an item names a board, stage, member or service you do not know; - at least once a day. A record can keep naming a member who left the team, or a board, stage or service that was deleted. If a refresh does not find an id, remember it and do not refresh for it again before the daily refresh. [`GET /v1/boards`](https://totemstudio.ai/developers/reference-leads#boards.list) pages like any list. [`GET /v1/members`](https://totemstudio.ai/developers/reference-account#members.list) and [`GET /v1/booking_services`](https://totemstudio.ai/developers/reference-bookings#booking_services.list) return the whole list in one response and take no `limit` or `cursor`. Replace your copy of each list as a whole. A member who leaves the team, or a service that is deleted, drops out of its list while older records keep naming its id: if you show names for older records, keep the name from an earlier read. Disabled members and services stay listed, with nothing that marks them as disabled. ## Pace A key reads up to 2,000 items a minute, so the initial sync of a large team takes a while. [Rate limits](https://totemstudio.ai/developers/rate-limits) has the numbers, an estimate of how long an initial sync takes, and why the numbers are approximate during the Beta. When you get a `429`, wait for `Retry-After` and continue with the same cursor: nothing is lost. The limits belong to the key, so a `429` pauses every team the key reads, not just the one you were syncing. ## Tested reference client The TypeScript below is a complete sync client that does everything this page describes. Totem runs it in its own tests against a simulated feed, with crashes, replayed pages, removals, resyncs and errors; the code shown here is that exact file. It has no dependencies and needs only `fetch`: compile it with your TypeScript build, or run it directly on Node.js 22.18 or later. Copy it to your server and implement `SyncStore` on your database. > **Note: Reference client or SDK?** > > For a sync, start from this client: it implements the tombstones, the mark and sweep of a full resync and one outcome per team. The [TypeScript SDK](https://totemstudio.ai/developers/typescript) gives typed requests and pagination; its own sync example handles one team and leaves the [full resync](https://totemstudio.ai/developers/sync#full-resync) to your store, so implement it as this page explains. Run it from a scheduled job every 1 to 5 minutes, one run per key at a time. The example is an ES module; under Node.js module resolution, import the file with the `.js` extension your build emits, or with `.ts` when Node.js runs the TypeScript directly: ```ts import { syncKey } from './syncTeam.js' const outcomes = await syncKey({ key: process.env.TOTEM_API_KEY!, store }) ``` The key must never reach a browser. Every `SyncStore` method must write durably before its promise resolves, and writes for one team must not run in parallel. ### Options and outcomes `syncKey` reads `GET /v1/teams`, deletes the teams that left the key, and syncs every listed team in turn. It returns one outcome per team: - `synced`: the team is caught up and its cursor is saved. - `partial`: the team read `maxPagesPerTeam` pages and is not caught up yet. Its cursor is saved, and the next run continues from it. - `dropped`: the team left the key, and its copy and cursor are deleted. - `skipped`, with the `code`: the team cannot be read right now (`403`, `capture_not_ready`, or a `team_not_accessible` that `GET /v1/teams` does not confirm). Its copy and cursor are kept, and the next run tries again. - `retry_later`, with the `code` and `retryAt` (epoch milliseconds): the API asked for a longer wait than this run allows, or a `429` or `5xx` was still failing after `maxRetries` retries. The copy and cursor are kept. Do not sync that team again before `retryAt`. A `429` belongs to the key, not to one team, so once one sends the key away, every team this run has not reached yet is reported `retry_later` with the same `code` and `retryAt`, without a request. A team that is restricted, still being prepared or gone never stops the others; a `429` this run cannot wait out pauses the whole key until `retryAt`. `syncKey` throws when the key itself is refused, when `GET /v1/teams` cannot be read, or on an error that points to a bug rather than to the team: a `400`, an unexpected `404`, a feed that still asks for a full resync right after one, a list or feed that repeats a cursor or leaves one out, or a network failure. No cursor is lost then either; when the error carries `retryAt`, schedule the next run for that time. Besides `key` and `store`, it takes: - `maxWaitMs`: the longest it sleeps in one go, in milliseconds. The default is 60,000. A longer `Retry-After`, such as the wait for the daily quota to reset at 00:00 UTC, ends the run with `retry_later` instead of keeping your process asleep. - `maxRetries`: how many times one request is retried after a `429` or a `5xx`. The default is 3. - `maxPagesPerTeam`: the most feed pages one team reads in one run. There is no limit by default. With a limit, a long initial sync stops with `partial`, the key's other teams are synced, and the next run continues from the saved cursor. - `fetch`, `baseUrl`, `sleep` and `now`: replace them in your tests. ```ts title="syncTeam.ts" /** * Reference receiver for the Totem changes feed. No dependencies: it needs * only `fetch`. Compile it with your TypeScript build, or run it directly on * Node.js 22.18 or later. * * It keeps a copy of every team a key reads, safely across crashes: * - Run it on your server only. The key is a secret and must never reach a browser. * - Run one `syncKey` per key at a time, every 1 to 5 minutes. * - Persist the `SyncStore` in your own database. `MemorySyncStore` * (memoryStore.ts) loses everything on restart and is for tests only. * - Each page is applied before its cursor is saved, so a crash in between * replays the page, and the version check makes the replay harmless. * - One team's trouble never stops the others: a team that cannot be read * right now keeps its copy and its cursor, and the next run tries again. * - It stops instead of looping if the API ever repeats a cursor or sends * none, before saving it, so a fault on either side cannot spend the key's * daily quota. */ export interface FeedItem { kind: string id: string version: number object?: Record removed?: { reason: string } } export interface FeedPage { object: 'list' data: FeedItem[] has_more: boolean next_cursor: string } interface ListPage { data: unknown[] has_more: boolean next_cursor: string | null } /** Your persistence. Every method must be durable before its promise resolves. */ export interface SyncStore { teamIds(): Promise ensureTeam(teamId: string): Promise cursor(teamId: string): Promise /** The version you hold for this record, removals included; 0 when you hold none. */ version(teamId: string, kind: string, id: string): Promise setContactPermissions(teamId: string, leadId: string, permissions: Record): Promise upsert(teamId: string, item: FeedItem): Promise /** * Delete the record but keep its version (a tombstone). Removing a lead also * deletes its attribution, response and activities. */ remove(teamId: string, item: FeedItem): Promise saveCursor(teamId: string, cursor: string): Promise /** Start a full resync: forget the team's versions and cursor, keep its records, clear the seen set. */ startResync(teamId: string): Promise /** Remember that the feed still delivers this record. Must survive a restart until the resync finishes. */ markSeen(teamId: string, item: FeedItem): Promise /** End a full resync: delete the team's records the feed did not deliver. A no-op when no resync is running. */ finishResync(teamId: string): Promise /** Delete everything you hold for the team, cursor included. */ dropTeam(teamId: string): Promise dictionariesAt(teamId: string): Promise saveDictionaries(teamId: string, dictionaries: Record, at: number): Promise /** * True when the item names a board, stage, member or service that is neither * in your dictionaries nor remembered as unresolved (`rememberUnresolved`). */ hasUnknownReferences(teamId: string, item: FeedItem): Promise /** * Called for an item that still names unknown ids right after the * dictionaries were refreshed. A record can keep naming a member who left * the team, or a board, stage or service that was deleted: remember those * ids so they never cause another refresh. The daily refresh still picks * up any that come back. */ rememberUnresolved(teamId: string, item: FeedItem): Promise } export interface SyncOptions { /** A secret key (`tsk_live_…`). */ key: string store: SyncStore fetch?: typeof globalThis.fetch baseUrl?: string sleep?: (ms: number) => Promise now?: () => number /** How many times one request is retried after a `429` or a `5xx`. Default 3. */ maxRetries?: number /** * The longest this process waits in one go, in milliseconds. Default 60 000. * A longer `Retry-After` (the daily quota resets at 00:00 UTC) ends that * team's run with `retryAt` instead of keeping the process asleep. */ maxWaitMs?: number /** * The most feed pages one team reads in one run. No limit by default. A * team's initial sync can take hours: with a limit, it stops with `partial` * after that many pages, the key's other teams are synced, and the next run * continues from the saved cursor. */ maxPagesPerTeam?: number } /** What happened to one team in one `syncKey` run. */ export type TeamOutcome = /** Caught up; the cursor is saved. */ | { team: string, status: 'synced' } /** Read `maxPagesPerTeam` pages and not caught up yet; the cursor is saved and the next run continues. */ | { team: string, status: 'partial' } /** The team left the key: its copy and cursor are deleted. */ | { team: string, status: 'dropped' } /** Not readable right now (`403`, `capture_not_ready`, unconfirmed `team_not_accessible`); copy and cursor kept. */ | { team: string, status: 'skipped', code: string } /** * Asked to wait longer than `maxWaitMs`, or still failing after `maxRetries`; * copy and cursor kept. Do not call before `retryAt` (epoch milliseconds). */ | { team: string, status: 'retry_later', code: string, retryAt: number } /** An error answer from the API. `retryAt` (epoch milliseconds) is set when asking again later can succeed. */ export class SyncHttpError extends Error { readonly status: number readonly code: string readonly retryAt: number | null constructor(status: number, code: string, retryAt: number | null = null) { super(`Totem API answered ${status} ${code}`) this.name = 'SyncHttpError' this.status = status this.code = code this.retryAt = retryAt } } const DEFAULT_BASE_URL = 'https://api.totemstudio.ai/v1' const DAY_MS = 86_400_000 /** * The API always moves a cursor forward. Throws when a page says there is * more but repeats a cursor this loop already used, or sends none: following * it would read the same page forever. */ function nextCursor(seen: Set, cursor: string | null): string { if (!cursor || seen.has(cursor)) throw new Error('Totem API sent a missing or repeated cursor; stopping so the loop cannot spend the key\'s quota') seen.add(cursor) return cursor } /** `Retry-After` is always whole seconds. Null when the header is missing or unreadable. */ function retryAfterMs(response: Response): number | null { const header = response.headers.get('Retry-After') if (header === null || header.trim() === '') return null const seconds = Number(header) return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null } /** One GET. A `429` or `5xx` is retried while the wait fits within `maxWaitMs`. */ async function get(options: SyncOptions, path: string, team?: string): Promise { const fetcher = options.fetch ?? globalThis.fetch const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) const now = options.now ?? Date.now const maxRetries = options.maxRetries ?? 3 const maxWaitMs = options.maxWaitMs ?? 60_000 for (let attempt = 0; ; attempt++) { const response = await fetcher(`${options.baseUrl ?? DEFAULT_BASE_URL}${path}`, { headers: { Authorization: `Bearer ${options.key}`, ...(team ? { 'Totem-Team': team } : {}) }, }) if (response.ok) return await response.json() as T // Errors are application/problem+json. The firewall in front of the API may answer without a body. const problem = await response.json().catch(() => ({})) as { code?: string } const code = problem.code ?? `http_${response.status}` // Only a 429 or a 5xx is worth sending again within this run; the caller decides about the rest. if (response.status !== 429 && response.status < 500) throw new SyncHttpError(response.status, code) const waitMs = retryAfterMs(response) ?? 1000 * 2 ** attempt // Too long to wait here (the daily quota, say), or out of retries: report when to come back. if (waitMs > maxWaitMs || attempt >= maxRetries) throw new SyncHttpError(response.status, code, now() + waitMs) await sleep(waitMs) } } /** Every team `GET /v1/teams` lists. It lists restricted teams too, so a missing team has left the key. */ async function listedTeams(options: SyncOptions): Promise { const page = await get<{ data: Array<{ id: string }> }>(options, '/teams') return page.data.map(team => team.id) } /** * Members and bookable services are not in the feed: read them in full when a * team starts, when an item names an id you do not know, and once a day. * Boards do travel in the feed; they are read here as well because a lead can * name a new stage before that board's change reaches you. */ async function refreshDictionaries(options: SyncOptions, teamId: string): Promise { const boards: unknown[] = [] const seen = new Set() let cursor: string | null = null do { const query: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' const page: ListPage = await get(options, `/boards?limit=100${query}`, teamId) boards.push(...page.data) cursor = page.has_more ? nextCursor(seen, page.next_cursor) : null } while (cursor) // These two arrive whole, in one response, and take no `limit` or `cursor`. const members = await get(options, '/members', teamId) const services = await get(options, '/booking_services', teamId) const now = options.now ?? Date.now const dictionaries = { boards, members: members.data, booking_services: services.data } await options.store.saveDictionaries(teamId, dictionaries, now()) } /** Applies one page. Every item is checked against the version you hold, removals included. */ async function applyPage(options: SyncOptions, teamId: string, page: FeedPage): Promise { const { store } = options let refreshed = false for (const item of page.data) { // Apply only what is newer than your copy. A removal keeps its version as a // tombstone, so an older copy replayed later cannot bring the record back. if (item.version > await store.version(teamId, item.kind, item.id)) { if (await store.hasUnknownReferences(teamId, item)) { // One refresh per page at most. What it does not resolve (a member who // left, a deleted stage) is remembered, so it never asks again. if (!refreshed) { await refreshDictionaries(options, teamId) refreshed = true } await store.rememberUnresolved(teamId, item) } if (item.removed) { await store.remove(teamId, item) } else { // Commit contact permissions before anything downstream can act on the lead. const permissions = item.kind === 'lead' ? item.object?.contact_permissions : undefined if (permissions && typeof permissions === 'object') { await store.setContactPermissions(teamId, item.id, permissions as Record) } await store.upsert(teamId, item) } } // During a full resync, remember every record the feed still delivers. await store.markSeen(teamId, item) } } /** * Brings one team's copy up to date: reads the feed from the saved cursor * until it has caught up (`synced`), or until it has read `maxPagesPerTeam` * pages (`partial`). Throws `SyncHttpError` when the API refuses; the saved * cursor is untouched, so the next run resumes from the same place. */ export async function syncTeam(options: SyncOptions & { teamId: string }): Promise<'synced' | 'partial'> { const { store, teamId } = options const now = options.now ?? Date.now await store.ensureTeam(teamId) const dictionariesAt = await store.dictionariesAt(teamId) if (dictionariesAt === null || now() - dictionariesAt >= DAY_MS) await refreshDictionaries(options, teamId) let resyncing = false let pages = 0 let seen = new Set() for (;;) { const cursor = await store.cursor(teamId) const query = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' let page: FeedPage try { page = await get(options, `/sync/changes?limit=100${query}`, teamId) } catch (error) { // The cursor expired (410) or the feed cannot continue from it (409 resync_required): // run a full resync, once per run. const restart = error instanceof SyncHttpError && (error.status === 410 || error.code === 'resync_required') if (restart && !resyncing) { await store.startResync(teamId) resyncing = true seen = new Set() continue } throw error } await applyPage(options, teamId, page) // Check the cursor before saving it, so a missing or repeated one never // replaces the last good cursor. A last page only has to send one. if (cursor) seen.add(cursor) if (page.has_more || !page.next_cursor) nextCursor(seen, page.next_cursor) // Save the cursor only after the page is applied, and save it for an empty // page too: that keeps the cursor of a quiet team from expiring. await store.saveCursor(teamId, page.next_cursor) if (!page.has_more) { // After a full resync, delete what the feed no longer delivers. await store.finishResync(teamId) return 'synced' } pages++ // Leave the rest for the next run, so the key's other teams are not kept waiting. if (options.maxPagesPerTeam !== undefined && pages >= options.maxPagesPerTeam) return 'partial' } } /** * What one team's error means for this run. Every outcome but `dropped` keeps * the team's copy and cursor. Any other error (a `401`, a `400`, an unexpected * `404`, a feed that still asks for a resync after one) concerns the whole key * or a bug, so it is rethrown and stops the run. */ async function outcomeOf(options: SyncOptions, team: string, error: SyncHttpError): Promise { // A 429 or 5xx that asked for a longer wait than this run allows (or kept failing). if (error.retryAt !== null) return { team, status: 'retry_later', code: error.code, retryAt: error.retryAt } // Restricted, sector-restricted or switched off: keep everything and skip the team. if (error.status === 403) return { team, status: 'skipped', code: error.code } // Totem is still preparing this team's data (once per team, ever): a later run reads it. if (error.code === 'capture_not_ready') return { team, status: 'skipped', code: error.code } // The team may have left the key. Only `/teams` can confirm it; a team it still lists keeps its copy. if (error.code === 'team_not_accessible') { if ((await listedTeams(options)).includes(team)) return { team, status: 'skipped', code: error.code } await options.store.dropTeam(team) return { team, status: 'dropped' } } throw error } /** * Syncs every team the key reads and reports what happened to each. Re-reads * `/teams` on every run, so a new team starts its initial sync and a team that * left the key is deleted. Once a `429` (the daily quota, say) sends the key * away for longer than `maxWaitMs`, the teams not reached yet are reported * `retry_later` for the same time, without a request. * * Throws when the key itself is refused, when `/teams` cannot be read, when a * team's answer points to a bug rather than to the team (a `400`, say), or * when the network fails. No cursor is lost. When the error carries `retryAt` * (the daily quota is spent, for example), schedule the next run for then. */ export async function syncKey(options: SyncOptions): Promise { const outcomes: TeamOutcome[] = [] const listed = await listedTeams(options) for (const team of await options.store.teamIds()) { if (listed.includes(team)) continue await options.store.dropTeam(team) outcomes.push({ team, status: 'dropped' }) } // Set when a `429` sends the key away for longer than this run waits. let keyWait: { code: string, retryAt: number } | null = null for (const team of listed) { if (keyWait) { outcomes.push({ team, status: 'retry_later', ...keyWait }) continue } try { outcomes.push({ team, status: await syncTeam({ ...options, teamId: team }) }) } catch (error) { if (!(error instanceof SyncHttpError)) throw error const outcome = await outcomeOf(options, team, error) outcomes.push(outcome) // A `429` concerns the key, never one team: the daily quota and the rate // limit are both the key's. The teams not reached yet would be refused // too, so they wait for the same moment without spending a request. if (error.status === 429 && outcome.status === 'retry_later') keyWait = { code: outcome.code, retryAt: outcome.retryAt } } } return outcomes } ``` ## In-memory store for tests `MemorySyncStore` implements every `SyncStore` method in memory. It shows what each method must do and makes your tests deterministic. It loses everything on restart, so replace it with your database before you go live. ```ts title="memoryStore.ts" /** * An in-memory `SyncStore`, for tests and for reading alongside syncTeam.ts. * It loses everything on restart: in production, implement every method on * your database, durably, and commit a page and its cursor together when you can. */ import type { FeedItem, SyncStore } from './syncTeam.js' /** One key per record: a lead and its two facets share an id but not a kind. */ const keyOf = (item: Pick) => `${item.kind}/${item.id}` /** * Every board, stage, service and member id an item names: a lead's `board`, * `stage` and `assignees`, a conversation's `assignee`, a booking's `service`, * and an activity's `stage_snapshot`, `from` and `to`: a stage with its board, * or a list of members. A status change's `from` and `to` are statuses. */ function referencedIds(item: FeedItem): string[] { const object = item.object ?? {} const ids: unknown[] = [object.board, object.stage, object.service, object.assignee] if (Array.isArray(object.assignees)) ids.push(...object.assignees) if (item.kind === 'activity') { for (const side of [object.from, object.to, object.stage_snapshot]) { if (Array.isArray(side)) { ids.push(...side) } else if (side && typeof side === 'object') { const { stage, board } = side as { stage?: unknown, board?: unknown } ids.push(stage, board) } } } return ids.filter((value): value is string => typeof value === 'string') } export class MemorySyncStore implements SyncStore { /** team → record key → the latest item applied. */ rows = new Map>() cursors = new Map() /** team → record key → version held, removals (tombstones) included. */ versions = new Map>() /** `team/lead` → the lead's contact permissions. */ permissions = new Map>() dictionaries = new Map>() dictionaryTimes = new Map() /** team → keys the feed delivered during a running full resync. */ recovery = new Map>() /** `team/lead` of every removed lead, so a facet or activity replayed later is not stored again. */ removedLeads = new Set() /** team → ids still unknown right after a dictionary refresh: departed members, deleted boards, stages or services. */ unresolved = new Map>() async teamIds() { return [...this.rows.keys()] } async ensureTeam(team: string) { if (!this.rows.has(team)) this.rows.set(team, new Map()) if (!this.versions.has(team)) this.versions.set(team, new Map()) } async cursor(team: string) { return this.cursors.get(team) ?? null } async version(team: string, kind: string, id: string) { return this.versions.get(team)?.get(`${kind}/${id}`) ?? 0 } async setContactPermissions(team: string, lead: string, permissions: Record) { this.permissions.set(`${team}/${lead}`, structuredClone(permissions)) } async upsert(team: string, item: FeedItem) { await this.ensureTeam(team) this.versions.get(team)!.set(keyOf(item), item.version) // A facet or an activity of a removed lead keeps its version but is not stored. const lead = item.kind === 'lead_attribution' || item.kind === 'lead_response' ? item.id : item.object?.lead if (typeof lead === 'string' && this.removedLeads.has(`${team}/${lead}`)) return this.rows.get(team)!.set(keyOf(item), structuredClone(item)) } async remove(team: string, item: FeedItem) { await this.ensureTeam(team) // The tombstone: the version stays, so an older copy delivered later is ignored. this.versions.get(team)!.set(keyOf(item), item.version) const rows = this.rows.get(team)! rows.delete(keyOf(item)) if (item.kind !== 'lead') return // A removed lead takes its attribution, response, activities and permissions with it. this.removedLeads.add(`${team}/${item.id}`) this.permissions.delete(`${team}/${item.id}`) for (const [key, row] of rows) { const facet = (row.kind === 'lead_attribution' || row.kind === 'lead_response') && row.id === item.id const activity = row.kind === 'activity' && row.object?.lead === item.id if (facet || activity) rows.delete(key) } } async saveCursor(team: string, cursor: string) { this.cursors.set(team, cursor) } async startResync(team: string) { this.versions.set(team, new Map()) this.cursors.delete(team) this.recovery.set(team, new Set()) this.unresolved.delete(team) // A restore can reissue ids and versions, so the resync starts from a clean slate. for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key) } async markSeen(team: string, item: FeedItem) { this.recovery.get(team)?.add(keyOf(item)) } async finishResync(team: string) { const seen = this.recovery.get(team) if (!seen) return const rows = this.rows.get(team) ?? new Map() for (const [key, row] of rows) { if (seen.has(key)) continue rows.delete(key) if (row.kind === 'lead') this.permissions.delete(`${team}/${row.id}`) } this.recovery.delete(team) } async dropTeam(team: string) { this.rows.delete(team) this.cursors.delete(team) this.versions.delete(team) this.recovery.delete(team) this.dictionaries.delete(team) this.dictionaryTimes.delete(team) this.unresolved.delete(team) for (const key of this.permissions.keys()) if (key.startsWith(`${team}/`)) this.permissions.delete(key) for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key) } async dictionariesAt(team: string) { return this.dictionaryTimes.get(team) ?? null } async saveDictionaries(team: string, data: Record, at: number) { this.dictionaries.set(team, structuredClone(data)) this.dictionaryTimes.set(team, at) } async hasUnknownReferences(team: string, item: FeedItem) { const unresolved = this.unresolved.get(team) return this.unknownIds(team, item).some(id => !unresolved?.has(id)) } async rememberUnresolved(team: string, item: FeedItem) { const ids = this.unknownIds(team, item) if (!ids.length) return const unresolved = this.unresolved.get(team) ?? new Set() for (const id of ids) unresolved.add(id) this.unresolved.set(team, unresolved) } /** The board, stage, service and member ids the item names that the dictionaries do not hold. */ private unknownIds(team: string, item: FeedItem): string[] { if (!item.object) return [] const known = new Set() for (const entry of Object.values(this.dictionaries.get(team) ?? {}).flat()) { const object = entry as { id: string, stages?: Array<{ id: string }> } known.add(object.id) for (const stage of object.stages ?? []) known.add(stage.id) } return referencedIds(item).filter(id => !known.has(id)) } } ``` --- # Lists and pagination > How list endpoints return records a page at a time, how to read every page, and what order and filters a list supports. Source: https://totemstudio.ai/developers/pagination List endpoints, such as [`GET /v1/leads`](https://totemstudio.ai/developers/reference-leads#leads.list) or [`GET /v1/calls`](https://totemstudio.ai/developers/reference-calls#calls.list), return a team's records one page at a time. This page explains how to read every page, how a list is ordered and filtered, and which endpoints are not paged at all. ## Pages and cursors Every page comes in the same list envelope: ```json { "object": "list", "data": [ { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoiY2FsbCIsImEiOiJjYWxsX20ya1ViRmNheUhsWjVMZ3M5eFpObURTM2MzSnZrIiwicyI6ImM0OTk4YTdlNDJkYiJ9357H5Y" } ``` - `limit` sets the page size, from 1 to 100. The default is 25. You can change it from one page to the next. - While `has_more` is `true`, send `next_cursor` back as `cursor` to get the next page. On the last page, `has_more` is `false` and `next_cursor` is `null`. - A page can hold fewer items than `limit`, or none at all, and still say `has_more: true`. Totem skips records it has not prepared yet, and ends a page early rather than make you wait. Keep going until `has_more` is `false`. A cursor is opaque: store it exactly as you received it, and never build or edit one. It works only with the endpoint, the filters and the team it came from. Any other use gets `400 invalid_request`, with `param` set to `cursor`. A conversations cursor also stops working if the conversation it points at is erased: if a cursor you did not change gets `400 invalid_request` with `param` set to `cursor`, start that list again from the first page. ### Read every page To read a whole list, keep the same filters and the same `Totem-Team` on every request, and stop only when `has_more` is `false`. These loops list a team's calls with cURL and `jq`, with TypeScript on Node.js 22.18 or later (an ES module, so `await` works at the top level), and with Python and `requests`: ```bash [list-calls.sh] cursor="" while :; do page=$(curl -sS --fail-with-body \ "https://api.totemstudio.ai/v1/calls?limit=100${cursor:+&cursor=$cursor}" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID") || { printf '%s\n' "$page" >&2; break; } printf '%s\n' "$page" | jq -r '.data[].id' [ "$(printf '%s' "$page" | jq -r '.has_more')" = true ] || break next=$(printf '%s' "$page" | jq -er '.next_cursor') || { echo 'The page has no next_cursor' >&2; break; } [ "$next" != "$cursor" ] || { echo 'The list repeated a cursor' >&2; break; } cursor=$next done ``` ```ts [list-calls.ts] interface CallPage { data: Array<{ id: string }> has_more: boolean next_cursor: string | null } const apiKey = process.env.TOTEM_API_KEY if (!apiKey) throw new Error('Set TOTEM_API_KEY on your server') const headers: Record = { Authorization: `Bearer ${apiKey}` } if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID let cursor: string | null = null while (true) { const url = new URL('https://api.totemstudio.ai/v1/calls') url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', cursor) const response = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) }) if (!response.ok) { const problem = (await response.json().catch(() => ({}))) as { code?: string } throw new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${response.headers.get('Totem-Request-Id') ?? 'n/a'}`) } const page = (await response.json()) as CallPage // Your code goes here. Print ids, never whole records: they hold personal data. for (const call of page.data) console.log(call.id) if (!page.has_more) break if (!page.next_cursor || page.next_cursor === cursor) throw new Error('The list did not move forward') cursor = page.next_cursor } ``` ```python [list_calls.py] # pip install requests import os import requests headers = {"Authorization": f"Bearer {os.environ['TOTEM_API_KEY']}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] params = {"limit": 100} while True: response = requests.get("https://api.totemstudio.ai/v1/calls", headers=headers, params=params, timeout=30) if not response.ok: try: code = response.json().get("code") except ValueError: code = None raise RuntimeError( f"Totem API {response.status_code} {code or response.reason}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) page = response.json() # Your code goes here. Print ids, never whole records: they hold personal data. for call in page["data"]: print(call["id"]) if not page["has_more"]: break if not page["next_cursor"] or page["next_cursor"] == params.get("cursor"): raise RuntimeError("The list did not move forward") params["cursor"] = page["next_cursor"] ``` Each loop also stops when the API answers an error, or when a page repeats the cursor it was asked for, rather than asking for the same page forever. To keep a copy of a team, use the changes feed instead, as [Keeping a copy](https://totemstudio.ai/developers/pagination#keeping-a-copy) explains. ## Order A list comes in a stable order that means nothing: it is not by date, and not by name. If you need an order, sort on your side, for example by `created_at`. ## Filters [`GET /v1/leads`](https://totemstudio.ai/developers/reference-leads#leads.list) is the only list with filters. It takes at most one of them, with one value: - `board`: the leads on one board (`brd_…`); - `stage`: the leads in one stage (`stg_…`); - `status`: the leads with one status, such as `qualified`. ```bash curl "https://api.totemstudio.ai/v1/leads?status=qualified" \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` Two filters in one request get `400 invalid_request`, with `param` naming one of them. A board or stage the team does not have returns an empty page, not an error. There is no `updated_since` filter. To find out what changed, use the changes feed: [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains it. ## Lists that come whole Three lists are short, so they come whole, in one response, and take no `limit` or `cursor`: - [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list), the teams your key reads; - [`GET /v1/members`](https://totemstudio.ai/developers/reference-account#members.list), a team's members; - [`GET /v1/booking_services`](https://totemstudio.ai/developers/reference-bookings#booking_services.list), a team's bookable services. They use the same envelope, with `has_more: false` and `next_cursor: null`. Sending `limit` or `cursor` to one of them gets `400 invalid_request`. ## Search is not a list [`POST /v1/leads/search`](https://totemstudio.ai/developers/reference-leads#leads.search) finds leads by exact phone or email, sent in the JSON body. It returns at most 10 leads, in one response that never pages: `has_more` is always `false` and `next_cursor` is always `null`. When many records share a phone number or an email address, search may not return them all. ## Records that are not ready yet Totem prepares each record for the API shortly after it changes. Until then, lists and search skip a new record, and a request for it by id, such as [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/reference-leads#leads.get), answers `404 resource_missing`. Do not retry in a loop: the changes feed delivers the record as soon as it is ready. ## Keeping a copy A list shows the records as they are now. It is the right tool to look something up, not to keep a copy: re-reading every list to find changes costs far more of your [daily quota](https://totemstudio.ai/developers/rate-limits) and still misses removals. To keep a copy of a team, follow the changes feed, as [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains. --- # Data coverage > What Totem records, since when, and how to tell an empty value from a fact that was never recorded. Source: https://totemstudio.ai/developers/coverage The API returns what Totem has recorded, and tells you where each fact came from. It does not rebuild old attribution, invent status changes nobody recorded, or include the content of messages. This page tells you which facts you can rely on, and what an empty value means. > **Note: Some facts start on a fixed date** > > Totem records a few facts for the API only from the day it started capturing each one, the same day for every team: the ad that opened a conversation; the activities of a lead and what Totem reads from them (`converted_at`, `revived_at`, `status_set_by` and `archived_from_status`); the Meta ad set of an ad form submission; and the links between a rescheduled booking and its replacement. Neither this page nor the API gives that day; ask [support](https://totemstudio.ai/developers/support) if you need it. Records from before it carry `null`, `unknown` or `not_captured` for those facts, so do not treat your history as complete. ## What Totem records, and since when | Fact | Where it comes from | Limits | |---|---|---| | **Who the lead is, and where it stands** | The lead as it is now: contact details, `status`, `board` and `stage`. | People on the team can edit these at any time. A value this version of the API does not know arrives as `null`, `other` or `unknown`, depending on the field; [All objects](https://totemstudio.ai/developers/objects) says which. | | **How the lead first arrived** (`attribution.original_capture`) | Recorded once, when the lead was created (`capture: at_creation`). A lead with no such record, because it was created before Totem started recording it or its creation was not recorded, gets a copy of the lead as it was when its team was first added to an integration (`capture: at_activation`). | An `at_activation` copy shows the lead on that day, not the form the person first filled in. | | **The latest ad form** (`attribution.latest_submission`) | The newest ad form submission recorded for the lead, including a repeat from a contact Totem already had. | It never changes how the lead first arrived. Google Ads and LinkedIn submissions stop being readable after 90 days (`expires_at`). A LinkedIn submission the person later deleted is no longer shown. When a submission expires or is withdrawn, the changes feed delivers a new `lead_attribution` version without it, which can show an older submission instead; replace your copy with it. LinkedIn Lead Sync cannot be connected today, and only Totem can connect Google Ads lead forms, so most teams have no `google_ads` or `linkedin` submissions. | | **Campaign, ad and form details** from Google, LinkedIn and Meta | What the ad platform sent, field by field, each with its `provenance`. | Totem does not attribute leads after the fact, and does not look ids up in the platforms' APIs to fill gaps. | | **The ad that opened a conversation** (`original_capture.ad_referral`) | The first Meta ad referral of the conversation that created the lead. | Only from the day Totem started recording it, as the note above explains. Never the messages. | | **Activities, and the dates they set** (`converted_at`, `revived_at`) | Each change of stage, status or assigned members that Totem recorded. | Only from the day Totem started recording them, as the note above explains. Totem does not invent changes it did not record. | | **How the team has responded** (the lead's `response`) | The times of messages and calls across the lead's conversations. | Erased messages and web-chat visitor messages are not counted, and imported WhatsApp history does not count as a customer message or as a reply. A reply is a sent message recorded as a conversation reply or with no purpose. Outreach and transactional messages, such as reminders, are left out when they are recorded as such, but many automated sends carry no purpose and do count as replies. Totem reads at most 20 conversations, 200 messages and 50 calls per lead; past that, `coverage.truncated` is `true`. | | **Calls, conversations and bookings** | The metadata Totem stores for each one. | A link to a lead that no longer exists is `null`. No recordings, transcripts, notes or payments. A booking rescheduled before Totem started recording reschedule links may lack its `rescheduled_from` or `rescheduled_to` link. | | **Contact permissions** | The refusals Totem stored, each with its `provenance`. | A `null` consent means unknown, never yes. Apply permissions before you contact anyone. | ## Provenance and empty values Many fields tell you where their value came from: - `capture`, on `original_capture`: `at_creation` when it was recorded as the lead was created, or `at_activation` when it was copied once, as the team was first added to an integration, for a lead that had no capture record: one created before Totem started recording attribution, or whose creation was not recorded. - `provenance`, on each campaign, ad, form and UTM field: `provider` when the ad platform sent it, `team_alias` when someone on the team typed it, `stored_metadata` when it comes from lead data the team can edit, `stored_metadata_at_activation` when that data was copied once at activation, and `not_captured` exactly when `value` is `null`. - `source_provenance`, on a lead: `server_written` when Totem stamped the source itself from the ad platform, or `member_editable` when people on the team can change it. - `original_capture_missing_reason` and `latest_submission_missing_reason`: `not_captured` whenever the object next to them is `null`, whatever the reason: Totem did not record it, the lead never came from an ad form, or the submission expired or was withdrawn. And when a value is empty: - A `null` date is not proof that something never happened. It can mean Totem did not record it. - A `null` consent is unknown. It is never consent. - Check `coverage.truncated` before you treat a lead's response as complete. [All objects](https://totemstudio.ai/developers/objects) describes every field, and lists every value each of these fields can take today. --- # Testing and go-live > How to build and test an integration safely while the API has no test environment, and what to check before you go live. Source: https://totemstudio.ai/developers/testing ## No test environment yet Version 1.0a has no sandbox and no test keys. Every key reads a real team, and everything it returns has `livemode: true`. The API is read-only, so no request can change data in Totem: testing is safe as long as you treat what you read as real personal data. ## Where to test Start with a team that holds only fictitious data, and use real data only when you are ready to go live. - **A dedicated team with fictitious data.** Use a separate Totem team containing only fictitious contacts, then create an integration for that team. Its key is still a live key, and the team needs what a production team needs: an organization whose plan includes the API, a completed legal setup, a business sector that is not sensitive, and an administrator to create the integration ([Quickstart](https://totemstudio.ai/developers/quickstart#before-you-begin) lists them). The API is read-only, so you create the test data in Totem itself: add a few leads by hand with fictitious names, phone numbers and email addresses from the ranges below, then call, write to or book with the test team yourself to produce calls, conversations and bookings. - **Your own team.** You can also build against your team's real data with your own key. Keep that data on systems your contracts cover, and delete test copies when you finish. ## Example data The integrator kit ships with the API's description and with example objects: - [`openapi.json`](https://totemstudio.ai/developers/kit/openapi.json) describes every endpoint and field; - [`fixtures.json`](https://totemstudio.ai/developers/kit/fixtures.json) holds example objects of every kind, all with `livemode: false`. Load the example data in your unit tests before you call the API. The examples in these docs and in the kit use fictitious contacts only: - phone numbers from `+1 202-555-0100` to `+1 202-555-0199`; - email addresses at the `example.com` domain. Use the same ranges in your own test data, so that no test can ever reach a real person. ## What to test before you go live Run each check against your own code, with the example data or with a test team. [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains the behavior each one proves. ### Applying the feed - **Idempotency.** Apply the same feed page twice: nothing may change the second time. - **Older versions.** Deliver an item with a lower `version` than the one you hold: it must be ignored. - **Crash recovery.** Stop your sync after saving a page and before saving its cursor, then start it again: the replayed page must change nothing. - **Removals.** A removal must delete the record and, for a lead, its attribution, response and activities. Then deliver an older version of the same record: it must stay deleted. - **Full resync.** Throw your cursor away and run the [full resync](https://totemstudio.ai/developers/sync#full-resync): records missing from the new copy must disappear. - **Unknown values.** An enum value or an error code you do not know must not stop the sync. ### Errors and limits - **A team that is not ready.** With a key that reads two teams, make one answer `409 capture_not_ready`: the other must keep syncing in the same run, and the first must keep its cursor. - **Teams that come and go.** `404 team_not_accessible` must delete the team's data only once `GET /v1/teams` confirms the team is gone. A `403` must keep the data and the cursor. - **Rate limit.** A `429 rate_limited` must wait for `Retry-After` and then continue with the same cursor. - **Daily quota.** A `429 quota_exceeded` must stop the sync until 00:00 UTC and resume then with the same cursor. Make sure the waiting process cannot overlap the next scheduled run. ### Contacts - **Contact permissions.** A lead with `contact_permissions.do_not_contact: true` must not be contacted by any system you feed. - **Unknown consent.** A lead with `whatsapp_consent: null` must not receive WhatsApp messages as if it had agreed. ## Rehearse a key rotation Roll a key once before you depend on it, so the first real rotation is routine: 1. In **Settings → Developers**, choose **Roll key** and keep the previous key working for 1 hour. 2. Deploy the new key to your server, the way you will in production. 3. Check that your sync keeps running on the new key. 4. Once that hour has passed, check that the old key answers `401 key_expired`, and that nothing in your systems still uses it. [Authentication](https://totemstudio.ai/developers/authentication#roll-and-revoke) explains rolling and revoking. ## Checking availability [`GET /v1/health`](https://totemstudio.ai/developers/reference-account#health) tells you whether the API is up. It needs no key and returns no data, so use it for uptime checks. Check it with `GET`: the firewall in front of the API answers `HEAD` with a plain `404`. Call it with no query string: any parameter, even a cache-buster, gets `400 invalid_request`. Its `Totem-Release` response header names the release that answered; quote it when you report an incident. To check a key, call [`GET /v1/me`](https://totemstudio.ai/developers/reference-account#me.get). --- # Security and personal data > How Totem protects API keys and the data the API serves, what it records about each key, where the API runs, and what to do with the personal data you copy. Source: https://totemstudio.ai/developers/security This page is for whoever reviews the security of an integration, on the customer's side or on yours. It sums up how Totem protects keys and data, and what to do with the personal data you copy. [Authentication](https://totemstudio.ai/developers/authentication) has the details of each control. ## Keys - **A key is a long random secret.** It starts with `tsk_live_`, then a lookup id, then about 256 bits of random secret and a checksum. The checksum lets Totem refuse a mistyped key before looking it up. - **Totem never stores the key itself.** It keeps a one-way digest of the secret, computed with HMAC-SHA-256 and a separate secret that is kept outside Totem's database, so a copy of the database is not enough to use a key. Totem shows the key once, when it is created, and its logs name a key by its lookup id, never by its secret. - **Keys do not expire.** A key works until you roll it or revoke it. Roll keys on a schedule that suits you, for example every 90 days, and whenever someone who had access leaves; [Testing and go-live](https://totemstudio.ai/developers/testing#rehearse-a-key-rotation) shows how to rehearse it. - **Keys are managed only in Settings.** Creating, rolling and revoking keys happen in **Settings → Developers**, never through the API. Creating or rolling a key, adding a team and changing the IP allowlist ask the administrator to sign in again if they have not done so recently, and the administrators who manage the integration, and those of every team it covers, are told about every change, in Totem and by email. - **Revoking is fast.** A revoked key stops working within 5 seconds, and revoking never needs a new sign-in, so it can be done at once during an incident. - **Access is read-only and scoped to teams.** No key can create or change anything in Totem. A key reads only the teams of its integration, with administrator-level access to them, including channels and records marked Private, so give an integration only the teams it needs. - **A key can be bound to your servers.** An [IP allowlist](https://totemstudio.ai/developers/authentication#ip-allowlist) limits a key to the IPv4 addresses you list. - **Guessing is throttled.** Repeated failed attempts lock out the address or the key for a while, as [Failed attempts](https://totemstudio.ai/developers/authentication#failed-attempts) explains. ## Activity records For each key, **Settings → Developers** shows **Last used**: when the key last made a request, and the `/24` network the request came from. It can lag by up to 10 minutes. Each integration's **Recent activity** shows its most recent changes, such as its creation, each key roll or revocation, and each change to its teams, its IP allowlist or its name, with the date and who made it. Totem keeps each of these entries for 365 days. Totem also counts each key's requests per day and endpoint, and keeps those counts for 90 days. ## Where the API runs - **Region.** The API runs on Google Cloud in the `europe-west1` region, in Belgium, and reads Totem's database, which is stored in the European Union. - **Encryption in transit.** `api.totemstudio.ai` answers only on port 443, over HTTPS with TLS 1.2 or later. There is no plain-HTTP listener, so a key never travels unencrypted, and the API's own responses carry `Strict-Transport-Security` and `X-Content-Type-Options: nosniff`. - **No caching.** No CDN or shared cache sits in front of the API, and every response from the API itself carries `Cache-Control: no-store`. - **No browsers.** The API sends no CORS headers and refuses a secret key sent with an `Origin` header (`400 secret_key_in_browser`), so a key pasted into a web page fails at once instead of working quietly. - **A firewall in front.** A firewall filters requests before they reach the API, as [Errors](https://totemstudio.ai/developers/errors#error-format) describes. ## Personal data you receive The API returns personal data: leads' names, email addresses, phone numbers, company and tags, where each lead came from (UTM values and ad, campaign and form names) and its activities, team members' display names, and the metadata of calls, conversations and bookings. It never returns message text, call transcripts or recordings. Once you copy this data, protecting it is up to you and the customer whose data it is. We recommend that you: - **Use the data only for what the customer asked.** If you build the integration for a Totem customer, use what it returns only for that customer's purposes. - **Protect your copy.** Store it encrypted, limit who can read it, and keep records out of logs, error reports and support requests. Print ids, not whole records. - **Apply removals as soon as they arrive.** Delete the record from your copy and from every system you fed from it, and keep only its tombstone. [Removals](https://totemstudio.ai/developers/sync#removals) explains, and [How do I honor erasure requests?](https://totemstudio.ai/developers/faq#how-do-i-honor-erasure-requests) covers erasures. - **Apply contact permissions first**, before any of your systems contacts a person, as [Contact permissions first](https://totemstudio.ai/developers/sync#contact-permissions-first) explains. - **Send opt-outs back.** The API is read-only, so Totem cannot learn about an opt-out recorded in your systems. When a person opts out there, make sure it is also recorded on the lead in Totem. - **Treat text fields as untrusted input**, as [Conventions](https://totemstudio.ai/developers/conventions#text-fields-are-untrusted-input) explains. - **Delete what you no longer receive.** When an integration is revoked or a team leaves it, Totem can no longer send you removals for that data: delete your copy, unless the customer asks you to keep it and has its own reason to. While access is refused, for example because the customer's plan no longer includes the API, no removals arrive either: if that lasts longer than your retention policy allows, delete your copy. ## Report a vulnerability If you find a security problem in the API, in these docs or in the [TypeScript SDK](https://totemstudio.ai/developers/typescript), write privately to hello@totemstudio.ai. Say what you found and how to reproduce it with fictitious data, and include request ids when you have them. Never include a live key or real customer data, and do not report it in a public issue. If one of your keys leaked, follow [If a key leaks](https://totemstudio.ai/developers/support#if-a-key-leaks) first. --- # Conventions > The notation these docs use, and the rules every response follows: ids, timestamps, empty values, amounts, enums, versions, stability, errors and untrusted text. Source: https://totemstudio.ai/developers/conventions ## Notation - `GET /v1/leads/{id}` is a method and a path under `https://api.totemstudio.ai`. A word in braces is a placeholder. - `…` stands for a value left out of an example. - `$TOTEM_API_KEY` is an environment variable that holds your secret key. - Field names appear as they are in the JSON. A dot reaches into an object: `contact_permissions.do_not_contact`. ## Requests and responses - HTTPS only. Request and response bodies are JSON in UTF-8, and field names are `snake_case`. - Every resource, such as a lead, a call or a team, has an `object` field that names its type, and a `livemode` field. `livemode` is `true` on everything the API returns; only the example data of these docs has `livemode: false`. - A collection comes in a list envelope with `object: "list"`, `data`, `has_more` and `next_cursor`. [Lists and pagination](https://totemstudio.ai/developers/pagination) explains it. - A few bodies have shapes of their own. The items of the changes feed carry `kind`, and the record sits inside them under `object`, or a removal under `removed`. [`GET /v1/health`](https://totemstudio.ai/developers/reference-account#health) answers `{"object": "health", "status": "ok"}`. Errors follow the format described under [Errors](https://totemstudio.ai/developers/conventions#errors). ## Ids Ids are strings with a prefix that names the type of record: | Prefix | Record | |---|---| | `team_` | Team | | `mem_` | Team member | | `svc_` | Bookable service | | `brd_` | Board | | `stg_` | Stage | | `lead_` | Lead, and its `lead_attribution` and `lead_response` facets | | `act_` | Lead activity | | `call_` | Call | | `conv_` | Conversation | | `bkg_` | Booking | | `prn_` | Integration | Treat an id as opaque: store it whole, compare it exactly and never parse it. An id stays the same while its record exists. A record that is removed and later created again can come back with a new id. Member ids belong to one team: the same person has a different `mem_` id in each team. ## Timestamps Timestamps are ISO 8601 strings in UTC, such as `2026-10-15T09:30:00Z`, and may carry fractional seconds. Each team's time zone comes with `GET /v1/teams/{id}`; use it to display times, never to store them. ## Empty values In API objects, a field with no value is `null`, never an empty string. Some fields say why they are empty: - a lead's `phone` is `null` with `phone_status` set to `missing` or `invalid`; - a lead's pipeline fields are `null` with `pipeline_cleared: true` when someone converted the lead and cleared its pipeline data. ## Phones and amounts - Phones are E.164 strings, such as `+12025550123`. - A lead's `estimated_value` and `value_at_conversion` are amounts as Totem stores them: in major units, such as euros or dollars rather than cents, possibly with decimals. `estimated_value_currency` is always `null`, because Totem stores no currency with these values. - No field of version 1.0a carries an amount in minor units. ## Enums can grow Fields with a fixed set of values, such as `status` or `archive_reason`, can gain values in any version, and so can error codes. When you meet a value you do not know, keep the record, treat the value as "other", and carry on with the sync. New kinds of record can also appear in the changes feed: store or skip an item whose `kind` you do not know, and keep syncing. The OpenAPI document lists the values known today. If you generate a client from it, make sure the client accepts a value it does not know instead of rejecting the whole object. ## Record versions Every record the changes feed delivers carries `version`, an integer that grows each time the record changes, and `updated_at`, the time of that version. That covers leads and their two facets, activities, calls, conversations, bookings and boards. Apply a record only when its `version` is greater than the one you hold. The feed can deliver a record more than once; its version tells you which copy is newer. Teams, members and bookable services have no version. When you read them again, replace your copy as a whole. ## API versions and stability `/v1` is the current API version. New fields, enum values, endpoints and error codes can appear, so ignore what you do not use. Every operation states its stability in this reference and, in the OpenAPI document, as `x-totem-stability`. In version 1.0a every operation is `beta`. Read the [changelog](https://totemstudio.ai/developers/changelog) for updates. ### What Beta means While the API is in Beta, Totem can change or remove fields, values and behavior, with no fixed notice period. The [changelog](https://totemstudio.ai/developers/changelog) lists changes under the date they shipped, and an entry that asks you to change your integration or to run a full resync starts with **Action required**. Additive changes, such as new fields, enum values, endpoints or error codes, can appear at any time, so build your integration to [ignore what it does not know](https://totemstudio.ai/developers/conventions#enums-can-grow). ## Request ids Every response from the API carries a `Totem-Request-Id` header, such as `req_0f6b2c1e-8d4a-4b7e-9c3f-5a2d1e7b9c40`, and an error body repeats it as `request_id`. Log it, and quote it when you ask [Support](https://totemstudio.ai/developers/support) for help. ## Errors Errors follow RFC 9457 and come as `application/problem+json`: ```json { "type": "https://totemstudio.ai/developers/errors#team_required", "title": "Team required", "status": 400, "code": "team_required", "request_id": "req_0f6b2c1e-8d4a-4b7e-9c3f-5a2d1e7b9c40", "param": "Totem-Team", "doc_url": "https://totemstudio.ai/developers/errors#team_required" } ``` - `code` says what went wrong. Branch on `code`, never on `title`, which can change. `type` and `doc_url` both link to the code's entry on the [Errors](https://totemstudio.ai/developers/errors) page, which says when it happens and what to do. - `param`, when present, names the header, parameter or field at fault. It never repeats the value you sent. - New codes can appear. Handle a code you do not know by its HTTP status. - A `401` also carries the header `WWW-Authenticate: Bearer realm="Totem API"`. - A `429` carries `Retry-After`, a whole number of seconds: wait at least that long. Two codes use it, `rate_limited` and `quota_exceeded`; [Rate limits](https://totemstudio.ai/developers/rate-limits) explains both. Retry a `5xx` with exponential backoff. During Beta, the firewall in front of the API answers some requests itself with a plain `403`, `404` or `429` that is not in this format: no JSON error body, no `Retry-After` and no `Totem-Request-Id`. It answers `404` to any path outside `/v1/` and to any method other than `GET` and `POST`, `HEAD` included. Handle its answer by its status; [Errors](https://totemstudio.ai/developers/errors) lists when each one happens. ## Personal data in URLs Never put a phone number, an email address or a name in a URL: URLs end up in logs on both sides. To find a lead by phone or email, send them in the JSON body of [`POST /v1/leads/search`](https://totemstudio.ai/developers/reference-leads#leads.search). ## Text fields are untrusted input Text fields such as a lead's `name`, `surname`, `company`, `email` and `tags`, a member's `display_name`, and attribution values such as UTM parameters and ad, campaign and form names come from web forms, the links visitors arrive from, ad platforms, messaging profiles and the team's own members, and Totem returns them as they were stored. Anyone who fills in a form or edits a link chooses what they contain. Escape them wherever you display them, and never interpret them as HTML, Markdown or a template. When you export them to CSV or a spreadsheet, put a single quote before any value that starts with `=`, `+`, `-`, `@`, a tab or a carriage return, so that no cell runs as a formula. --- # Errors > The error format, how your integration should handle each kind of error, and every error code with when it happens and what to do. Source: https://totemstudio.ai/developers/errors When a request fails, the API answers with an HTTP status and a JSON body that says what went wrong. Every error body links to its entry on this page. ## Error format Errors follow RFC 9457 and come as `application/problem+json`. For example, `GET /v1/leads?limit=500` answers: ```json { "type": "https://totemstudio.ai/developers/errors#invalid_request", "title": "Invalid request", "status": 400, "code": "invalid_request", "request_id": "req_0f6b2c1e-8d4a-4b7e-9c3f-5a2d1e7b9c40", "param": "limit", "doc_url": "https://totemstudio.ai/developers/errors#invalid_request" } ``` | Field | What it holds | |---|---| | `code` | What went wrong, as a stable code. Branch on it. | | `status` | The HTTP status, repeated. | | `title` | A short description in English, for people. It can change, so never branch on it. | | `type` and `doc_url` | A link to the code's entry below. | | `request_id` | The id of this request, the same as the `Totem-Request-Id` header. | | `param` | Present only when one header, query parameter or body field is at fault. It names it, and never repeats the value you sent. | These headers come with an error: - `Totem-Request-Id`, on every response the API itself sends. Log it, and quote it when you ask [Support](https://totemstudio.ai/developers/support) for help. Never send us your key or your customers' data. - `Retry-After`, on every `429` the API itself returns: a whole number of seconds to wait. - `WWW-Authenticate: Bearer realm="Totem API"`, on every `401`. New codes can appear at any time. Handle a code you do not know by its HTTP status, as the next section describes. During the Beta, the firewall in front of the API answers some requests itself, before they reach the API. Its answer has no JSON error body, only the firewall's own default page, and no `Retry-After` or `Totem-Request-Id`. Do not parse its body as JSON: handle it by its status: - `404` for a path outside `/v1/`, or for any method other than `GET` and `POST`, `HEAD` and `OPTIONS` included; - `403` for a request with a body, unless it is `POST /v1/leads/search`, and for a body larger than 16 KiB; - `429` when one address sends too many requests, for example many requests without a well-formed key. ## Handling errors Decide by HTTP status first, then by `code`: - **`400`: fix the request.** Sending the same request again fails the same way. `param` says what to fix. One `400` is not your request's fault: a conversations cursor stops working when the conversation it points at is erased, so start that list again from the first page, as [Lists and pagination](https://totemstudio.ai/developers/pagination#pages-and-cursors) explains. Two codes need more than a fix: `team_required` means your key reads several teams and the request must name one in `Totem-Team`; `secret_key_in_browser` means the key was used from a browser, so treat it as leaked. - **`401`: fix the key.** Do not retry in a loop: repeated failures lock your IP address or your key out for a while. `key_expired` means the key was rolled and its grace period is over: deploy the new key. - **`403`: access is refused for now.** Keep your data and your cursors, and skip the team or the endpoint. Some refusals can end on their own, such as `capability_disabled` or a team whose access Totem has paused; others need someone to act, such as `plan_not_entitled` or `ip_not_allowed`; and `sector_restricted` does not end by waiting: it lasts as long as Totem's current sector policy applies, and the [changelog](https://totemstudio.ai/developers/changelog) records any change to it. Each code below says which. While access is refused, the changes feed delivers nothing for the team, removals included, so if a refusal lasts longer than your retention policy allows, delete your copy. - **`404`: check what you asked for.** `resource_missing` means the record does not exist, is gone, or is not ready yet; the changes feed delivers new records once they are ready. `team_not_accessible` means the key may no longer read the team: [confirm it with `GET /v1/teams`](https://totemstudio.ai/developers/sync#when-teams-change) before you delete anything. A `404` without a JSON error body comes from the firewall: check the method and the path. - **`409` and `410`: the changes feed needs you to act.** `resync_required` and `sync_cursor_expired` ask for a [full resync](https://totemstudio.ai/developers/sync#full-resync). `capture_not_ready` asks you to wait a few minutes, while the key's other teams keep syncing. - **`429`: slow down.** Wait the seconds in `Retry-After`, then retry the same request. `rate_limited` usually clears within seconds, but after an [authentication lockout](https://totemstudio.ai/developers/authentication#failed-attempts) it lasts 600 seconds. `quota_exceeded` lasts until 00:00 UTC, when the daily quota resets. A `429` from the firewall has no `Retry-After`: back off exponentially. [Rate limits](https://totemstudio.ai/developers/rate-limits) explains both limits. - **`5xx`: retry later.** Retry with exponential backoff, keeping your cursors. If it keeps failing, contact [Support](https://totemstudio.ai/developers/support) with the `Totem-Request-Id`. In every case, never save a changes-feed cursor after a failed request. ## All error codes Each code has its own anchor, which is where `doc_url` points. | Status | Code | When it happens | What to do | Retry | | --- | --- | --- | --- | --- | | 400 | `invalid_request` | Something in the request is not valid: an unknown, repeated or out-of-range query parameter, a query string or body on an endpoint that takes none, a cursor issued for another endpoint, filter or team, a malformed `Totem-Team` header, or a body that is not the JSON this endpoint expects. `param` names the parameter, header or field at fault. A conversations cursor is also refused once the conversation it points at has been erased. | Correct what `param` names before you retry: the same request fails the same way. For a cursor, send the `next_cursor` you received from the same endpoint, with the same filters and team. If a conversations cursor you did not change is refused, start that list again from the first page. | No. The same request fails the same way until you act on it. | | 400 | `team_required` | The key reads more than one team and this endpoint reads one team's data, but the request does not say which team. | Send the team's id (`team_…`, as listed by `GET /v1/teams`) in the `Totem-Team` header. | No. The same request fails the same way until you act on it. | | 400 | `secret_key_in_browser` | The request carried a secret key together with an `Origin` header, which means it came from a browser. Totem refuses it before reading anything. | Call the API only from your server. If the key has been in a web page, a mobile app or a browser extension, treat it as leaked: roll it and mark it as compromised, so the old key stops working at once. | No. The same request fails the same way until you act on it. | | 401 | `invalid_credentials` | The `Authorization` header is missing or malformed, or the key is unknown, wrong or revoked, or its integration is no longer active. | Send `Authorization: Bearer ` with a current secret key. Do not retry in a loop: repeated failures from one IP address, or against one key, lock it out for a while with `429 rate_limited`. | No. The same request fails the same way until you act on it. | | 401 | `key_expired` | The key was rolled and its grace period is over. | Deploy the key that replaced it: the old key is past its grace period and will not work again. If there is no replacement, create a new key in Settings → Developers. | No. The same request fails the same way until you act on it. | | 403 | `insufficient_scope` | The key lacks a permission this endpoint needs. Every v1.0a key holds the full read set, so this should not happen. | Contact support and quote the `Totem-Request-Id` of the response. | No. The same request fails the same way until you act on it. | | 403 | `plan_not_entitled` | The organization's plan does not include the Totem API at the moment. | Keep your data and cursors, and pause calls until the customer has a plan with API access again; then continue where you left off. While access is refused, the feed delivers nothing for the key's teams, removals included: if the refusal lasts longer than your retention policy allows, delete your copy. | Later, once access is restored. Keep your data and cursor meanwhile. | | 403 | `ip_not_allowed` | The key is valid, but the request came from an IP address outside the integration's allowlist. | Call from an allowed address, or ask an administrator to add yours to the allowlist in Settings → Developers. | No. The same request fails the same way until you act on it. | | 403 | `team_state_restricted` | The team is restricted for now: it is disabled (for example, suspended for non-payment), archived or billing-only, or its API access is paused. `GET /v1/teams` still lists it. | Keep the team's data and cursor, skip it for this run and try again later. The key's other teams are not affected. While the team is restricted, the feed delivers nothing for it, removals included: if the restriction lasts longer than your retention policy allows, delete the team's copy. | Later, once access is restored. Keep your data and cursor meanwhile. | | 403 | `sector_restricted` | Totem does not export this team's data through the API because the team works in a sensitive sector, such as health care, where its records can reveal health information. | Skip the team: waiting does not change this. The feed delivers nothing for it, removals included, so keep what you already have only as long as your retention policy allows, then delete the team's copy. Talk to the customer or to Totem if you need its data. | No. Waiting does not change it. | | 403 | `capability_disabled` | Totem has temporarily switched this endpoint off for every key. | Keep your data and cursors and try again later; the other endpoints keep working. | Later, once access is restored. Keep your data and cursor meanwhile. | | 404 | `resource_missing` | The record does not exist, was removed, belongs to a team this key does not read, or was created so recently that Totem has not prepared it yet. A `GET` or `POST` under `/v1/` that matches no endpoint also gets this code. Any other method, and any path outside `/v1/`, gets a plain `404` from the firewall in front of the API, with no problem body and no `Totem-Request-Id`. | Check the id and the path. If the record was created moments ago, do not retry in a loop: the changes feed delivers it as soon as it is ready. | No. The same request fails the same way until you act on it. | | 404 | `team_not_accessible` | The `Totem-Team` header names a team this key does not read: one it never covered, one removed from the key or from the organization, or one that was deleted. Without the header, it means the key no longer reads any team. | Re-read `GET /v1/teams` before you act. If the team is no longer listed, delete its copy and its cursor; if it is still listed, keep everything and try again on the next run. | Only if `GET /v1/teams` still lists the team: then on the next run. | | 409 | `resync_required` | Totem cannot continue the feed from this cursor: the data was restored from a backup, or a gap was found in the team's changes. | Run a full resync: forget the versions you stored for the team, read the feed again without a cursor, and once it has caught up delete every local record of the team it did not deliver. Keep using your current copy until then. | No. The same request fails the same way until you act on it. | | 409 | `capture_not_ready` | Totem is still preparing this team's data for the API. This happens once per team, after it is first added to any key; adding it to another key later does not repeat it. | Keep whatever you hold for the team and try again in a few minutes; the key's other teams keep syncing meanwhile. | Yes, later: the same request succeeds once the data is ready. | | 410 | `sync_cursor_expired` | The cursor is more than 30 days old, counted from the point in the feed it reached (or, while an initial sync is still running, from when that sync started). | Run a full resync, as for `resync_required`. To prevent it, save the `next_cursor` of every page, empty pages included: the cursor of a team you poll regularly never ages out. | No. The same request fails the same way until you act on it. | | 429 | `rate_limited` | The key went over its rate limit (items per minute), or too many failed authentication attempts from this IP address or against this key have locked it out for a while. | Wait the seconds in `Retry-After`, then retry the same request. Each request sets aside its full `limit` up front and gives back what it does not use, so a smaller `limit` keeps a nearly spent allowance moving. | Yes, after the seconds in `Retry-After`. | | 429 | `quota_exceeded` | The key has spent its daily quota of items. Every item returned by the changes feed and by every list counts toward it, including `GET /v1/teams`, `GET /v1/members`, `GET /v1/booking_services` and a lead's activities. The quota resets at 00:00 UTC. | Stop syncing until 00:00 UTC (`Retry-After` counts the seconds) and resume then with the same cursor: nothing is lost. Requests for one record, such as `GET /v1/leads/{id}`, search and `GET /v1/me` do not count toward the quota and keep working. | Yes, after the seconds in `Retry-After`. | | 500 | `internal_error` | Something went wrong inside Totem while answering this request. | Retry with exponential backoff. If it keeps failing, contact support and quote the `Totem-Request-Id`. | Yes, with exponential backoff. | | 503 | `api_disabled` | Totem has temporarily switched the API off for every key. | Keep your data and cursors and retry later with exponential backoff. | Yes, with exponential backoff. | | 503 | `service_unavailable` | Reserved: the API itself does not return this code today. A `503` from the load balancer or the hosting platform in front of the API arrives without a problem body; treat it like this code. | Retry with exponential backoff; your data and cursors stay valid. | Yes, with exponential backoff. | --- # Rate limits > How much a key can read per minute and per day, what counts toward each limit, how to retry after a 429, and how long an initial sync takes. Source: https://totemstudio.ai/developers/rate-limits Each key has two limits, shared by every team it reads: a **rate limit** on how fast it reads, and a **daily quota** on how much. Both count **items**, not requests. An item is one object in a response: a feed page of 100 changes is 100 items, removals included. > **Note: Approximate during the Beta** > > In the Beta, each server that answers your requests counts these limits, and the lockout after failed attempts, on its own, and a restart starts its count again. So the numbers on this page are approximate: a key can sometimes read more, and a request refused with `429` can succeed when it is retried. Design your integration for the numbers on this page. ## Two limits per key Which requests count toward which limit: | Requests | Rate limit | Daily quota | |---|---|---| | The changes feed, every list, a lead's activities, `GET /v1/teams`, `GET /v1/members` and `GET /v1/booking_services` | Yes | Yes | | A request for one record, such as `GET /v1/leads/{id}`, and also `POST /v1/leads/search` and `GET /v1/me` | Yes | No | | `GET /v1/health` | No | No | Each endpoint in the reference shows its row: **Sync** for the first, **Read** for the second. ### Rate limit A key reads up to 2,000 items a minute, with bursts of up to 10,000. Capacity you do not use builds back up at that pace until the burst is full again. Past the limit, the API answers `429 rate_limited`, and `Retry-After` says how long until there is room for your request. How a request counts: - Before it runs, a request reserves its `limit`. A list or feed request that sends no `limit` reserves 25; a request for one record reserves one. - Once it has run, it is charged for the items it returned, and at least one. A short page gives back the difference, but a request that returns nothing still costs one item: a search that finds no lead, an id that answers `404`, or any other error it gets once it runs, `quota_exceeded` included. - A request refused before it runs is not charged, such as a failed authentication, a missing permission or, on a route that takes `Totem-Team`, a team the key cannot read or that is not ready yet. A request that names a record by its id, such as `GET /v1/leads/{id}` or a lead's activities, checks its team as it runs, so a refusal for that team costs one item. - A request for one record is charged for what it returns: `GET /v1/leads/{id}` returns a lead with its two facets, so it counts up to three items. A search counts each lead it returns, or one when it finds none. Because a request reserves its full `limit` first, a smaller `limit` lets you keep reading when little capacity is left. `429 rate_limited` can also mean that your IP address or your key is locked out after too many failed authentication attempts. `Retry-After` tells the two apart: a lockout always sends 600 seconds, the full length of the lock, while the rate limit asks for the few seconds your request needs. Slowing down does not end a lockout: find the process at that address that sends a missing, malformed, wrong or unknown key, fix it, then wait. [Authentication](https://totemstudio.ai/developers/authentication#failed-attempts) explains what counts as a failed attempt. ### Daily quota Each UTC day, a key can read 250,000 items, or 3 times the number of records in its teams if that is more. Records of every kind count toward that number, removals included. Every item returned by the requests in the first row of the table counts toward the quota, whatever its kind. The number of records is taken at the key's first request of each UTC day, so a team added to the key later that day may raise the quota only from the next day. Plan the initial sync of a large team for when its quota allows it. The quota leaves room for an initial sync of every team and a day of polling. Re-reading whole lists again and again is what spends it. Past the quota, those requests answer `429 quota_exceeded` until 00:00 UTC, when the quota resets. `Retry-After` counts the seconds until then. Requests for one record, search and `GET /v1/me` keep working. ## Retry after a 429 `Retry-After` comes with every `429` the API itself returns, and it is always a whole number of seconds. Wait at least that long, then send the same request again, with the same cursor. Nothing is lost while you wait: the feed keeps every change for you. - After `rate_limited`, the wait is usually a few seconds, or 600 seconds after an [authentication lockout](https://totemstudio.ai/developers/authentication#failed-attempts). - After `quota_exceeded`, the wait runs until 00:00 UTC and can last hours. Do not keep a process asleep that long: end the run, and schedule the next one for that time. The [reference client](https://totemstudio.ai/developers/sync#tested-reference-client) does this with its `retry_later` outcome. A `429` from the firewall in front of the API has no `Retry-After` and no JSON error body: back off exponentially. A `5xx` carries no `Retry-After` either: retry it with exponential backoff too. ## Initial sync estimates The table gives the minimum time the rate limit adds to an initial sync at the limits on this page, for one key with no other traffic; during the Beta it is approximate, as the note at the top of this page explains. It counts records of every kind, one item each: a lead with its attribution, its response and three activities is six records. Network time, and the time Totem needs to prepare a team just added to a key, come on top. A copy that fits within the burst shows *Under 1 minute*: the rate limit adds nothing to it, but it still takes the time of its requests. | Records (all kinds) | Minimum time | | ---: | ---: | | 1,000 | Under 1 minute | | 10,000 | Under 1 minute | | 50,000 | 20 min | | 100,000 | 45 min | | 250,000 | 2 h | Minimum duration of an initial sync, set by the feed's rate limit alone: the first 10,000 records at once, then 2,000 records per minute. A real sync takes longer. ## Good practice - Run one sync process per key and team. Several workers on one key share the same limits, so they only make each other wait. - Ask for full pages of 100 items during an initial sync. - Poll the changes feed every 1 to 5 minutes. A poll that finds nothing new costs one item of the rate limit and nothing of the daily quota. - Read members and bookable services when you start a team, when you meet an id you do not know, and once a day: not on every run, and not again for an id the last read did not resolve. - Never create more integrations or keys to get around the limits. If they are too low for what you need, [tell us](https://totemstudio.ai/developers/support). --- # Account, teams and members > Check that a key works and what it can read, list its teams with their time zones, list each team's members, and check that the API is up. Source: https://totemstudio.ai/developers/reference-account ## Overview Start here to find out what your key can read. - [`GET /v1/me`](https://totemstudio.ai/developers/reference-account#me.get) names the integration behind your key, the last four characters of the key, the teams it reads and its scopes. Call it first to check that a key works. - [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list) lists every team the key reads, with its name, time zone and default country, in one response. It includes teams that are restricted for now. [`GET /v1/teams/{id}`](https://totemstudio.ai/developers/reference-account#teams.get) returns one of them. - [`GET /v1/members`](https://totemstudio.ai/developers/reference-account#members.list) lists a team's members, by display name only, in one response. Totem exports no email address, phone number or role for a member. - [`GET /v1/health`](https://totemstudio.ai/developers/reference-account#health) tells you whether the API is up. It is the only endpoint that needs no key. A **team** is a workspace in Totem, with its own boards, members, leads and bookings. A key reads one team or several teams of the same organization. `GET /v1/me` and `GET /v1/teams` describe the key and never take the `Totem-Team` header; `GET /v1/members` reads one team, so a key with several teams names it in that header. [Authentication](https://totemstudio.ai/developers/authentication#teams-and-the-totem-team-header) explains when to send it. Every timestamp the API returns is in UTC. Use a team's `timezone` to show its times in local time, never to store them. A member's id belongs to one team: the same person has a different `mem_` id in each team. Members are not in the changes feed, so read the list when you start syncing a team, when a record names a member you do not know, and at least once a day. A member who leaves the team drops out of the list, while older records keep naming its id: do not re-read the list for an id it did not resolve last time, and if you show names for older records, keep the name from an earlier read. Disabled members stay listed, with nothing that marks them as disabled. Read [`GET /v1/teams`](https://totemstudio.ai/developers/reference-account#teams.list) on every sync run, since a team that is no longer listed has left the key. ## Endpoints | Method | Path | Summary | | --- | --- | --- | | GET | `/v1/me` | Get the integration behind this key | | GET | `/v1/teams` | List the teams this key reads | | GET | `/v1/teams/{id}` | Get a team | | GET | `/v1/members` | List team members | | GET | `/v1/health` | Check that the API is up | ## GET /v1/me **Get the integration behind this key** Returns the integration this key belongs to: its name, the last four characters of the key, the teams it reads today and its scopes. Call it first to check that a key works and to see which teams it reads; `GET /v1/teams` has their details. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Read: counts toward the rate limit ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta ### Returns Returns a [`Me`](https://totemstudio.ai/developers/objects#Me) object. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"integration"` | Yes | Always `integration`. | | `id` | `string` | Yes | Public id of the integration. | | `livemode` | `boolean` | Yes | `true` for a real key; `false` in example data. | | `name` | `string` | Yes | The name an administrator gave the integration. | | `key` | `object` | Yes | The key used for this request. | | `key.last4` | `string` | Yes | The last four characters of the key used for this request. | | `teams` | `Array` | Yes | The teams this key reads today. Their details are in `GET /v1/teams`. | | `scopes` | `Array<"teams:read" \| "crm:read" \| "calls:read" \| "messages:read" \| "calendar:read">` | Yes | The key's scopes: the fixed read set in v1.0a. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/me' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/me', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/me", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Example response ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` ### Errors Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/teams **List the teams this key reads** Lists every team this key reads, in one response that is never paged, including teams that are restricted for now (their other endpoints answer `403`). Re-read it on every sync run: a team that is no longer listed has left the key, so delete its copy and its cursor. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `teams:read` ### Returns Returns a list of [`Team`](https://totemstudio.ai/developers/objects#Team) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Team) | Yes | Every team this key reads, restricted ones included. | | `has_more` | `false` | Yes | Always `false`: this list is never paged. | | `next_cursor` | `null` | Yes | Always `null`: there is no next page. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/teams' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/teams', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/teams", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/teams/{id} **Get a team** Returns one team this key reads: its name, time zone and default country. A team the key does not read answers `404 resource_missing`. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Read: counts toward the rate limit ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `teams:read` ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | The team's public id. | ### Returns Returns a [`Team`](https://totemstudio.ai/developers/objects#Team) object. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"team"` | Yes | Always `team`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `name` | `string \| null` | Yes | The team's name as it appears in Totem, or null when none is stored. | | `timezone` | `string` | Yes | The team's IANA time zone: its business-hours zone when that is valid, otherwise its default country's (Europe/Madrid when neither is set). Interpret the team's local times with it. | | `default_country` | `string \| null` | Yes | The team's default country (ISO 3166-1 alpha-2), which Totem uses when someone types a phone number without a prefix; null when none is set. The API does not apply it: `Lead.phone` is validated without a default country, so a number stored without its country prefix reads `phone_status: invalid`. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/teams/team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/teams/team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/teams/team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Example response ```json { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ``` ### Errors - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`resource_missing`](https://totemstudio.ai/developers/errors#resource_missing): Resource not found Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/members **List team members** Lists every member of the team in one response that is never paged and takes no `limit` or `cursor`. Members are not in the changes feed: read this list when you start syncing a team, again when an item names a member you do not know, and at least once a day. A member who leaves the team drops out of the list while older records keep naming its id, so keep a name you need from an earlier read. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `teams:read` ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`Member`](https://totemstudio.ai/developers/objects#Member) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Member) | Yes | Every member of the team. | | `has_more` | `false` | Yes | Always `false`: this list is never paged. | | `next_cursor` | `null` | Yes | Always `null`: there is no next page. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/members' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/members', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/members", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "object": "member", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "display_name": "Sam Lee" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/health **Check that the API is up** Tells you whether the API is up; call it with `GET`, because the firewall in front of the API answers any other method, `HEAD` included, with a plain `404`. It needs no key and takes no query string: any parameter, even a cache-buster, answers `400 invalid_request`. The `Totem-Release` response header names the release that answered. - **Authentication:** No key - **Stability:** Beta ### Returns Returns a `Health` object. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"health"` | Yes | Always `health`. | | `status` | `"ok"` | Yes | Always `ok`: when the API is not available, the answer is an error instead. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/health' ``` ```javascript const response = await fetch('https://api.totemstudio.ai/v1/health'); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data); ``` ```python # pip install requests import requests response = requests.get( "https://api.totemstudio.ai/v1/health", timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data) ``` ### Example response ```json { "object": "health", "status": "ok" } ``` ### Errors - `400` [`invalid_request`](https://totemstudio.ai/developers/errors#invalid_request): Invalid request - `400` [`secret_key_in_browser`](https://totemstudio.ai/developers/errors#secret_key_in_browser): Secret key sent from a browser - `500` [`internal_error`](https://totemstudio.ai/developers/errors#internal_error): Internal error - `503` [`service_unavailable`](https://totemstudio.ai/developers/errors#service_unavailable): Service unavailable ## The Me object The integration that owns the key, with the teams the key reads. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"integration"` | Yes | Always `integration`. | | `id` | `string` | Yes | Public id of the integration. | | `livemode` | `boolean` | Yes | `true` for a real key; `false` in example data. | | `name` | `string` | Yes | The name an administrator gave the integration. | | `key` | `object` | Yes | The key used for this request. | | `key.last4` | `string` | Yes | The last four characters of the key used for this request. | | `teams` | `Array` | Yes | The teams this key reads today. Their details are in `GET /v1/teams`. | | `scopes` | `Array<"teams:read" \| "crm:read" \| "calls:read" \| "messages:read" \| "calendar:read">` | Yes | The key's scopes: the fixed read set in v1.0a. | ### Example ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` ## The Team object A team this key reads, with its time zone and default country. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"team"` | Yes | Always `team`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `name` | `string \| null` | Yes | The team's name as it appears in Totem, or null when none is stored. | | `timezone` | `string` | Yes | The team's IANA time zone: its business-hours zone when that is valid, otherwise its default country's (Europe/Madrid when neither is set). Interpret the team's local times with it. | | `default_country` | `string \| null` | Yes | The team's default country (ISO 3166-1 alpha-2), which Totem uses when someone types a phone number without a prefix; null when none is set. The API does not apply it: `Lead.phone` is validated without a default country, so a number stored without its country prefix reads `phone_status: invalid`. | ### Example ```json { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ``` ## The Member object A team member, by display name only: Totem exports no email, phone or role. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"member"` | Yes | Always `member`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `display_name` | `string \| null` | Yes | The member's display name, or first and last name, or null when none is stored. Totem exports no email, phone or role. | ### Example ```json { "id": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "object": "member", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "display_name": "Sam Lee" } ``` --- # Leads, boards and activities > List, fetch and search a team's leads and clients, read each lead's activities, and list the boards and stages they move through. Source: https://totemstudio.ai/developers/reference-leads ## Overview A **lead** is a person or company in a team's pipeline: their contact details, where they stand (`board` and `stage`), their `status` and their `contact_permissions`. A record with `record_kind: "client"` is a client rather than a lead: it has no pipeline status, so its `status` is `null`. Leads and clients come from the same endpoints. The eight statuses are `new`, `contacted`, `qualified`, `negotiating`, `converted`, `lost`, `archived` and `unreachable`. `converted` does not mean "won". A lead whose status this version of the API does not know keeps `record_kind: "lead"` and reads `status: null`. An archived lead says why in `archive_reason`, including `merged` and `family_transfer` for the leads that [merges and transfers](https://totemstudio.ai/developers/sync#removals-merges-and-transfers) leave behind. Apply `contact_permissions` before any of your systems contacts the person: [Keep a copy in sync](https://totemstudio.ai/developers/sync#contact-permissions-first) explains each one. ### Attribution and response [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/reference-leads#leads.get) returns the lead with two facets: `attribution`, where it came from, and `response`, how the team has answered it across its conversations and calls. Each facet has its own `version`, so a new message changes the lead's response without changing the lead. A facet is `null` while Totem is still preparing it. Lists return the lead alone. The changes feed delivers the facets as items of their own, `lead_attribution` and `lead_response`, with the lead's id. [Data coverage](https://totemstudio.ai/developers/coverage) explains what each facet can and cannot tell you. ### Search by phone or email [`POST /v1/leads/search`](https://totemstudio.ai/developers/reference-leads#leads.search) finds a team's leads by exact phone or email. Send exactly one of `phone` or `email` in the JSON body, never in a URL: ```bash curl https://api.totemstudio.ai/v1/leads/search \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" \ -H "Content-Type: application/json" \ -d '{"email": "alex.rivera@example.com"}' ``` Matching is exact on the number as Totem stores it, not on the normalized `phone` a lead shows. A phone matches a record stored in E.164, with or without its leading `+`; a record stored with spaces or without its country prefix is not found, even by the `phone` the API returns for it. An email matches the address as you sent it or in lower case. Records Totem has not delivered to the API yet are skipped until it does. Search returns archived, merged and client records too; follow `merged_into` to the lead that was kept. It returns at most 10 leads, in one response that never pages, so when many records share a phone number or an email address, it may not return them all. ### Activities [`GET /v1/leads/{id}/activities`](https://totemstudio.ai/developers/reference-leads#leads.activities) returns a lead's history, a page at a time: each change of its stage, status or assigned members, and when it happened. `actor_kind` says who made it: a person (`human`), the AI (`ai`), Totem automation (`system`), an API caller (`api`), or `unknown`. Totem knows who made a change only when the same save wrote the lead's status, so a stage or assignee change made on its own reads `unknown`. Pages are not in date order, so sort by `occurred_at`. Activities never change once written, but they are removed together with their lead, and new ones also arrive in the changes feed. ### Boards and stages A **board** is a pipeline, with its stages in order. A lead's `stage` names the step it is on. Each stage has a `mode` that says who moves leads: people only (`standard`), people and the AI, which may move leads in (`supervised`), or the AI, which runs outreach and moves leads in and out (`automated`). [`GET /v1/boards`](https://totemstudio.ai/developers/reference-leads#boards.list) returns a team's boards with their stages, a page at a time. Boards also travel in the changes feed; read this list when a lead names a stage you have not seen yet. A stage with no stored position is left out of its board, so do not re-read the list for a stage it did not include last time. ## Endpoints | Method | Path | Summary | | --- | --- | --- | | GET | `/v1/leads` | List leads | | GET | `/v1/leads/{id}` | Get a lead with its attribution and response | | POST | `/v1/leads/search` | Find leads by exact phone or email | | GET | `/v1/leads/{id}/activities` | List a lead's activities | | GET | `/v1/boards` | List boards with their stages | ## GET /v1/leads **List leads** Returns a page of the team's leads and clients, in a stable order that carries no meaning. Filter by at most one of `board`, `stage` or `status`; a board or stage the team does not have returns an empty page. To keep a copy up to date, follow the changes feed instead of re-reading this list. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `crm:read` ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `integer` | No | Items per page, 1–100. Defaults to 25. | | `cursor` | `string` | No | The `next_cursor` of the previous page. Omit it for the first page. | | `board` | `string` | No | Only leads on this board. At most one of `board`, `stage` and `status`. | | `stage` | `string` | No | Only leads in this stage. At most one of `board`, `stage` and `status`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable"` | No | Only leads with this status. At most one of `board`, `stage` and `status`. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`Lead`](https://totemstudio.ai/developers/objects#Lead) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Lead) | Yes | The items of this page. | | `has_more` | `boolean` | Yes | `true` when another page follows: fetch it with `next_cursor`, even if this page holds fewer items than `limit`. | | `next_cursor` | `string \| null` | Yes | Opaque cursor for the next page, or null on the last page. Never parse it. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/leads?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/leads?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/leads?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoibGVhZCIsImEiOiJsZWFkX3I3MkVmYlJOVXp1cU9SRkpSME84SHhBd0VYZDkzakdVVFlNYU1NWEtTRjBRYkxTOENpVUtKcEVQMzRMRlFHIiwicyI6ImYzNTI3NzI2MTkwYiJ93iHyRg" } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/leads/{id} **Get a lead with its attribution and response** Returns one lead with its two facets: `attribution` (where it came from) and `response` (how the team has answered it), each with its own `version`. A lead created moments ago can answer `404 resource_missing` until Totem has prepared it; the changes feed delivers it then. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Read: counts toward the rate limit ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `crm:read` ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | The lead's public id. | ### Returns Returns a [`LeadDetail`](https://totemstudio.ai/developers/objects#LeadDetail) object. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead"` | Yes | Always `lead`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the record belongs to. | | `record_kind` | `"lead" \| "client"` | Yes | `lead` when the record has a pipeline status, otherwise `client`. A lead whose status this version does not know reads `status: null`. | | `name` | `string \| null` | Yes | First name or display name, or null. | | `surname` | `string \| null` | Yes | Surname, or null. | | `email` | `string \| null` | Yes | Email address as stored, or null. | | `company` | `string \| null` | Yes | Company, or null. | | `phone` | `string \| null` | Yes | Phone in E.164, only when the stored number validates; otherwise null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Yes | `valid` (see `phone`), `invalid` (a number is stored but does not validate, for example one stored without its country prefix) or `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | Pipeline status; null for a client record, or for a status this version does not know. `converted` is not "won". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Yes | Who set the current status: a person, the AI, Totem automation, an API caller, or unknown (no recorded transition lands on it). Null when `status` is null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Yes | Why the record was archived, as a closed code; stored free text is `other`. Null when none is stored. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | The status the lead had before its current archive; null while it is not archived or when unknown. | | `contact_permissions` | `object` | Yes | Contact permissions. Apply them before any outreach from your own systems. | | `contact_permissions.do_not_contact` | `boolean` | Yes | True when the person asked never to be contacted (the `opt_out` archive reason). Stop every channel. | | `contact_permissions.email_opt_out` | `boolean` | Yes | True when the person unsubscribed from marketing and follow-up email. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Yes | When the email opt-out was recorded, or null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Yes | WhatsApp consent: false when refused, true when recorded, null when unknown. | | `contact_permissions.provenance` | `object` | Yes | The source of each permission. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Yes | Where `do_not_contact` comes from, or null when it is false. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Yes | Who recorded the email opt-out: the unsubscribe link, a person in Totem, or unknown. Null when there is none. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Yes | `archive_reason` when a refusal is recorded as the archive reason; `stored` for the value kept on the record, which team members can edit. Null when unknown. | | `merged_into` | `string \| null` | Yes | The lead this record was merged into, or null (also when that lead no longer exists). | | `transferred_to` | `object \| null` | Yes | Where a transfer to another team of your organization sent this lead, or null. The team may be one your key does not read. | | `transferred_to.team` | `string` | Yes | The destination team. | | `transferred_to.lead` | `string \| null` | Yes | The destination lead, or null when its id cannot be named across teams: an assistant handoff created it, or the transfer landed on an existing lead-ads lead (Meta, Google Ads or LinkedIn) or another lead with a fixed id. | | `revived_at` | `string (date-time) \| null` | Yes | When the contact last came back on their own and revived an archived lead, or null. | | `pipeline_cleared` | `boolean` | Yes | True when the lead was converted with "clear pipeline data": board, stage, priority, assignees, estimated value and the source fields other than `source_channel` are null because they were cleared. | | `converted_at` | `string (date-time) \| null` | Yes | When the lead was converted, while its status is `converted`; otherwise null. | | `value_at_conversion` | `number \| null` | Yes | The estimated value when it was converted, in major units. Survives "clear pipeline data". | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Yes | Priority, or null. | | `tags` | `Array` | Yes | Labels as stored. `lead` and `booking` are system labels. | | `board` | `string \| null` | Yes | The board, resolved through the stage when it has one; or null. | | `stage` | `string \| null` | Yes | The stage, or null. | | `assignees` | `Array \| null` | Yes | Assigned members, sorted. Null when the record has no pipeline data. | | `estimated_value` | `number \| null` | Yes | Estimated value in major units as stored (decimals allowed), or null. | | `estimated_value_currency` | `null` | Yes | Always null: Totem stores no currency with the value. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Yes | The stored lead source; `api` also covers leads created by Totem itself (for example from a conversation or a call), see `source_detail`. Null when none is stored. | | `source_channel` | `string \| null` | Yes | The channel the record started on (for example `whatsapp`, `phone`, `widget`), or null. Team members can edit it. | | `source_detail` | `string \| null` | Yes | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` or `unknown`; null when `source` needs no detail. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Yes | `server_written` when Totem set the source from what the ad platform reported; otherwise `member_editable` (team members can change it). Null with `source`. | | `created_at` | `string (date-time)` | Yes | When the record was created. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | | `attribution` | [`LeadAttribution \| null`](https://totemstudio.ai/developers/objects#LeadAttribution) | Yes | The lead's attribution facet, or null while Totem is still preparing it. | | `response` | [`LeadResponse \| null`](https://totemstudio.ai/developers/objects#LeadResponse) | Yes | The lead's response facet, or null while Totem is still preparing it. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Example response ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z", "attribution": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" }, "response": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ``` ### Errors - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`resource_missing`](https://totemstudio.ai/developers/errors#resource_missing): Resource not found - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## POST /v1/leads/search **Find leads by exact phone or email** Finds the team's leads whose phone or email matches exactly, including archived, merged and client records (follow `merged_into` to the lead that was kept). Send exactly one of `phone` or `email` in the JSON body, never in the URL. Returns at most 10 leads, chosen from the first 20 records that match, in one response that never paginates. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Read: counts toward the rate limit ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `crm:read` ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Request body Exactly one of `phone` or `email`, in the JSON body and never in the URL. | Field | Type | Required | Description | | --- | --- | --- | --- | | `phone` | `string` | Conditional | A phone number in E.164 (`+12025550123`). It finds the number stored in E.164, with or without the `+`, and in the layouts phone libraries write: `+1 202-555-0123`, `(202) 555-0123` and `2025550123`. Other layouts, such as `202 555 0123`, are not found. | | `email` | `string` | Conditional | An email address. It finds records stored exactly as sent, or stored in lowercase. | ### Returns Returns a list of [`Lead`](https://totemstudio.ai/developers/objects#Lead) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Lead) | Yes | The matching leads: at most 10. | | `has_more` | `false` | Yes | Always `false`, even when more records match: search returns at most 10 leads and has no next page. | | `next_cursor` | `null` | Yes | Always `null`: search does not paginate. | ### Example request ```bash curl -X POST 'https://api.totemstudio.ai/v1/leads/search' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" \ -H "Content-Type: application/json" \ --data '{"phone": "+12025550123"}' ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}`, 'Content-Type': 'application/json', }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/leads/search', { method: 'POST', headers, body: JSON.stringify({ phone: '+12025550123' }), }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.post( "https://api.totemstudio.ai/v1/leads/search", headers=headers, json={"phone": "+12025550123"}, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/leads/{id}/activities **List a lead's activities** Returns a page of the lead's activities: each change of its stage, status or assigned members, with when it happened and, when Totem recorded it, who made it. Pages are not in date order, so sort by `occurred_at`. Activities never change once written, and new ones also arrive in the changes feed. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `crm:read` ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | The lead's public id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `integer` | No | Items per page, 1–100. Defaults to 25. | | `cursor` | `string` | No | The `next_cursor` of the previous page. Omit it for the first page. | ### Returns Returns a list of [`Activity`](https://totemstudio.ai/developers/objects#Activity) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Activity) | Yes | The items of this page. | | `has_more` | `boolean` | Yes | `true` when another page follows: fetch it with `next_cursor`, even if this page holds fewer items than `limit`. | | `next_cursor` | `string \| null` | Yes | Opaque cursor for the next page, or null on the last page. Never parse it. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG/activities?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG/activities?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/leads/lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG/activities?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "act_aBgzeetVKDEjpAAkXfEFF53ZGd2ZMmGhUDKgagqLbh6c7R1GqOl0xsFw7pyRhD3SqqYWGMdb371xxj", "object": "activity", "livemode": false, "type": "stage_changed", "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "occurred_at": "2026-09-28T10:20:00.000Z", "actor_kind": "ai", "stage_snapshot": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "name": "Contacted" }, "value_snapshot": 1200, "from": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "to": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "version": 1, "updated_at": "2026-09-28T10:20:01.500Z" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`resource_missing`](https://totemstudio.ai/developers/errors#resource_missing): Resource not found - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/boards **List boards with their stages** Returns a page of the team's boards, each with its stages in pipeline order. Boards also arrive in the changes feed; read this list when you need them at once, for example when a lead names a stage you have not seen yet. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `crm:read` ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `integer` | No | Items per page, 1–100. Defaults to 25. | | `cursor` | `string` | No | The `next_cursor` of the previous page. Omit it for the first page. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`Board`](https://totemstudio.ai/developers/objects#Board) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Board) | Yes | The items of this page. | | `has_more` | `boolean` | Yes | `true` when another page follows: fetch it with `next_cursor`, even if this page holds fewer items than `limit`. | | `next_cursor` | `string \| null` | Yes | Opaque cursor for the next page, or null on the last page. Never parse it. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/boards?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/boards?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/boards?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "object": "board", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "New patients", "is_default": true, "is_archived": false, "appointment_booked_stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "stages": [ { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "name": "New", "position": 0, "is_default": true, "is_final": false, "is_archive": false, "mode": "automated" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "name": "Contacted", "position": 1, "is_default": false, "is_final": false, "is_archive": false, "mode": "supervised" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "name": "Visit booked", "position": 2, "is_default": false, "is_final": true, "is_archive": false, "mode": "standard" } ], "version": 4, "updated_at": "2026-09-02T16:40:00.000Z" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## The Lead object A lead or client record: lead fields only. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead"` | Yes | Always `lead`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the record belongs to. | | `record_kind` | `"lead" \| "client"` | Yes | `lead` when the record has a pipeline status, otherwise `client`. A lead whose status this version does not know reads `status: null`. | | `name` | `string \| null` | Yes | First name or display name, or null. | | `surname` | `string \| null` | Yes | Surname, or null. | | `email` | `string \| null` | Yes | Email address as stored, or null. | | `company` | `string \| null` | Yes | Company, or null. | | `phone` | `string \| null` | Yes | Phone in E.164, only when the stored number validates; otherwise null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Yes | `valid` (see `phone`), `invalid` (a number is stored but does not validate, for example one stored without its country prefix) or `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | Pipeline status; null for a client record, or for a status this version does not know. `converted` is not "won". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Yes | Who set the current status: a person, the AI, Totem automation, an API caller, or unknown (no recorded transition lands on it). Null when `status` is null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Yes | Why the record was archived, as a closed code; stored free text is `other`. Null when none is stored. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | The status the lead had before its current archive; null while it is not archived or when unknown. | | `contact_permissions` | `object` | Yes | Contact permissions. Apply them before any outreach from your own systems. | | `contact_permissions.do_not_contact` | `boolean` | Yes | True when the person asked never to be contacted (the `opt_out` archive reason). Stop every channel. | | `contact_permissions.email_opt_out` | `boolean` | Yes | True when the person unsubscribed from marketing and follow-up email. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Yes | When the email opt-out was recorded, or null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Yes | WhatsApp consent: false when refused, true when recorded, null when unknown. | | `contact_permissions.provenance` | `object` | Yes | The source of each permission. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Yes | Where `do_not_contact` comes from, or null when it is false. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Yes | Who recorded the email opt-out: the unsubscribe link, a person in Totem, or unknown. Null when there is none. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Yes | `archive_reason` when a refusal is recorded as the archive reason; `stored` for the value kept on the record, which team members can edit. Null when unknown. | | `merged_into` | `string \| null` | Yes | The lead this record was merged into, or null (also when that lead no longer exists). | | `transferred_to` | `object \| null` | Yes | Where a transfer to another team of your organization sent this lead, or null. The team may be one your key does not read. | | `transferred_to.team` | `string` | Yes | The destination team. | | `transferred_to.lead` | `string \| null` | Yes | The destination lead, or null when its id cannot be named across teams: an assistant handoff created it, or the transfer landed on an existing lead-ads lead (Meta, Google Ads or LinkedIn) or another lead with a fixed id. | | `revived_at` | `string (date-time) \| null` | Yes | When the contact last came back on their own and revived an archived lead, or null. | | `pipeline_cleared` | `boolean` | Yes | True when the lead was converted with "clear pipeline data": board, stage, priority, assignees, estimated value and the source fields other than `source_channel` are null because they were cleared. | | `converted_at` | `string (date-time) \| null` | Yes | When the lead was converted, while its status is `converted`; otherwise null. | | `value_at_conversion` | `number \| null` | Yes | The estimated value when it was converted, in major units. Survives "clear pipeline data". | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Yes | Priority, or null. | | `tags` | `Array` | Yes | Labels as stored. `lead` and `booking` are system labels. | | `board` | `string \| null` | Yes | The board, resolved through the stage when it has one; or null. | | `stage` | `string \| null` | Yes | The stage, or null. | | `assignees` | `Array \| null` | Yes | Assigned members, sorted. Null when the record has no pipeline data. | | `estimated_value` | `number \| null` | Yes | Estimated value in major units as stored (decimals allowed), or null. | | `estimated_value_currency` | `null` | Yes | Always null: Totem stores no currency with the value. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Yes | The stored lead source; `api` also covers leads created by Totem itself (for example from a conversation or a call), see `source_detail`. Null when none is stored. | | `source_channel` | `string \| null` | Yes | The channel the record started on (for example `whatsapp`, `phone`, `widget`), or null. Team members can edit it. | | `source_detail` | `string \| null` | Yes | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` or `unknown`; null when `source` needs no detail. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Yes | `server_written` when Totem set the source from what the ad platform reported; otherwise `member_editable` (team members can change it). Null with `source`. | | `created_at` | `string (date-time)` | Yes | When the record was created. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ``` ## The LeadDetail object A lead with its `attribution` and `response` facets, each with its own `version`. A facet is `null` only while Totem is still preparing it. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead"` | Yes | Always `lead`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the record belongs to. | | `record_kind` | `"lead" \| "client"` | Yes | `lead` when the record has a pipeline status, otherwise `client`. A lead whose status this version does not know reads `status: null`. | | `name` | `string \| null` | Yes | First name or display name, or null. | | `surname` | `string \| null` | Yes | Surname, or null. | | `email` | `string \| null` | Yes | Email address as stored, or null. | | `company` | `string \| null` | Yes | Company, or null. | | `phone` | `string \| null` | Yes | Phone in E.164, only when the stored number validates; otherwise null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Yes | `valid` (see `phone`), `invalid` (a number is stored but does not validate, for example one stored without its country prefix) or `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | Pipeline status; null for a client record, or for a status this version does not know. `converted` is not "won". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Yes | Who set the current status: a person, the AI, Totem automation, an API caller, or unknown (no recorded transition lands on it). Null when `status` is null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Yes | Why the record was archived, as a closed code; stored free text is `other`. Null when none is stored. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | The status the lead had before its current archive; null while it is not archived or when unknown. | | `contact_permissions` | `object` | Yes | Contact permissions. Apply them before any outreach from your own systems. | | `contact_permissions.do_not_contact` | `boolean` | Yes | True when the person asked never to be contacted (the `opt_out` archive reason). Stop every channel. | | `contact_permissions.email_opt_out` | `boolean` | Yes | True when the person unsubscribed from marketing and follow-up email. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Yes | When the email opt-out was recorded, or null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Yes | WhatsApp consent: false when refused, true when recorded, null when unknown. | | `contact_permissions.provenance` | `object` | Yes | The source of each permission. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Yes | Where `do_not_contact` comes from, or null when it is false. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Yes | Who recorded the email opt-out: the unsubscribe link, a person in Totem, or unknown. Null when there is none. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Yes | `archive_reason` when a refusal is recorded as the archive reason; `stored` for the value kept on the record, which team members can edit. Null when unknown. | | `merged_into` | `string \| null` | Yes | The lead this record was merged into, or null (also when that lead no longer exists). | | `transferred_to` | `object \| null` | Yes | Where a transfer to another team of your organization sent this lead, or null. The team may be one your key does not read. | | `transferred_to.team` | `string` | Yes | The destination team. | | `transferred_to.lead` | `string \| null` | Yes | The destination lead, or null when its id cannot be named across teams: an assistant handoff created it, or the transfer landed on an existing lead-ads lead (Meta, Google Ads or LinkedIn) or another lead with a fixed id. | | `revived_at` | `string (date-time) \| null` | Yes | When the contact last came back on their own and revived an archived lead, or null. | | `pipeline_cleared` | `boolean` | Yes | True when the lead was converted with "clear pipeline data": board, stage, priority, assignees, estimated value and the source fields other than `source_channel` are null because they were cleared. | | `converted_at` | `string (date-time) \| null` | Yes | When the lead was converted, while its status is `converted`; otherwise null. | | `value_at_conversion` | `number \| null` | Yes | The estimated value when it was converted, in major units. Survives "clear pipeline data". | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Yes | Priority, or null. | | `tags` | `Array` | Yes | Labels as stored. `lead` and `booking` are system labels. | | `board` | `string \| null` | Yes | The board, resolved through the stage when it has one; or null. | | `stage` | `string \| null` | Yes | The stage, or null. | | `assignees` | `Array \| null` | Yes | Assigned members, sorted. Null when the record has no pipeline data. | | `estimated_value` | `number \| null` | Yes | Estimated value in major units as stored (decimals allowed), or null. | | `estimated_value_currency` | `null` | Yes | Always null: Totem stores no currency with the value. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Yes | The stored lead source; `api` also covers leads created by Totem itself (for example from a conversation or a call), see `source_detail`. Null when none is stored. | | `source_channel` | `string \| null` | Yes | The channel the record started on (for example `whatsapp`, `phone`, `widget`), or null. Team members can edit it. | | `source_detail` | `string \| null` | Yes | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` or `unknown`; null when `source` needs no detail. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Yes | `server_written` when Totem set the source from what the ad platform reported; otherwise `member_editable` (team members can change it). Null with `source`. | | `created_at` | `string (date-time)` | Yes | When the record was created. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | | `attribution` | [`LeadAttribution \| null`](https://totemstudio.ai/developers/objects#LeadAttribution) | Yes | The lead's attribution facet, or null while Totem is still preparing it. | | `response` | [`LeadResponse \| null`](https://totemstudio.ai/developers/objects#LeadResponse) | Yes | The lead's response facet, or null while Totem is still preparing it. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z", "attribution": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" }, "response": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ``` ## The LeadAttribution object Where a lead came from: its original capture and latest submission. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead_attribution"` | Yes | Always `lead_attribution`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the lead belongs to. | | `original_capture` | `object \| null` | Yes | How the lead first entered the CRM, frozen once; null when it was not captured. "Original" is the first time the contact entered the CRM, not the first ad it answered. | | `original_capture.capture` | `"at_creation" \| "at_activation"` | Yes | `at_creation`: frozen from the lead as it was first saved. `at_activation`: copied once from the lead as it was when the API was activated, for a lead with no capture record (created before Totem started recording attribution, or whose creation was not recorded). | | `original_capture.captured_at` | `string (date-time)` | Yes | When the record was written. | | `original_capture.source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Yes | The lead source at capture, from the same set as the lead's `source`; `api` also covers leads created by Totem itself (for example from a conversation or a call, see `origin`). Null when none was stored. | | `original_capture.source_hint` | `string \| null` | Yes | The real source of a gated ad lead stored as `api`, or null. | | `original_capture.source_channel` | `string \| null` | Yes | The channel at capture, or null. | | `original_capture.external_provider` | `string \| null` | Yes | The portal or booking platform of a forwarded or synced lead, or null. | | `original_capture.received_at` | `string (date-time) \| null` | Yes | When the lead was received, as first written, or null. | | `original_capture.submitted_at` | `string (date-time) \| null` | Yes | When the ad platform says the form was submitted, or null. | | `original_capture.origin` | `object \| null` | Yes | The conversation or call that created the lead (never its id), or null (also for an origin of another kind). | | `original_capture.origin.kind` | `"conversation" \| "call"` | Yes | `conversation` or `call`. | | `original_capture.origin.channel` | `string \| null` | Yes | Its channel, or null. | | `original_capture.campaign` | `object` | Yes | The campaign. | | `original_capture.campaign.id` | `object` | Yes | Its id. | | `original_capture.campaign.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.campaign.name` | `object` | Yes | Its name. | | `original_capture.campaign.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad` | `object` | Yes | The ad. | | `original_capture.ad.id` | `object` | Yes | Its id. | | `original_capture.ad.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad.name` | `object` | Yes | Its name. | | `original_capture.ad.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_set` | `object` | Yes | The Meta ad set. | | `original_capture.ad_set.id` | `object` | Yes | Its id. | | `original_capture.ad_set.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_set.name` | `object` | Yes | Its name. | | `original_capture.ad_set.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_group` | `object` | Yes | The Google Ads ad group. | | `original_capture.ad_group.id` | `object` | Yes | Its id. | | `original_capture.ad_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.form` | `object` | Yes | The lead form. | | `original_capture.form.id` | `object` | Yes | Its id. | | `original_capture.form.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.form.name` | `object` | Yes | Its name. | | `original_capture.form.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.creative` | `object` | Yes | The creative. | | `original_capture.creative.id` | `object` | Yes | Its id. | | `original_capture.creative.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.asset_group` | `object` | Yes | The Google Ads asset group. | | `original_capture.asset_group.id` | `object` | Yes | Its id. | | `original_capture.asset_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.page` | `object` | Yes | The Facebook page. | | `original_capture.page.id` | `object` | Yes | Its id. | | `original_capture.page.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.page.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.page.name` | `object` | Yes | Its name. | | `original_capture.page.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.page.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm` | `object \| null` | Yes | The five standard UTM keys, or null when none was captured. | | `original_capture.utm.source` | `object` | Yes | `utm_source`. | | `original_capture.utm.source.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.medium` | `object` | Yes | `utm_medium`. | | `original_capture.utm.medium.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.campaign` | `object` | Yes | `utm_campaign`. | | `original_capture.utm.campaign.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.term` | `object` | Yes | `utm_term`. | | `original_capture.utm.term.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.content` | `object` | Yes | `utm_content`. | | `original_capture.utm.content.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.click_network` | `object` | Yes | The network of the first click id (`google` or `meta`). The click id itself is never kept. | | `original_capture.click_network.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.click_network.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_referral` | `object \| null` | Yes | The first Meta ad referral of the conversation that created the lead, or null. | | `original_capture.ad_referral.channel` | `"whatsapp" \| "facebook" \| "instagram"` | Yes | The channel. | | `original_capture.ad_referral.source_type` | `"ad" \| "post" \| "link" \| "other"` | Yes | What opened the conversation. | | `original_capture.ad_referral.ad_id` | `string \| null` | Yes | The ad or post id, or null. | | `original_capture.ad_referral.referred_at` | `string (date-time)` | Yes | Meta's time for the referral. | | `original_capture.ad_referral.provenance` | `"provider"` | Yes | Always `provider`: only the signed Meta webhooks write it. | | `original_capture.family_origin` | `object \| null` | Yes | The transfer from another team of your organization that created the lead, or null. | | `original_capture.family_origin.team` | `string` | Yes | The team of your organization the lead came from. | | `original_capture.family_origin.lead` | `string \| null` | Yes | Its lead there, or null when that id cannot be named across teams: an assistant handed off a caller without one, or the lead there is a lead-ads lead (Meta, Google Ads or LinkedIn) or another lead with a fixed id. `automated` tells an assistant's handoff from a move made by a person. | | `original_capture.family_origin.automated` | `boolean` | Yes | True for an assistant's handoff. | | `original_capture.audience` | `object` | Yes | Not available in this version. | | `original_capture.audience.value` | `null` | Yes | Always null. | | `original_capture.audience.missing_reason` | `"unsupported"` | Yes | Totem does not record audiences; an ad set is not one. | | `original_capture_missing_reason` | `"not_captured" \| null` | Yes | `not_captured` when `original_capture` is null, otherwise null. | | `latest_submission` | `object \| null` | Yes | The newest ad submission recorded for this lead, including a repeat on an existing contact; null when none is recorded. A submission the ad platform reports deleted (a LinkedIn member deleting their form response) is no longer shown, and once the newest one expires or is withdrawn an older one can show instead. | | `latest_submission.provider` | `"meta" \| "google_ads" \| "linkedin"` | Yes | The ad platform. | | `latest_submission.submitted_at` | `string (date-time) \| null` | Yes | When the platform says it was submitted, or null. | | `latest_submission.campaign` | `object` | Yes | The campaign. | | `latest_submission.campaign.id` | `object` | Yes | Its id. | | `latest_submission.campaign.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.campaign.name` | `object` | Yes | Its name. | | `latest_submission.campaign.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad` | `object` | Yes | The ad. | | `latest_submission.ad.id` | `object` | Yes | Its id. | | `latest_submission.ad.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad.name` | `object` | Yes | Its name. | | `latest_submission.ad.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad_set` | `object` | Yes | The Meta ad set. | | `latest_submission.ad_set.id` | `object` | Yes | Its id. | | `latest_submission.ad_set.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad_set.name` | `object` | Yes | Its name. | | `latest_submission.ad_set.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad_group` | `object` | Yes | The Google Ads ad group. | | `latest_submission.ad_group.id` | `object` | Yes | Its id. | | `latest_submission.ad_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.form` | `object` | Yes | The lead form. | | `latest_submission.form.id` | `object` | Yes | Its id. | | `latest_submission.form.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.form.name` | `object` | Yes | Its name. | | `latest_submission.form.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.creative` | `object` | Yes | The creative. | | `latest_submission.creative.id` | `object` | Yes | Its id. | | `latest_submission.creative.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.asset_group` | `object` | Yes | The Google Ads asset group. | | `latest_submission.asset_group.id` | `object` | Yes | Its id. | | `latest_submission.asset_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm` | `object \| null` | Yes | Its UTM keys, or null. | | `latest_submission.utm.source` | `object` | Yes | `utm_source`. | | `latest_submission.utm.source.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.medium` | `object` | Yes | `utm_medium`. | | `latest_submission.utm.medium.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.campaign` | `object` | Yes | `utm_campaign`. | | `latest_submission.utm.campaign.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.term` | `object` | Yes | `utm_term`. | | `latest_submission.utm.term.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.content` | `object` | Yes | `utm_content`. | | `latest_submission.utm.content.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.expires_at` | `string (date-time) \| null` | Yes | When this submission stops being readable (Google Ads and LinkedIn keep it 90 days); null for Meta. | | `latest_submission_missing_reason` | `"not_captured" \| null` | Yes | `not_captured` when `latest_submission` is null, otherwise null. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" } ``` ## The LeadResponse object How the team has answered a lead across its conversations and calls: the latest messages each way and whether a reply is due. It uses its lead's id. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead_response"` | Yes | Always `lead_response`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `last_inbound_at` | `string (date-time) \| null` | Yes | The latest message from the customer on any of the lead's conversations (UTC), or null. Not counted: erased messages, imported WhatsApp history and web-chat visitor turns. Instagram shares cannot be told apart and count. | | `last_outbound_at` | `string (date-time) \| null` | Yes | The latest message sent to the customer that did not fail, on any of the lead's conversations and whoever sent it (UTC), or null. Imported WhatsApp history counts. | | `awaiting_reply` | `boolean` | Yes | True when `last_inbound_at` is newer than the latest reply on any of the lead's conversations. A reply is a sent message that did not fail and was recorded as a conversation reply or with no purpose; outreach, transactional messages (such as reminders) and imported history do not count. Most stored messages carry no purpose, so an automated send recorded without one also counts as a reply. False when the customer has never written. | | `last_answered_call_at` | `string (date-time) \| null` | Yes | When the latest of the lead's calls whose `outcome` is `answered_human` or `answered_ai` started (UTC), or null. A connected outbound call on Totem's phone lines reads `unknown`, so it is not counted. | | `last_whatsapp_delivery_status` | `"delivered" \| "read" \| "failed" \| null` | Yes | `delivered`, `read` or `failed` for the latest message sent to the lead on WhatsApp. Null when that message has no receipt yet (`sent` is never stored) or the lead has no message sent on WhatsApp. | | `coverage` | `object` | Yes | What these facts were computed from. | | `coverage.conversations` | `integer` | Yes | The lead's conversations these facts were computed from, newest first; at most 20. | | `coverage.calls` | `integer` | Yes | The lead's calls `last_answered_call_at` was computed from, newest first; at most 50. | | `coverage.channels_without_delivery_data` | `Array<"sms" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email">` | Yes | The channels of those conversations that store no delivery receipts (every channel but WhatsApp), sorted. | | `coverage.truncated` | `boolean` | Yes | True when a read limit was reached (20 conversations, 200 messages or 50 calls): older activity was not read, so a time can be null or older than the truth, and `awaiting_reply` can miss an older reply. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ## The Activity object One stage, status or assignee transition of a lead. Activities never change once written. ### Attributes of every variant | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"activity"` | Yes | Always `activity`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the lead belongs to. | | `lead` | `string \| null` | Yes | The lead that moved. Null only while Totem has not yet assigned the lead its id. | | `occurred_at` | `string (date-time)` | Yes | When the change was saved. | | `actor_kind` | `"human" \| "ai" \| "system" \| "api" \| "unknown"` | Yes | Who made the change: a person, the AI, Totem automation, an API caller, or unknown. Totem knows who made a change only when the same save wrote the lead's status, so a stage or assignee change made on its own reads unknown. | | `stage_snapshot` | `object \| null` | Yes | The stage the lead was in after the change (for "clear pipeline data", the stage it left), or null. | | `stage_snapshot.stage` | `string \| null` | Yes | The stage. | | `stage_snapshot.board` | `string \| null` | Yes | Its board, or null for a legacy stage without one. | | `stage_snapshot.name` | `string \| null` | Yes | The stage name when the change was recorded, or null when the stage was already deleted. | | `value_snapshot` | `number \| null` | Yes | The estimated value at that moment, in major units, or null. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Variants Each `Activity` is exactly one of these 3 variants. #### A status transition When `type` is one of `status_changed`, `archived`, `unarchived`, `revived`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `type` | `"status_changed" \| "archived" \| "unarchived" \| "revived"` | Yes | `status_changed`, `archived`, `unarchived`, or `revived` (the contact came back on their own and reopened an archived lead). | | `from` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | The status before, or null when there was none. | | `to` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable"` | Yes | The status after. | #### A stage move When `type` is `stage_changed`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `type` | `"stage_changed"` | Yes | Always `stage_changed`. | | `from` | `object \| null` | Yes | The stage and board before, or null when the lead had neither. | | `from.stage` | `string \| null` | Yes | The stage. | | `from.board` | `string \| null` | Yes | The board, or null for a legacy stage without one. | | `to` | `object` | Yes | The stage and board after. | | `to.stage` | `string \| null` | Yes | The stage. | | `to.board` | `string \| null` | Yes | The board, or null for a legacy stage without one. | #### A change of assigned members When `type` is `assignees_changed`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `type` | `"assignees_changed"` | Yes | Always `assignees_changed`. | | `from` | `Array` | Yes | The assigned members before, sorted. | | `to` | `Array` | Yes | The assigned members after, sorted. | ### Example ```json { "id": "act_aBgzeetVKDEjpAAkXfEFF53ZGd2ZMmGhUDKgagqLbh6c7R1GqOl0xsFw7pyRhD3SqqYWGMdb371xxj", "object": "activity", "livemode": false, "type": "stage_changed", "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "occurred_at": "2026-09-28T10:20:00.000Z", "actor_kind": "ai", "stage_snapshot": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "name": "Contacted" }, "value_snapshot": 1200, "from": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "to": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "version": 1, "updated_at": "2026-09-28T10:20:01.500Z" } ``` ## The Board object A board with its stages in pipeline order. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"board"` | Yes | Always `board`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `name` | `string \| null` | Yes | The board's name, or null when none is stored. | | `is_default` | `boolean` | Yes | True for the team's default board, where new leads land unless routed elsewhere. | | `is_archived` | `boolean` | Yes | True when the board is archived (hidden from the board selector). Its leads still export. | | `appointment_booked_stage` | `string \| null` | Yes | The stage of this board a lead moves to when its first appointment is confirmed, or null when none is set or it names no stage of this board. | | `stages` | `Array` | Yes | The board's stages, ordered by position, then by id. A stage with no position stored at all is left out, although a lead can still name it. | | `stages[].object` | `"stage"` | Yes | Always `stage`. | | `stages[].id` | `string` | Yes | Public id of the stage; a lead's `stage` names it. | | `stages[].name` | `string \| null` | Yes | The stage's name, or null when none is stored. | | `stages[].position` | `number \| null` | Yes | The stage's position on its board, left to right; null when the stored position is not a number. Stages are listed in this order. | | `stages[].is_default` | `boolean` | Yes | True for the stage new leads of this board enter. | | `stages[].is_final` | `boolean` | Yes | True for a final stage: moving a lead here marks it converted. | | `stages[].is_archive` | `boolean` | Yes | True for an archive stage, whose leads are hidden from the main board view. | | `stages[].mode` | `"standard" \| "supervised" \| "automated" \| null` | Yes | Who moves leads: `standard` (people only), `supervised` (AI may move leads in, people move them out) or `automated` (AI runs outreach and moves leads in and out); null for a value this version does not know. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "object": "board", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "New patients", "is_default": true, "is_archived": false, "appointment_booked_stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "stages": [ { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "name": "New", "position": 0, "is_default": true, "is_final": false, "is_archive": false, "mode": "automated" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "name": "Contacted", "position": 1, "is_default": false, "is_final": false, "is_archive": false, "mode": "supervised" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "name": "Visit booked", "position": 2, "is_default": false, "is_final": true, "is_archive": false, "mode": "standard" } ], "version": 4, "updated_at": "2026-09-02T16:40:00.000Z" } ``` --- # Calls > List and fetch a team's calls, with their direction, timing and a normalized outcome. No recordings or transcripts. Source: https://totemstudio.ai/developers/reference-calls ## Overview A **call** is the metadata of a phone call, or of a web voice session, linked to a lead: its `direction`, when it started, how long it lasted, and how it ended. Recordings, transcripts and summaries are never exported. The caller's name and number travel on the linked lead, not on the call. `outcome` says how the call ended: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed`, or `unknown` when the call is still live or nothing shows who answered. Read it together with `outcome_basis`, which names the evidence behind it: | `outcome_basis` | The outcome rests on | |---|---| | `inbound_verdict` | Totem's classification of an inbound call. | | `ai_analysis` | Totem's classification of the status, end reason and length that Retell reports. | | `duration_floor` | The length of a call on a human line: one of at least 10 seconds counts as answered. | | `carrier_answer_only` | Only the carrier's answer signal. It never counts as answered, so a connected call reads `unknown`. | | `browser_session` | A web voice session with the assistant. | | `none` | No evidence. | Which basis a call gets depends on how it was placed. Inbound calls on Totem's phone lines get `inbound_verdict`, or `carrier_answer_only` when Totem has not classified them. Every outbound call on Totem's phone lines, whether the AI or a person made it, gets `carrier_answer_only`, so a connected outbound call reads `unknown`. Calls handled through the Retell voice platform get `ai_analysis`, and calls on a connected Zadarma PBX get `duration_floor`. A web voice session gets `browser_session`, and a call that is still live gets `none`. A lead's `last_answered_call_at` counts only `answered_human` and `answered_ai`, so it leaves out connected outbound calls on Totem's phone lines. `duration_seconds` is rounded down to whole seconds, and is `null` while the call is still live. `lead` is `null` when the call is not linked to a lead, or when the lead no longer exists. Use [`GET /v1/calls`](https://totemstudio.ai/developers/reference-calls#calls.list) and [`GET /v1/calls/{id}`](https://totemstudio.ai/developers/reference-calls#calls.get) to look calls up; `GET /v1/calls?lead=` lists one lead's calls. To keep a copy, follow the changes feed, which delivers every new call and every change: [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains it. ## Endpoints | Method | Path | Summary | | --- | --- | --- | | GET | `/v1/calls` | List calls | | GET | `/v1/calls/{id}` | Get a call | ## GET /v1/calls **List calls** Returns a page of the team's calls, each with its direction, start time, duration and a normalized outcome, and linked to its lead when there is one. Pass `lead` for one lead's calls. Recordings and transcripts are not exported. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `calls:read` ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `integer` | No | Items per page, 1–100. Defaults to 25. | | `cursor` | `string` | No | The `next_cursor` of the previous page. Omit it for the first page. | | `lead` | `string` | No | Only the calls linked to this lead: every item's `lead` is this id. A lead of another team, or one that does not exist, matches nothing. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`Call`](https://totemstudio.ai/developers/objects#Call) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Call) | Yes | The items of this page. | | `has_more` | `boolean` | Yes | `true` when another page follows: fetch it with `next_cursor`, even if this page holds fewer items than `limit`. | | `next_cursor` | `string \| null` | Yes | Opaque cursor for the next page, or null on the last page. Never parse it. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/calls?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/calls?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/calls?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/calls/{id} **Get a call** Returns one call by its id. A call this key cannot read, or one that was removed, answers `404 resource_missing`. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Read: counts toward the rate limit ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `calls:read` ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | The call's public id. | ### Returns Returns a [`Call`](https://totemstudio.ai/developers/objects#Call) object. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"call"` | Yes | Always `call`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this call is linked to, or null when it is unlinked or the lead no longer exists. Identity (name, phone) travels on the lead only. | | `direction` | `"inbound" \| "outbound" \| null` | Yes | `inbound` or `outbound`; null when not stored. | | `started_at` | `string (date-time) \| null` | Yes | When the call started (UTC), or when it was recorded if the provider gave no start time. | | `duration_seconds` | `integer \| null` | Yes | Whole seconds of the call (rounded down); null while the call is still live, or when not stored. | | `outcome` | `"answered_human" \| "answered_ai" \| "voicemail" \| "no_answer" \| "busy" \| "failed" \| "unknown"` | Yes | How the call ended: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed`, or `unknown` (still live, or no evidence of who answered). Read it with `outcome_basis`. | | `outcome_basis` | `"inbound_verdict" \| "ai_analysis" \| "duration_floor" \| "carrier_answer_only" \| "browser_session" \| "none"` | Yes | The evidence behind `outcome`: `inbound_verdict` (Totem's classification of an inbound call on its phone lines), `ai_analysis` (Totem's classification of the status, end reason and length that Retell reports), `duration_floor` (a call on a connected Zadarma PBX, where at least 10 seconds counts as answered), `carrier_answer_only` (only the carrier's answer signal, which never counts as answered, so a connected call reads `unknown`: every outbound call on Totem's phone lines, made by the AI or by a person, and an inbound one Totem has not classified), `browser_session` (a web voice session with the assistant), or `none` (no evidence, as for a call that is still live). | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/calls/call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/calls/call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/calls/call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Example response ```json { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ``` ### Errors - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`resource_missing`](https://totemstudio.ai/developers/errors#resource_missing): Resource not found - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## The Call object A call: its direction, timing and a normalized outcome. No recording or transcript. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"call"` | Yes | Always `call`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this call is linked to, or null when it is unlinked or the lead no longer exists. Identity (name, phone) travels on the lead only. | | `direction` | `"inbound" \| "outbound" \| null` | Yes | `inbound` or `outbound`; null when not stored. | | `started_at` | `string (date-time) \| null` | Yes | When the call started (UTC), or when it was recorded if the provider gave no start time. | | `duration_seconds` | `integer \| null` | Yes | Whole seconds of the call (rounded down); null while the call is still live, or when not stored. | | `outcome` | `"answered_human" \| "answered_ai" \| "voicemail" \| "no_answer" \| "busy" \| "failed" \| "unknown"` | Yes | How the call ended: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed`, or `unknown` (still live, or no evidence of who answered). Read it with `outcome_basis`. | | `outcome_basis` | `"inbound_verdict" \| "ai_analysis" \| "duration_floor" \| "carrier_answer_only" \| "browser_session" \| "none"` | Yes | The evidence behind `outcome`: `inbound_verdict` (Totem's classification of an inbound call on its phone lines), `ai_analysis` (Totem's classification of the status, end reason and length that Retell reports), `duration_floor` (a call on a connected Zadarma PBX, where at least 10 seconds counts as answered), `carrier_answer_only` (only the carrier's answer signal, which never counts as answered, so a connected call reads `unknown`: every outbound call on Totem's phone lines, made by the AI or by a person, and an inbound one Totem has not classified), `browser_session` (a web voice session with the assistant), or `none` (no evidence, as for a call that is still live). | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ``` --- # Conversations > List and fetch a team's conversations on every channel, with their status, assignee, whether a person has taken them over and when the last messages came and went. Never message content. Source: https://totemstudio.ai/developers/reference-conversations ## Overview A **conversation** is one thread with a lead, on one channel and business account: WhatsApp, email, the web chat (`chatbot`), Facebook, Instagram, SMS or TikTok. A lead can have several conversations on the same channel and business account, one for each contact address (phone number, email address or handle) it has used, so key your copy on the conversation's `id`. You get the conversation's metadata, never its messages: - `channel_type`: the channel, or `null` for a channel this version of the API does not know. - `status`: `active` or `archived` in the team's inbox. - `assignee`: the team member the conversation is assigned to, or `null`. - `reply_mode`: `human` while a person has taken the conversation over, including a WhatsApp chat answered from the WhatsApp Business app, and `ai` otherwise. `ai` means no takeover is active; it does not mean an assistant is set up or answering on that channel. A takeover with a time limit goes back to `ai` when it ends, and the conversation gets a new version then. - `last_inbound_at` and `last_outbound_at`: when the latest message came in from the customer, and when the latest message that did not fail went out to them. Erased messages, imported WhatsApp history and web-chat visitor messages do not count as inbound, so a `chatbot` conversation reads `null` there. Imported WhatsApp history does count as outbound. The contact's name, phone number or handle travel on the linked lead, not on the conversation. For the picture across all of a lead's channels, read the lead's `response` facet with [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/reference-leads#leads.get). Use [`GET /v1/conversations`](https://totemstudio.ai/developers/reference-conversations#conversations.list) and [`GET /v1/conversations/{id}`](https://totemstudio.ai/developers/reference-conversations#conversations.get) to look conversations up; `GET /v1/conversations?lead=` lists one lead's conversations. To keep a copy, follow the changes feed: [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains it. ## Endpoints | Method | Path | Summary | | --- | --- | --- | | GET | `/v1/conversations` | List conversations | | GET | `/v1/conversations/{id}` | Get a conversation | ## GET /v1/conversations **List conversations** Returns a page of the team's conversations, one per channel, business account and contact address: channel, inbox status, assignee and the times of the latest inbound and outbound messages. Pass `lead` for one lead's conversations. Message content is never exported. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `messages:read` ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `integer` | No | Items per page, 1–100. Defaults to 25. | | `cursor` | `string` | No | The `next_cursor` of the previous page. Omit it for the first page. | | `lead` | `string` | No | Only the conversations linked to this lead: every item's `lead` is this id. A lead of another team, or one that does not exist, matches nothing. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`Conversation`](https://totemstudio.ai/developers/objects#Conversation) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Conversation) | Yes | The items of this page. | | `has_more` | `boolean` | Yes | `true` when another page follows: fetch it with `next_cursor`, even if this page holds fewer items than `limit`. | | `next_cursor` | `string \| null` | Yes | Opaque cursor for the next page, or null on the last page. Never parse it. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/conversations?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/conversations?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/conversations?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/conversations/{id} **Get a conversation** Returns one conversation by its id, without message content. A conversation this key cannot read, or one that was removed, answers `404 resource_missing`. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Read: counts toward the rate limit ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `messages:read` ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | The conversation's public id. | ### Returns Returns a [`Conversation`](https://totemstudio.ai/developers/objects#Conversation) object. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"conversation"` | Yes | Always `conversation`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this conversation is linked to, or null when it is unlinked or the lead no longer exists. Identity (name, phone, handle) travels on the lead only. | | `channel_type` | `"sms" \| "whatsapp" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email" \| null` | Yes | The channel: `whatsapp`, `email`, `chatbot` (the web chat), `facebook`, `instagram`, `sms` or `tiktok`; null for a value this version does not know. A lead can have several conversations on the same channel and business account, one for each contact address (phone number, email address or handle) it has used, so key your copy on `id`. | | `status` | `"active" \| "archived" \| null` | Yes | `active` or `archived` in the team's inbox; null for a value this version does not know. | | `assignee` | `string \| null` | Yes | The team member assigned to the conversation (see `/members`), or null. | | `reply_mode` | `"ai" \| "human"` | Yes | `human` while a person has taken the conversation over, including a WhatsApp chat answered from the WhatsApp Business app; `ai` otherwise. `ai` means no takeover is active, not that an assistant is set up or answering on this channel. A timed takeover returns to `ai` when it expires, and the record's version changes then. | | `created_at` | `string (date-time) \| null` | Yes | When the conversation was created (UTC). | | `last_inbound_at` | `string (date-time) \| null` | Yes | The latest customer message (UTC). Not counted: erased messages, imported WhatsApp history and web-chat visitor turns, so a `chatbot` conversation reads null. Instagram shares cannot be told apart and count. | | `last_outbound_at` | `string (date-time) \| null` | Yes | The latest message sent to the customer that did not fail (UTC), whoever sent it. Imported WhatsApp history counts. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/conversations/conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/conversations/conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/conversations/conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Example response ```json { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ### Errors - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`resource_missing`](https://totemstudio.ai/developers/errors#resource_missing): Resource not found - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## The Conversation object A conversation on one channel: its status, assignee and the times of the latest messages, never their content. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"conversation"` | Yes | Always `conversation`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this conversation is linked to, or null when it is unlinked or the lead no longer exists. Identity (name, phone, handle) travels on the lead only. | | `channel_type` | `"sms" \| "whatsapp" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email" \| null` | Yes | The channel: `whatsapp`, `email`, `chatbot` (the web chat), `facebook`, `instagram`, `sms` or `tiktok`; null for a value this version does not know. A lead can have several conversations on the same channel and business account, one for each contact address (phone number, email address or handle) it has used, so key your copy on `id`. | | `status` | `"active" \| "archived" \| null` | Yes | `active` or `archived` in the team's inbox; null for a value this version does not know. | | `assignee` | `string \| null` | Yes | The team member assigned to the conversation (see `/members`), or null. | | `reply_mode` | `"ai" \| "human"` | Yes | `human` while a person has taken the conversation over, including a WhatsApp chat answered from the WhatsApp Business app; `ai` otherwise. `ai` means no takeover is active, not that an assistant is set up or answering on this channel. A timed takeover returns to `ai` when it expires, and the record's version changes then. | | `created_at` | `string (date-time) \| null` | Yes | When the conversation was created (UTC). | | `last_inbound_at` | `string (date-time) \| null` | Yes | The latest customer message (UTC). Not counted: erased messages, imported WhatsApp history and web-chat visitor turns, so a `chatbot` conversation reads null. Instagram shares cannot be told apart and count. | | `last_outbound_at` | `string (date-time) \| null` | Yes | The latest message sent to the customer that did not fail (UTC), whoever sent it. Imported WhatsApp history counts. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ``` --- # Bookings and services > List and fetch a team's bookings, and list the services its customers can book. Source: https://totemstudio.ai/developers/reference-bookings ## Overview A **booking** is an appointment on the team's calendar. Its `kind` says where it came from: - `service_booking`: booked for a bookable service, by the AI, a customer or someone on the team. `service` names the service. - `calendar_event`: an event from the team's connected calendar. - `external_import`: synced from an external booking system, or read from a booking portal's email. `status` is `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` or `team_no_show`. Only people record attendance. `source` says who booked it: `ai`, `customer` or `operator`. `meeting_type` is `online`, `in_person` or `phone`. Payments are not exported. A reschedule shows up in one of two ways. The same booking is updated when someone on the team moves it in Totem, when the AI moves a booking held in an external booking system such as Koibox, when that system reports a move, or when a booking portal's email does: `starts_at` and `ends_at` hold the new time, and the previous time is not exported. Its `status` becomes `rescheduled`, although a move that an external booking system reports can leave the status unchanged. When the AI reschedules any other booking, it creates a new one: the old booking ends as `cancelled`, with `rescheduled_to` pointing to the new one, and the new one points back with `rescheduled_from`. For a few seconds after that kind of reschedule, the old booking can still read `scheduled`. A change made directly in Google Calendar or Outlook may not reach the Totem booking. A booking rescheduled before Totem started recording these links may lack either link, as [Data coverage](https://totemstudio.ai/developers/coverage) explains. A **bookable service** is something customers can book with the team, such as a first consultation. [`GET /v1/booking_services`](https://totemstudio.ai/developers/reference-bookings#booking_services.list) returns every service of the team, each with its `meeting_type`, in one response that takes no `limit` or `cursor`. Services are not in the changes feed: read the list when you start syncing a team, when a booking names a service you do not know, and at least once a day. A deleted service drops out of the list while its bookings keep naming its id: do not re-read the list for an id it did not resolve last time, and if you show service names on older bookings, keep the name from an earlier read. Disabled services stay listed, with nothing that marks them as disabled. Use [`GET /v1/bookings`](https://totemstudio.ai/developers/reference-bookings#bookings.list) and [`GET /v1/bookings/{id}`](https://totemstudio.ai/developers/reference-bookings#bookings.get) to look bookings up; `GET /v1/bookings?lead=` lists one lead's bookings. To keep a copy, follow the changes feed: [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains it. ## Endpoints | Method | Path | Summary | | --- | --- | --- | | GET | `/v1/bookings` | List bookings | | GET | `/v1/bookings/{id}` | Get a booking | | GET | `/v1/booking_services` | List bookable services | ## GET /v1/bookings **List bookings** Returns a page of the team's bookings: appointments for a bookable service, events from the connected calendar and bookings imported from other systems, each with its status and times. Pass `lead` for one lead's bookings. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `calendar:read` ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `integer` | No | Items per page, 1–100. Defaults to 25. | | `cursor` | `string` | No | The `next_cursor` of the previous page. Omit it for the first page. | | `lead` | `string` | No | Only the bookings linked to this lead: every item's `lead` is this id. A lead of another team, or one that does not exist, matches nothing. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`Booking`](https://totemstudio.ai/developers/objects#Booking) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#Booking) | Yes | The items of this page. | | `has_more` | `boolean` | Yes | `true` when another page follows: fetch it with `next_cursor`, even if this page holds fewer items than `limit`. | | `next_cursor` | `string \| null` | Yes | Opaque cursor for the next page, or null on the last page. Never parse it. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/bookings?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/bookings?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/bookings?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/bookings/{id} **Get a booking** Returns one booking by its id. A booking this key cannot read, or one that was removed, answers `404 resource_missing`. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Rate limit:** Read: counts toward the rate limit ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `calendar:read` ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | The booking's public id. | ### Returns Returns a [`Booking`](https://totemstudio.ai/developers/objects#Booking) object. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"booking"` | Yes | Always `booking`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this booking belongs to, or null for a booking without a lead or when the lead no longer exists. Names and contact details appear only on the lead. | | `kind` | `"service_booking" \| "calendar_event" \| "external_import"` | Yes | `service_booking` (booked for a bookable service, by the AI, a customer or a person), `calendar_event` (an event from the connected calendar) or `external_import` (synced from an external booking system or read from a portal email). A booking from an origin this version does not know is reported as `calendar_event`. | | `service` | `string \| null` | Yes | The booked service (see `/booking_services`) on a `service_booking`; null otherwise. | | `status` | `"scheduled" \| "rescheduled" \| "cancelled" \| "postponed" \| "attended" \| "no_show" \| "team_no_show" \| null` | Yes | `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` or `team_no_show` (attendance is recorded only by people); null for a value this version does not know. `rescheduled` means the booking was moved in place and its times are the new ones; a booking the AI replaced with a new one reads `cancelled`, with `rescheduled_to` set. | | `starts_at` | `string (date-time) \| null` | Yes | When the booking starts (UTC). | | `ends_at` | `string (date-time) \| null` | Yes | When the booking ends (UTC). | | `meeting_type` | `"online" \| "in_person" \| "phone" \| null` | Yes | `online`, `in_person` or `phone`; null when not stored. | | `source` | `"ai" \| "customer" \| "operator" \| null` | Yes | Who booked it: `ai`, `customer` or `operator`; null when not recorded. | | `rescheduled_from` | `string \| null` | Yes | The booking this one replaced in a reschedule, or null. For a few seconds the replaced booking can still read `scheduled`; bookings rescheduled before Totem started recording this link may not have it. | | `rescheduled_to` | `string \| null` | Yes | The booking that replaced this one in a reschedule, or null. When it is set, this booking reads `cancelled`. | | `created_at` | `string (date-time) \| null` | Yes | When the booking was recorded in Totem (UTC). | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/bookings/bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0' \ -H "Authorization: Bearer $TOTEM_API_KEY" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; const response = await fetch('https://api.totemstudio.ai/v1/bookings/bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.object, data.id); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.totemstudio.ai/v1/bookings/bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print(data["object"], data["id"]) ``` ### Example response ```json { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ``` ### Errors - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`resource_missing`](https://totemstudio.ai/developers/errors#resource_missing): Resource not found - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## GET /v1/booking_services **List bookable services** Lists every bookable service of the team in one response that is never paged and takes no `limit` or `cursor`. Bookable services are not in the changes feed: read this list when you start syncing a team, again when a booking names a service you do not know, and at least once a day. A deleted service drops out of the list while its bookings keep naming its id, so keep a service name you need from an earlier read. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `calendar:read` ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`BookingService`](https://totemstudio.ai/developers/objects#BookingService) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#BookingService) | Yes | Every bookable service of the team. | | `has_more` | `false` | Yes | Always `false`: this list is never paged. | | `next_cursor` | `null` | Yes | Always `null`: there is no next page. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/booking_services' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/booking_services', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/booking_services", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "id": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "object": "booking_service", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "First visit", "meeting_type": "in_person" } ], "has_more": false, "next_cursor": null } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## The Booking object A booking and its `kind`: an appointment for a bookable service, an event from the connected calendar, or a booking imported from another system. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"booking"` | Yes | Always `booking`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this booking belongs to, or null for a booking without a lead or when the lead no longer exists. Names and contact details appear only on the lead. | | `kind` | `"service_booking" \| "calendar_event" \| "external_import"` | Yes | `service_booking` (booked for a bookable service, by the AI, a customer or a person), `calendar_event` (an event from the connected calendar) or `external_import` (synced from an external booking system or read from a portal email). A booking from an origin this version does not know is reported as `calendar_event`. | | `service` | `string \| null` | Yes | The booked service (see `/booking_services`) on a `service_booking`; null otherwise. | | `status` | `"scheduled" \| "rescheduled" \| "cancelled" \| "postponed" \| "attended" \| "no_show" \| "team_no_show" \| null` | Yes | `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` or `team_no_show` (attendance is recorded only by people); null for a value this version does not know. `rescheduled` means the booking was moved in place and its times are the new ones; a booking the AI replaced with a new one reads `cancelled`, with `rescheduled_to` set. | | `starts_at` | `string (date-time) \| null` | Yes | When the booking starts (UTC). | | `ends_at` | `string (date-time) \| null` | Yes | When the booking ends (UTC). | | `meeting_type` | `"online" \| "in_person" \| "phone" \| null` | Yes | `online`, `in_person` or `phone`; null when not stored. | | `source` | `"ai" \| "customer" \| "operator" \| null` | Yes | Who booked it: `ai`, `customer` or `operator`; null when not recorded. | | `rescheduled_from` | `string \| null` | Yes | The booking this one replaced in a reschedule, or null. For a few seconds the replaced booking can still read `scheduled`; bookings rescheduled before Totem started recording this link may not have it. | | `rescheduled_to` | `string \| null` | Yes | The booking that replaced this one in a reschedule, or null. When it is set, this booking reads `cancelled`. | | `created_at` | `string (date-time) \| null` | Yes | When the booking was recorded in Totem (UTC). | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ``` ## The BookingService object A service customers can book, with its meeting type. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"booking_service"` | Yes | Always `booking_service`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `name` | `string \| null` | Yes | The service's name, or null when none is stored. | | `meeting_type` | `"online" \| "in_person" \| "phone"` | Yes | How the service happens: `online` (the default when none is stored), `in_person` or `phone`. | ### Example ```json { "id": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "object": "booking_service", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "First visit", "meeting_type": "in_person" } ``` --- # Changes feed > One endpoint that copies a team and then returns every change, page by page. Source: https://totemstudio.ai/developers/reference-sync ## Overview [`GET /v1/sync/changes`](https://totemstudio.ai/developers/reference-sync#sync.changes) returns a team's changes, page by page, in the order Totem recorded them. Without `cursor`, it starts an initial sync of every record. With the `next_cursor` of your last page, it returns what changed since. Each item is the current state of one record, or a removal that tells you to delete it. New kinds of record can appear in the feed in any version: store or skip an item whose `kind` you do not know, and keep syncing. Store `next_cursor` after you apply each page, even an empty one, and send it on your next request. A cursor expires 30 days after the point in the feed it reached; a team you poll every few minutes never gets there. This page is the reference: the parameters, the item object and the errors. [Keep a copy in sync](https://totemstudio.ai/developers/sync) is the guide: how to apply a page, recover from each error, and run a sync client that Totem tests. ## Endpoints | Method | Path | Summary | | --- | --- | --- | | GET | `/v1/sync/changes` | Read the changes feed of one team | ## GET /v1/sync/changes **Read the changes feed of one team** Returns the next page of one team's changes, in the order Totem recorded them: each item is a record's current state or its removal. Without `cursor` it starts an initial sync of every record; after applying each page, store its `next_cursor` and send it on the next call, even when the page is empty. A cursor expires 30 days after the point in the feed it reached or, while an initial sync is still running, 30 days after that sync started. - **Authentication:** [Secret key](https://totemstudio.ai/developers/authentication) - **Team:** [`Totem-Team` header](https://totemstudio.ai/developers/authentication) - **Rate limit:** Sync: counts toward the rate limit and the daily quota ([rate limits](https://totemstudio.ai/developers/rate-limits)). - **Stability:** Beta - **Scopes:** `crm:read`, `calls:read`, `messages:read`, `calendar:read` ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | `string` | No | The `next_cursor` of the previous page. Omit it to start an initial sync from the beginning. | | `limit` | `integer` | No | Items per page, 1–100. Defaults to 25. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Totem-Team` | `string` | Conditional | The team to read, as a `team_…` id. Omit it when the key reads one team; it is required when the key reads several (`400 team_required`). When you send it, it must name a team the key reads: a malformed id gets `400 invalid_request`, and a team outside the key gets `404 team_not_accessible`, as does a request without it once the key reads no team at all. | ### Returns Returns a list of [`FeedItem`](https://totemstudio.ai/developers/objects#FeedItem) objects. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"list"` | Yes | Always `list`. | | `data` | [`Array`](https://totemstudio.ai/developers/objects#FeedItem) | Yes | Changes in the order Totem recorded them. A record can appear more than once during an initial sync. | | `has_more` | `boolean` | Yes | `true` when more changes are ready now; `false` once you have caught up. | | `next_cursor` | `string` | Yes | Always present, even on an empty page. Store it after applying the page and send it on the next call. | ### Example request ```bash curl 'https://api.totemstudio.ai/v1/sync/changes?limit=25' \ -H "Authorization: Bearer $TOTEM_API_KEY" \ -H "Totem-Team: $TOTEM_TEAM_ID" ``` ```javascript const headers = { Authorization: `Bearer ${process.env.TOTEM_API_KEY}` }; if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID; const response = await fetch('https://api.totemstudio.ai/v1/sync/changes?limit=25', { headers }); if (!response.ok) { const problem = await response.json().catch(() => ({})); const requestId = response.headers.get('Totem-Request-Id'); throw Object.assign(new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${requestId ?? 'n/a'}`), { status: response.status, code: problem.code, requestId, retryAfter: response.headers.get('Retry-After'), }); } const data = await response.json(); console.log(data.data.map(item => item.id), data.has_more); ``` ```python # pip install requests import os import requests api_key = os.environ["TOTEM_API_KEY"] headers = {"Authorization": f"Bearer {api_key}"} if os.environ.get("TOTEM_TEAM_ID"): headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"] response = requests.get( "https://api.totemstudio.ai/v1/sync/changes?limit=25", headers=headers, timeout=30, ) if not response.ok: try: problem = response.json() except ValueError: problem = {} raise RuntimeError( f"Totem API {response.status_code} {problem.get('code', response.reason)}, " f"request {response.headers.get('Totem-Request-Id', 'n/a')}" ) data = response.json() print([item["id"] for item in data["data"]], data["has_more"]) ``` ### Example response ```json { "object": "list", "data": [ { "kind": "lead", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 3, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } }, { "kind": "call", "id": "call_m1pf4APjJWVi3Z7ulyNaW8y3lUi7V", "version": 5, "removed": { "reason": "deleted" } }, { "kind": "lead_response", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 7, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ], "has_more": false, "next_cursor": "eyJ2IjoxLCJ0ZWFtIjoidGVhbV9yOXVZcklXNGdTRkpKNmhaUTlpcDFVSXBQYUFEMUNGN05SIiwiZXBvY2giOjAsInQiOnsicyI6MTc5MDg0NTE3MSwibnMiOjQwMDAwMDAwfSwibiI6IjVkMjJhMTVlM2E5MmNmYTE4NzUzZTVmYzkyZmVjNzI0MjQyNjIzZDE2MGIxZTUxMzRiYTM1ODE5MmUzNzM0ZjEiLCJicyI6bnVsbH01ZPX02" } ``` ### Errors - `400` [`team_required`](https://totemstudio.ai/developers/errors#team_required): Team required - `403` [`team_state_restricted`](https://totemstudio.ai/developers/errors#team_state_restricted): Team restricted - `403` [`sector_restricted`](https://totemstudio.ai/developers/errors#sector_restricted): Sector restricted - `404` [`team_not_accessible`](https://totemstudio.ai/developers/errors#team_not_accessible): Team not accessible - `409` [`resync_required`](https://totemstudio.ai/developers/errors#resync_required): Resync required - `409` [`capture_not_ready`](https://totemstudio.ai/developers/errors#capture_not_ready): Team data is still being prepared - `410` [`sync_cursor_expired`](https://totemstudio.ai/developers/errors#sync_cursor_expired): Sync cursor expired - `429` [`quota_exceeded`](https://totemstudio.ai/developers/errors#quota_exceeded): Daily quota exceeded Also 12 common errors that any authenticated request can return; see [Errors](https://totemstudio.ai/developers/errors). ## The FeedItem object One change: the record's current state, or its removal. ### Attributes of every variant | Field | Type | Always present | Description | | --- | --- | --- | --- | | `kind` | `"lead" \| "lead_attribution" \| "lead_response" \| "activity" \| "call" \| "conversation" \| "booking" \| "board"` | Yes | The kind of the record, such as `lead` or `call`. | | `version` | `integer` | Yes | The record's version. Apply the item only when it is greater than the version you hold. | ### Variants Each `FeedItem` is exactly one of these 2 variants. #### With `object` | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record; the facets `lead_attribution` and `lead_response` use their lead's id. | | `object` | `Lead \| LeadAttribution \| LeadResponse \| Activity \| Call \| Conversation \| Booking \| Board` | Yes | The record as it is now. | #### With `removed` | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the removed record. | | `removed` | `object` | Yes | Present only on a removal. | | `removed.reason` | `"erased" \| "deleted"` | Yes | `erased` after an erasure, which is also what deleting a lead, call or conversation in Totem does: erasing a lead erases its calls, conversations and merged duplicates too, while its bookings stay with `lead` set to `null`. `deleted` for any other removal. Either way, delete your copy and keep this version, so an older copy delivered later is ignored. | ### Example ```json { "kind": "lead", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 3, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } } ``` --- # All objects > Every object the API returns, field by field, with an example of each. Source: https://totemstudio.ai/developers/objects Every object the API returns is on this page, with its fields and an example. Each resource's reference page shows the same objects next to the endpoints that return them. ## How responses are shaped Responses take one of five shapes: - **One object.** A request for one record returns the object itself. [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/reference-leads#leads.get) returns the lead with its two facets, `attribution` and `response`, inside it. - **A list.** Lists return `object: "list"`, the objects in `data`, `has_more`, and `next_cursor`, which is `null` on the last page. [Lists and pagination](https://totemstudio.ai/developers/pagination) explains it. - **A changes-feed page.** The same envelope, but `data` holds feed items (the [`FeedItem`](https://totemstudio.ai/developers/objects#FeedItem) object below) and `next_cursor` is always a string, even on an empty page. [Keep a copy in sync](https://totemstudio.ai/developers/sync) explains it. - **An error.** An `application/problem+json` body, described in [Errors](https://totemstudio.ai/developers/errors#error-format). - **The health check.** [`GET /v1/health`](https://totemstudio.ai/developers/reference-account#health) answers `{"object": "health", "status": "ok"}`. Records, such as a lead, a call or a team, carry `object`, which names their type, and `livemode`, which is `true` on everything the API returns. The records the changes feed delivers also carry `version` and `updated_at`, as [Conventions](https://totemstudio.ai/developers/conventions#record-versions) explains. ## How to read a field list - **Every field is always there.** In a response, each field listed is present in every object, with two exceptions: a feed item carries exactly one of `object` and `removed`, and an error carries `param` only when it names one. A field with no value is `null`: never missing, and never an empty string. - **Nullable** marks a field that can be `null`. [Data coverage](https://totemstudio.ai/developers/coverage) says what a `null` can mean for each kind of fact. - **Possible values** lists the values a field can take today. New values can appear in any version, so keep the record when you meet one you do not know, as [Conventions](https://totemstudio.ai/developers/conventions#enums-can-grow) explains. - **Ids** show the prefix of the record they point to, such as `brd_` for a board, and a type that is another object links to it. - **Nested objects** are folded: open one to see its fields. The same information is in the [OpenAPI document](https://totemstudio.ai/developers/kit/openapi.json), and every example comes from the kit's example data, [`fixtures.json`](https://totemstudio.ai/developers/kit/fixtures.json): fictitious contacts with `livemode: false`. ## The Me object The integration that owns the key, with the teams the key reads. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `object` | `"integration"` | Yes | Always `integration`. | | `id` | `string` | Yes | Public id of the integration. | | `livemode` | `boolean` | Yes | `true` for a real key; `false` in example data. | | `name` | `string` | Yes | The name an administrator gave the integration. | | `key` | `object` | Yes | The key used for this request. | | `key.last4` | `string` | Yes | The last four characters of the key used for this request. | | `teams` | `Array` | Yes | The teams this key reads today. Their details are in `GET /v1/teams`. | | `scopes` | `Array<"teams:read" \| "crm:read" \| "calls:read" \| "messages:read" \| "calendar:read">` | Yes | The key's scopes: the fixed read set in v1.0a. | ### Example ```json { "object": "integration", "id": "prn_m1NFIcoIU8smXfqR4ymgt6j07TfRF", "livemode": false, "name": "Example CRM sync", "key": { "last4": "Q7xZ" }, "teams": [ "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR" ], "scopes": [ "teams:read", "crm:read", "calls:read", "messages:read", "calendar:read" ] } ``` ## The Team object A team this key reads, with its time zone and default country. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"team"` | Yes | Always `team`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `name` | `string \| null` | Yes | The team's name as it appears in Totem, or null when none is stored. | | `timezone` | `string` | Yes | The team's IANA time zone: its business-hours zone when that is valid, otherwise its default country's (Europe/Madrid when neither is set). Interpret the team's local times with it. | | `default_country` | `string \| null` | Yes | The team's default country (ISO 3166-1 alpha-2), which Totem uses when someone types a phone number without a prefix; null when none is set. The API does not apply it: `Lead.phone` is validated without a default country, so a number stored without its country prefix reads `phone_status: invalid`. | ### Example ```json { "id": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "object": "team", "livemode": false, "name": "Example Dental", "timezone": "America/New_York", "default_country": "US" } ``` ## The Member object A team member, by display name only: Totem exports no email, phone or role. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"member"` | Yes | Always `member`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `display_name` | `string \| null` | Yes | The member's display name, or first and last name, or null when none is stored. Totem exports no email, phone or role. | ### Example ```json { "id": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "object": "member", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "display_name": "Sam Lee" } ``` ## The Lead object A lead or client record: lead fields only. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead"` | Yes | Always `lead`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the record belongs to. | | `record_kind` | `"lead" \| "client"` | Yes | `lead` when the record has a pipeline status, otherwise `client`. A lead whose status this version does not know reads `status: null`. | | `name` | `string \| null` | Yes | First name or display name, or null. | | `surname` | `string \| null` | Yes | Surname, or null. | | `email` | `string \| null` | Yes | Email address as stored, or null. | | `company` | `string \| null` | Yes | Company, or null. | | `phone` | `string \| null` | Yes | Phone in E.164, only when the stored number validates; otherwise null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Yes | `valid` (see `phone`), `invalid` (a number is stored but does not validate, for example one stored without its country prefix) or `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | Pipeline status; null for a client record, or for a status this version does not know. `converted` is not "won". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Yes | Who set the current status: a person, the AI, Totem automation, an API caller, or unknown (no recorded transition lands on it). Null when `status` is null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Yes | Why the record was archived, as a closed code; stored free text is `other`. Null when none is stored. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | The status the lead had before its current archive; null while it is not archived or when unknown. | | `contact_permissions` | `object` | Yes | Contact permissions. Apply them before any outreach from your own systems. | | `contact_permissions.do_not_contact` | `boolean` | Yes | True when the person asked never to be contacted (the `opt_out` archive reason). Stop every channel. | | `contact_permissions.email_opt_out` | `boolean` | Yes | True when the person unsubscribed from marketing and follow-up email. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Yes | When the email opt-out was recorded, or null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Yes | WhatsApp consent: false when refused, true when recorded, null when unknown. | | `contact_permissions.provenance` | `object` | Yes | The source of each permission. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Yes | Where `do_not_contact` comes from, or null when it is false. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Yes | Who recorded the email opt-out: the unsubscribe link, a person in Totem, or unknown. Null when there is none. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Yes | `archive_reason` when a refusal is recorded as the archive reason; `stored` for the value kept on the record, which team members can edit. Null when unknown. | | `merged_into` | `string \| null` | Yes | The lead this record was merged into, or null (also when that lead no longer exists). | | `transferred_to` | `object \| null` | Yes | Where a transfer to another team of your organization sent this lead, or null. The team may be one your key does not read. | | `transferred_to.team` | `string` | Yes | The destination team. | | `transferred_to.lead` | `string \| null` | Yes | The destination lead, or null when its id cannot be named across teams: an assistant handoff created it, or the transfer landed on an existing lead-ads lead (Meta, Google Ads or LinkedIn) or another lead with a fixed id. | | `revived_at` | `string (date-time) \| null` | Yes | When the contact last came back on their own and revived an archived lead, or null. | | `pipeline_cleared` | `boolean` | Yes | True when the lead was converted with "clear pipeline data": board, stage, priority, assignees, estimated value and the source fields other than `source_channel` are null because they were cleared. | | `converted_at` | `string (date-time) \| null` | Yes | When the lead was converted, while its status is `converted`; otherwise null. | | `value_at_conversion` | `number \| null` | Yes | The estimated value when it was converted, in major units. Survives "clear pipeline data". | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Yes | Priority, or null. | | `tags` | `Array` | Yes | Labels as stored. `lead` and `booking` are system labels. | | `board` | `string \| null` | Yes | The board, resolved through the stage when it has one; or null. | | `stage` | `string \| null` | Yes | The stage, or null. | | `assignees` | `Array \| null` | Yes | Assigned members, sorted. Null when the record has no pipeline data. | | `estimated_value` | `number \| null` | Yes | Estimated value in major units as stored (decimals allowed), or null. | | `estimated_value_currency` | `null` | Yes | Always null: Totem stores no currency with the value. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Yes | The stored lead source; `api` also covers leads created by Totem itself (for example from a conversation or a call), see `source_detail`. Null when none is stored. | | `source_channel` | `string \| null` | Yes | The channel the record started on (for example `whatsapp`, `phone`, `widget`), or null. Team members can edit it. | | `source_detail` | `string \| null` | Yes | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` or `unknown`; null when `source` needs no detail. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Yes | `server_written` when Totem set the source from what the ad platform reported; otherwise `member_editable` (team members can change it). Null with `source`. | | `created_at` | `string (date-time)` | Yes | When the record was created. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } ``` ## The LeadDetail object A lead with its `attribution` and `response` facets, each with its own `version`. A facet is `null` only while Totem is still preparing it. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead"` | Yes | Always `lead`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the record belongs to. | | `record_kind` | `"lead" \| "client"` | Yes | `lead` when the record has a pipeline status, otherwise `client`. A lead whose status this version does not know reads `status: null`. | | `name` | `string \| null` | Yes | First name or display name, or null. | | `surname` | `string \| null` | Yes | Surname, or null. | | `email` | `string \| null` | Yes | Email address as stored, or null. | | `company` | `string \| null` | Yes | Company, or null. | | `phone` | `string \| null` | Yes | Phone in E.164, only when the stored number validates; otherwise null. | | `phone_status` | `"valid" \| "invalid" \| "missing"` | Yes | `valid` (see `phone`), `invalid` (a number is stored but does not validate, for example one stored without its country prefix) or `missing`. | | `status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | Pipeline status; null for a client record, or for a status this version does not know. `converted` is not "won". | | `status_set_by` | `"human" \| "ai" \| "system" \| "api" \| "unknown" \| null` | Yes | Who set the current status: a person, the AI, Totem automation, an API caller, or unknown (no recorded transition lands on it). Null when `status` is null. | | `archive_reason` | `"opt_out" \| "no_consent" \| "not_interested" \| "not_qualified" \| "no_contact" \| "merged" \| "family_transfer" \| "other" \| null` | Yes | Why the record was archived, as a closed code; stored free text is `other`. Null when none is stored. | | `archived_from_status` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | The status the lead had before its current archive; null while it is not archived or when unknown. | | `contact_permissions` | `object` | Yes | Contact permissions. Apply them before any outreach from your own systems. | | `contact_permissions.do_not_contact` | `boolean` | Yes | True when the person asked never to be contacted (the `opt_out` archive reason). Stop every channel. | | `contact_permissions.email_opt_out` | `boolean` | Yes | True when the person unsubscribed from marketing and follow-up email. | | `contact_permissions.email_opt_out_at` | `string (date-time) \| null` | Yes | When the email opt-out was recorded, or null. | | `contact_permissions.whatsapp_consent` | `boolean \| null` | Yes | WhatsApp consent: false when refused, true when recorded, null when unknown. | | `contact_permissions.provenance` | `object` | Yes | The source of each permission. | | `contact_permissions.provenance.do_not_contact` | `"archive_reason" \| null` | Yes | Where `do_not_contact` comes from, or null when it is false. | | `contact_permissions.provenance.email_opt_out` | `"unsubscribe_link" \| "manual" \| "unknown" \| null` | Yes | Who recorded the email opt-out: the unsubscribe link, a person in Totem, or unknown. Null when there is none. | | `contact_permissions.provenance.whatsapp_consent` | `"stored" \| "archive_reason" \| null` | Yes | `archive_reason` when a refusal is recorded as the archive reason; `stored` for the value kept on the record, which team members can edit. Null when unknown. | | `merged_into` | `string \| null` | Yes | The lead this record was merged into, or null (also when that lead no longer exists). | | `transferred_to` | `object \| null` | Yes | Where a transfer to another team of your organization sent this lead, or null. The team may be one your key does not read. | | `transferred_to.team` | `string` | Yes | The destination team. | | `transferred_to.lead` | `string \| null` | Yes | The destination lead, or null when its id cannot be named across teams: an assistant handoff created it, or the transfer landed on an existing lead-ads lead (Meta, Google Ads or LinkedIn) or another lead with a fixed id. | | `revived_at` | `string (date-time) \| null` | Yes | When the contact last came back on their own and revived an archived lead, or null. | | `pipeline_cleared` | `boolean` | Yes | True when the lead was converted with "clear pipeline data": board, stage, priority, assignees, estimated value and the source fields other than `source_channel` are null because they were cleared. | | `converted_at` | `string (date-time) \| null` | Yes | When the lead was converted, while its status is `converted`; otherwise null. | | `value_at_conversion` | `number \| null` | Yes | The estimated value when it was converted, in major units. Survives "clear pipeline data". | | `priority` | `"low" \| "medium" \| "high" \| "urgent" \| null` | Yes | Priority, or null. | | `tags` | `Array` | Yes | Labels as stored. `lead` and `booking` are system labels. | | `board` | `string \| null` | Yes | The board, resolved through the stage when it has one; or null. | | `stage` | `string \| null` | Yes | The stage, or null. | | `assignees` | `Array \| null` | Yes | Assigned members, sorted. Null when the record has no pipeline data. | | `estimated_value` | `number \| null` | Yes | Estimated value in major units as stored (decimals allowed), or null. | | `estimated_value_currency` | `null` | Yes | Always null: Totem stores no currency with the value. | | `source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Yes | The stored lead source; `api` also covers leads created by Totem itself (for example from a conversation or a call), see `source_detail`. Null when none is stored. | | `source_channel` | `string \| null` | Yes | The channel the record started on (for example `whatsapp`, `phone`, `widget`), or null. Team members can edit it. | | `source_detail` | `string \| null` | Yes | `conversation`, `call`, `external:`, `gated_ad:`, `family_handoff`, `api_intake` or `unknown`; null when `source` needs no detail. | | `source_provenance` | `"server_written" \| "member_editable" \| null` | Yes | `server_written` when Totem set the source from what the ad platform reported; otherwise `member_editable` (team members can change it). Null with `source`. | | `created_at` | `string (date-time)` | Yes | When the record was created. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | | `attribution` | [`LeadAttribution \| null`](https://totemstudio.ai/developers/objects#LeadAttribution) | Yes | The lead's attribution facet, or null while Totem is still preparing it. | | `response` | [`LeadResponse \| null`](https://totemstudio.ai/developers/objects#LeadResponse) | Yes | The lead's response facet, or null while Totem is still preparing it. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z", "attribution": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" }, "response": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } } ``` ## The LeadAttribution object Where a lead came from: its original capture and latest submission. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead_attribution"` | Yes | Always `lead_attribution`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the lead belongs to. | | `original_capture` | `object \| null` | Yes | How the lead first entered the CRM, frozen once; null when it was not captured. "Original" is the first time the contact entered the CRM, not the first ad it answered. | | `original_capture.capture` | `"at_creation" \| "at_activation"` | Yes | `at_creation`: frozen from the lead as it was first saved. `at_activation`: copied once from the lead as it was when the API was activated, for a lead with no capture record (created before Totem started recording attribution, or whose creation was not recorded). | | `original_capture.captured_at` | `string (date-time)` | Yes | When the record was written. | | `original_capture.source` | `"meta_lead_ads" \| "manual" \| "website_form" \| "booking_page" \| "csv_import" \| "api" \| "chatbot" \| "google_ads" \| "linkedin" \| "other" \| null` | Yes | The lead source at capture, from the same set as the lead's `source`; `api` also covers leads created by Totem itself (for example from a conversation or a call, see `origin`). Null when none was stored. | | `original_capture.source_hint` | `string \| null` | Yes | The real source of a gated ad lead stored as `api`, or null. | | `original_capture.source_channel` | `string \| null` | Yes | The channel at capture, or null. | | `original_capture.external_provider` | `string \| null` | Yes | The portal or booking platform of a forwarded or synced lead, or null. | | `original_capture.received_at` | `string (date-time) \| null` | Yes | When the lead was received, as first written, or null. | | `original_capture.submitted_at` | `string (date-time) \| null` | Yes | When the ad platform says the form was submitted, or null. | | `original_capture.origin` | `object \| null` | Yes | The conversation or call that created the lead (never its id), or null (also for an origin of another kind). | | `original_capture.origin.kind` | `"conversation" \| "call"` | Yes | `conversation` or `call`. | | `original_capture.origin.channel` | `string \| null` | Yes | Its channel, or null. | | `original_capture.campaign` | `object` | Yes | The campaign. | | `original_capture.campaign.id` | `object` | Yes | Its id. | | `original_capture.campaign.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.campaign.name` | `object` | Yes | Its name. | | `original_capture.campaign.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad` | `object` | Yes | The ad. | | `original_capture.ad.id` | `object` | Yes | Its id. | | `original_capture.ad.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad.name` | `object` | Yes | Its name. | | `original_capture.ad.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_set` | `object` | Yes | The Meta ad set. | | `original_capture.ad_set.id` | `object` | Yes | Its id. | | `original_capture.ad_set.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_set.name` | `object` | Yes | Its name. | | `original_capture.ad_set.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_group` | `object` | Yes | The Google Ads ad group. | | `original_capture.ad_group.id` | `object` | Yes | Its id. | | `original_capture.ad_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.form` | `object` | Yes | The lead form. | | `original_capture.form.id` | `object` | Yes | Its id. | | `original_capture.form.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.form.name` | `object` | Yes | Its name. | | `original_capture.form.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.creative` | `object` | Yes | The creative. | | `original_capture.creative.id` | `object` | Yes | Its id. | | `original_capture.creative.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.asset_group` | `object` | Yes | The Google Ads asset group. | | `original_capture.asset_group.id` | `object` | Yes | Its id. | | `original_capture.asset_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.page` | `object` | Yes | The Facebook page. | | `original_capture.page.id` | `object` | Yes | Its id. | | `original_capture.page.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.page.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.page.name` | `object` | Yes | Its name. | | `original_capture.page.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.page.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm` | `object \| null` | Yes | The five standard UTM keys, or null when none was captured. | | `original_capture.utm.source` | `object` | Yes | `utm_source`. | | `original_capture.utm.source.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.medium` | `object` | Yes | `utm_medium`. | | `original_capture.utm.medium.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.campaign` | `object` | Yes | `utm_campaign`. | | `original_capture.utm.campaign.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.term` | `object` | Yes | `utm_term`. | | `original_capture.utm.term.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.utm.content` | `object` | Yes | `utm_content`. | | `original_capture.utm.content.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.click_network` | `object` | Yes | The network of the first click id (`google` or `meta`). The click id itself is never kept. | | `original_capture.click_network.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `original_capture.click_network.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `original_capture.ad_referral` | `object \| null` | Yes | The first Meta ad referral of the conversation that created the lead, or null. | | `original_capture.ad_referral.channel` | `"whatsapp" \| "facebook" \| "instagram"` | Yes | The channel. | | `original_capture.ad_referral.source_type` | `"ad" \| "post" \| "link" \| "other"` | Yes | What opened the conversation. | | `original_capture.ad_referral.ad_id` | `string \| null` | Yes | The ad or post id, or null. | | `original_capture.ad_referral.referred_at` | `string (date-time)` | Yes | Meta's time for the referral. | | `original_capture.ad_referral.provenance` | `"provider"` | Yes | Always `provider`: only the signed Meta webhooks write it. | | `original_capture.family_origin` | `object \| null` | Yes | The transfer from another team of your organization that created the lead, or null. | | `original_capture.family_origin.team` | `string` | Yes | The team of your organization the lead came from. | | `original_capture.family_origin.lead` | `string \| null` | Yes | Its lead there, or null when that id cannot be named across teams: an assistant handed off a caller without one, or the lead there is a lead-ads lead (Meta, Google Ads or LinkedIn) or another lead with a fixed id. `automated` tells an assistant's handoff from a move made by a person. | | `original_capture.family_origin.automated` | `boolean` | Yes | True for an assistant's handoff. | | `original_capture.audience` | `object` | Yes | Not available in this version. | | `original_capture.audience.value` | `null` | Yes | Always null. | | `original_capture.audience.missing_reason` | `"unsupported"` | Yes | Totem does not record audiences; an ad set is not one. | | `original_capture_missing_reason` | `"not_captured" \| null` | Yes | `not_captured` when `original_capture` is null, otherwise null. | | `latest_submission` | `object \| null` | Yes | The newest ad submission recorded for this lead, including a repeat on an existing contact; null when none is recorded. A submission the ad platform reports deleted (a LinkedIn member deleting their form response) is no longer shown, and once the newest one expires or is withdrawn an older one can show instead. | | `latest_submission.provider` | `"meta" \| "google_ads" \| "linkedin"` | Yes | The ad platform. | | `latest_submission.submitted_at` | `string (date-time) \| null` | Yes | When the platform says it was submitted, or null. | | `latest_submission.campaign` | `object` | Yes | The campaign. | | `latest_submission.campaign.id` | `object` | Yes | Its id. | | `latest_submission.campaign.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.campaign.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.campaign.name` | `object` | Yes | Its name. | | `latest_submission.campaign.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.campaign.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad` | `object` | Yes | The ad. | | `latest_submission.ad.id` | `object` | Yes | Its id. | | `latest_submission.ad.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad.name` | `object` | Yes | Its name. | | `latest_submission.ad.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad_set` | `object` | Yes | The Meta ad set. | | `latest_submission.ad_set.id` | `object` | Yes | Its id. | | `latest_submission.ad_set.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad_set.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad_set.name` | `object` | Yes | Its name. | | `latest_submission.ad_set.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad_set.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.ad_group` | `object` | Yes | The Google Ads ad group. | | `latest_submission.ad_group.id` | `object` | Yes | Its id. | | `latest_submission.ad_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.ad_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.form` | `object` | Yes | The lead form. | | `latest_submission.form.id` | `object` | Yes | Its id. | | `latest_submission.form.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.form.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.form.name` | `object` | Yes | Its name. | | `latest_submission.form.name.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.form.name.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.creative` | `object` | Yes | The creative. | | `latest_submission.creative.id` | `object` | Yes | Its id. | | `latest_submission.creative.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.creative.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.asset_group` | `object` | Yes | The Google Ads asset group. | | `latest_submission.asset_group.id` | `object` | Yes | Its id. | | `latest_submission.asset_group.id.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.asset_group.id.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm` | `object \| null` | Yes | Its UTM keys, or null. | | `latest_submission.utm.source` | `object` | Yes | `utm_source`. | | `latest_submission.utm.source.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.source.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.medium` | `object` | Yes | `utm_medium`. | | `latest_submission.utm.medium.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.medium.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.campaign` | `object` | Yes | `utm_campaign`. | | `latest_submission.utm.campaign.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.campaign.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.term` | `object` | Yes | `utm_term`. | | `latest_submission.utm.term.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.term.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.utm.content` | `object` | Yes | `utm_content`. | | `latest_submission.utm.content.value` | `string \| null` | Yes | The value, or null when it was not captured. | | `latest_submission.utm.content.provenance` | `"provider" \| "team_alias" \| "stored_metadata" \| "stored_metadata_at_activation" \| "not_captured"` | Yes | `provider` (reported by the ad platform), `team_alias` (typed by staff), `stored_metadata` (editable lead data), `stored_metadata_at_activation` (copied once when the API was activated, for a lead created before capture), or `not_captured` exactly when `value` is null. | | `latest_submission.expires_at` | `string (date-time) \| null` | Yes | When this submission stops being readable (Google Ads and LinkedIn keep it 90 days); null for Meta. | | `latest_submission_missing_reason` | `"not_captured" \| null` | Yes | `not_captured` when `latest_submission` is null, otherwise null. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_attribution", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "original_capture": { "capture": "at_creation", "captured_at": "2026-09-28T10:15:00.000Z", "source": "meta_lead_ads", "source_hint": null, "source_channel": null, "external_provider": null, "received_at": "2026-09-28T10:15:00.000Z", "submitted_at": "2026-09-28T10:14:30.000Z", "origin": null, "campaign": { "id": { "value": "120210000000000101", "provenance": "provider" }, "name": { "value": "Spring check-up", "provenance": "provider" } }, "ad": { "id": { "value": "120210000000000303", "provenance": "provider" }, "name": { "value": "Free first visit", "provenance": "provider" } }, "ad_set": { "id": { "value": "120210000000000202", "provenance": "provider" }, "name": { "value": "Families nearby", "provenance": "provider" } }, "ad_group": { "id": { "value": null, "provenance": "not_captured" } }, "form": { "id": { "value": "900000000000000404", "provenance": "provider" }, "name": { "value": "Book a check-up", "provenance": "provider" } }, "creative": { "id": { "value": null, "provenance": "not_captured" } }, "asset_group": { "id": { "value": null, "provenance": "not_captured" } }, "page": { "id": { "value": "100000000000000505", "provenance": "provider" }, "name": { "value": "Example Dental", "provenance": "provider" } }, "utm": null, "click_network": { "value": null, "provenance": "not_captured" }, "ad_referral": null, "family_origin": null, "audience": { "value": null, "missing_reason": "unsupported" } }, "original_capture_missing_reason": null, "latest_submission": null, "latest_submission_missing_reason": "not_captured", "version": 1, "updated_at": "2026-09-28T10:15:02.100Z" } ``` ## The LeadResponse object How the team has answered a lead across its conversations and calls: the latest messages each way and whether a reply is due. It uses its lead's id. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"lead_response"` | Yes | Always `lead_response`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `last_inbound_at` | `string (date-time) \| null` | Yes | The latest message from the customer on any of the lead's conversations (UTC), or null. Not counted: erased messages, imported WhatsApp history and web-chat visitor turns. Instagram shares cannot be told apart and count. | | `last_outbound_at` | `string (date-time) \| null` | Yes | The latest message sent to the customer that did not fail, on any of the lead's conversations and whoever sent it (UTC), or null. Imported WhatsApp history counts. | | `awaiting_reply` | `boolean` | Yes | True when `last_inbound_at` is newer than the latest reply on any of the lead's conversations. A reply is a sent message that did not fail and was recorded as a conversation reply or with no purpose; outreach, transactional messages (such as reminders) and imported history do not count. Most stored messages carry no purpose, so an automated send recorded without one also counts as a reply. False when the customer has never written. | | `last_answered_call_at` | `string (date-time) \| null` | Yes | When the latest of the lead's calls whose `outcome` is `answered_human` or `answered_ai` started (UTC), or null. A connected outbound call on Totem's phone lines reads `unknown`, so it is not counted. | | `last_whatsapp_delivery_status` | `"delivered" \| "read" \| "failed" \| null` | Yes | `delivered`, `read` or `failed` for the latest message sent to the lead on WhatsApp. Null when that message has no receipt yet (`sent` is never stored) or the lead has no message sent on WhatsApp. | | `coverage` | `object` | Yes | What these facts were computed from. | | `coverage.conversations` | `integer` | Yes | The lead's conversations these facts were computed from, newest first; at most 20. | | `coverage.calls` | `integer` | Yes | The lead's calls `last_answered_call_at` was computed from, newest first; at most 50. | | `coverage.channels_without_delivery_data` | `Array<"sms" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email">` | Yes | The channels of those conversations that store no delivery receipts (every channel but WhatsApp), sorted. | | `coverage.truncated` | `boolean` | Yes | True when a read limit was reached (20 conversations, 200 messages or 50 calls): older activity was not read, so a time can be null or older than the truth, and `awaiting_reply` can miss an older reply. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead_response", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "awaiting_reply": false, "last_answered_call_at": "2026-09-28T10:17:00.000Z", "last_whatsapp_delivery_status": "read", "coverage": { "conversations": 2, "calls": 1, "channels_without_delivery_data": [ "email" ], "truncated": false }, "version": 7, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ## The Activity object One stage, status or assignee transition of a lead. Activities never change once written. ### Attributes of every variant | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"activity"` | Yes | Always `activity`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team the lead belongs to. | | `lead` | `string \| null` | Yes | The lead that moved. Null only while Totem has not yet assigned the lead its id. | | `occurred_at` | `string (date-time)` | Yes | When the change was saved. | | `actor_kind` | `"human" \| "ai" \| "system" \| "api" \| "unknown"` | Yes | Who made the change: a person, the AI, Totem automation, an API caller, or unknown. Totem knows who made a change only when the same save wrote the lead's status, so a stage or assignee change made on its own reads unknown. | | `stage_snapshot` | `object \| null` | Yes | The stage the lead was in after the change (for "clear pipeline data", the stage it left), or null. | | `stage_snapshot.stage` | `string \| null` | Yes | The stage. | | `stage_snapshot.board` | `string \| null` | Yes | Its board, or null for a legacy stage without one. | | `stage_snapshot.name` | `string \| null` | Yes | The stage name when the change was recorded, or null when the stage was already deleted. | | `value_snapshot` | `number \| null` | Yes | The estimated value at that moment, in major units, or null. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Variants Each `Activity` is exactly one of these 3 variants. #### A status transition When `type` is one of `status_changed`, `archived`, `unarchived`, `revived`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `type` | `"status_changed" \| "archived" \| "unarchived" \| "revived"` | Yes | `status_changed`, `archived`, `unarchived`, or `revived` (the contact came back on their own and reopened an archived lead). | | `from` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable" \| null` | Yes | The status before, or null when there was none. | | `to` | `"new" \| "contacted" \| "qualified" \| "negotiating" \| "converted" \| "lost" \| "archived" \| "unreachable"` | Yes | The status after. | #### A stage move When `type` is `stage_changed`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `type` | `"stage_changed"` | Yes | Always `stage_changed`. | | `from` | `object \| null` | Yes | The stage and board before, or null when the lead had neither. | | `from.stage` | `string \| null` | Yes | The stage. | | `from.board` | `string \| null` | Yes | The board, or null for a legacy stage without one. | | `to` | `object` | Yes | The stage and board after. | | `to.stage` | `string \| null` | Yes | The stage. | | `to.board` | `string \| null` | Yes | The board, or null for a legacy stage without one. | #### A change of assigned members When `type` is `assignees_changed`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `type` | `"assignees_changed"` | Yes | Always `assignees_changed`. | | `from` | `Array` | Yes | The assigned members before, sorted. | | `to` | `Array` | Yes | The assigned members after, sorted. | ### Example ```json { "id": "act_aBgzeetVKDEjpAAkXfEFF53ZGd2ZMmGhUDKgagqLbh6c7R1GqOl0xsFw7pyRhD3SqqYWGMdb371xxj", "object": "activity", "livemode": false, "type": "stage_changed", "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "occurred_at": "2026-09-28T10:20:00.000Z", "actor_kind": "ai", "stage_snapshot": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "name": "Contacted" }, "value_snapshot": 1200, "from": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "to": { "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst" }, "version": 1, "updated_at": "2026-09-28T10:20:01.500Z" } ``` ## The Board object A board with its stages in pipeline order. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"board"` | Yes | Always `board`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `name` | `string \| null` | Yes | The board's name, or null when none is stored. | | `is_default` | `boolean` | Yes | True for the team's default board, where new leads land unless routed elsewhere. | | `is_archived` | `boolean` | Yes | True when the board is archived (hidden from the board selector). Its leads still export. | | `appointment_booked_stage` | `string \| null` | Yes | The stage of this board a lead moves to when its first appointment is confirmed, or null when none is set or it names no stage of this board. | | `stages` | `Array` | Yes | The board's stages, ordered by position, then by id. A stage with no position stored at all is left out, although a lead can still name it. | | `stages[].object` | `"stage"` | Yes | Always `stage`. | | `stages[].id` | `string` | Yes | Public id of the stage; a lead's `stage` names it. | | `stages[].name` | `string \| null` | Yes | The stage's name, or null when none is stored. | | `stages[].position` | `number \| null` | Yes | The stage's position on its board, left to right; null when the stored position is not a number. Stages are listed in this order. | | `stages[].is_default` | `boolean` | Yes | True for the stage new leads of this board enter. | | `stages[].is_final` | `boolean` | Yes | True for a final stage: moving a lead here marks it converted. | | `stages[].is_archive` | `boolean` | Yes | True for an archive stage, whose leads are hidden from the main board view. | | `stages[].mode` | `"standard" \| "supervised" \| "automated" \| null` | Yes | Who moves leads: `standard` (people only), `supervised` (AI may move leads in, people move them out) or `automated` (AI runs outreach and moves leads in and out); null for a value this version does not know. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "object": "board", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "New patients", "is_default": true, "is_archived": false, "appointment_booked_stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "stages": [ { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVR3422Yg", "name": "New", "position": 0, "is_default": true, "is_final": false, "is_archive": false, "mode": "automated" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "name": "Contacted", "position": 1, "is_default": false, "is_final": false, "is_archive": false, "mode": "supervised" }, { "object": "stage", "id": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVT1QpfZ5", "name": "Visit booked", "position": 2, "is_default": false, "is_final": true, "is_archive": false, "mode": "standard" } ], "version": 4, "updated_at": "2026-09-02T16:40:00.000Z" } ``` ## The Call object A call: its direction, timing and a normalized outcome. No recording or transcript. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"call"` | Yes | Always `call`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this call is linked to, or null when it is unlinked or the lead no longer exists. Identity (name, phone) travels on the lead only. | | `direction` | `"inbound" \| "outbound" \| null` | Yes | `inbound` or `outbound`; null when not stored. | | `started_at` | `string (date-time) \| null` | Yes | When the call started (UTC), or when it was recorded if the provider gave no start time. | | `duration_seconds` | `integer \| null` | Yes | Whole seconds of the call (rounded down); null while the call is still live, or when not stored. | | `outcome` | `"answered_human" \| "answered_ai" \| "voicemail" \| "no_answer" \| "busy" \| "failed" \| "unknown"` | Yes | How the call ended: `answered_human`, `answered_ai`, `voicemail`, `no_answer`, `busy`, `failed`, or `unknown` (still live, or no evidence of who answered). Read it with `outcome_basis`. | | `outcome_basis` | `"inbound_verdict" \| "ai_analysis" \| "duration_floor" \| "carrier_answer_only" \| "browser_session" \| "none"` | Yes | The evidence behind `outcome`: `inbound_verdict` (Totem's classification of an inbound call on its phone lines), `ai_analysis` (Totem's classification of the status, end reason and length that Retell reports), `duration_floor` (a call on a connected Zadarma PBX, where at least 10 seconds counts as answered), `carrier_answer_only` (only the carrier's answer signal, which never counts as answered, so a connected call reads `unknown`: every outbound call on Totem's phone lines, made by the AI or by a person, and an inbound one Totem has not classified), `browser_session` (a web voice session with the assistant), or `none` (no evidence, as for a call that is still live). | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk", "object": "call", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "direction": "inbound", "started_at": "2026-09-28T10:17:00.000Z", "duration_seconds": 184, "outcome": "answered_ai", "outcome_basis": "inbound_verdict", "version": 2, "updated_at": "2026-09-28T10:21:04.010Z" } ``` ## The Conversation object A conversation on one channel: its status, assignee and the times of the latest messages, never their content. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"conversation"` | Yes | Always `conversation`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this conversation is linked to, or null when it is unlinked or the lead no longer exists. Identity (name, phone, handle) travels on the lead only. | | `channel_type` | `"sms" \| "whatsapp" \| "facebook" \| "instagram" \| "tiktok" \| "chatbot" \| "email" \| null` | Yes | The channel: `whatsapp`, `email`, `chatbot` (the web chat), `facebook`, `instagram`, `sms` or `tiktok`; null for a value this version does not know. A lead can have several conversations on the same channel and business account, one for each contact address (phone number, email address or handle) it has used, so key your copy on `id`. | | `status` | `"active" \| "archived" \| null` | Yes | `active` or `archived` in the team's inbox; null for a value this version does not know. | | `assignee` | `string \| null` | Yes | The team member assigned to the conversation (see `/members`), or null. | | `reply_mode` | `"ai" \| "human"` | Yes | `human` while a person has taken the conversation over, including a WhatsApp chat answered from the WhatsApp Business app; `ai` otherwise. `ai` means no takeover is active, not that an assistant is set up or answering on this channel. A timed takeover returns to `ai` when it expires, and the record's version changes then. | | `created_at` | `string (date-time) \| null` | Yes | When the conversation was created (UTC). | | `last_inbound_at` | `string (date-time) \| null` | Yes | The latest customer message (UTC). Not counted: erased messages, imported WhatsApp history and web-chat visitor turns, so a `chatbot` conversation reads null. Instagram shares cannot be told apart and count. | | `last_outbound_at` | `string (date-time) \| null` | Yes | The latest message sent to the customer that did not fail (UTC), whoever sent it. Imported WhatsApp history counts. | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "conv_m3GhGjRHmFbww1g10oqOp7j3gWqPJ", "object": "conversation", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "channel_type": "whatsapp", "status": "active", "assignee": "mem_h548E7UiYhutHhucVrQCzkM0eaea0", "reply_mode": "ai", "created_at": "2026-09-28T10:16:00.000Z", "last_inbound_at": "2026-10-01T08:58:00.000Z", "last_outbound_at": "2026-10-01T08:59:30.000Z", "version": 12, "updated_at": "2026-10-01T08:59:31.040Z" } ``` ## The Booking object A booking and its `kind`: an appointment for a bookable service, an event from the connected calendar, or a booking imported from another system. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"booking"` | Yes | Always `booking`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `lead` | `string \| null` | Yes | The lead this booking belongs to, or null for a booking without a lead or when the lead no longer exists. Names and contact details appear only on the lead. | | `kind` | `"service_booking" \| "calendar_event" \| "external_import"` | Yes | `service_booking` (booked for a bookable service, by the AI, a customer or a person), `calendar_event` (an event from the connected calendar) or `external_import` (synced from an external booking system or read from a portal email). A booking from an origin this version does not know is reported as `calendar_event`. | | `service` | `string \| null` | Yes | The booked service (see `/booking_services`) on a `service_booking`; null otherwise. | | `status` | `"scheduled" \| "rescheduled" \| "cancelled" \| "postponed" \| "attended" \| "no_show" \| "team_no_show" \| null` | Yes | `scheduled`, `rescheduled`, `cancelled`, `postponed`, `attended`, `no_show` or `team_no_show` (attendance is recorded only by people); null for a value this version does not know. `rescheduled` means the booking was moved in place and its times are the new ones; a booking the AI replaced with a new one reads `cancelled`, with `rescheduled_to` set. | | `starts_at` | `string (date-time) \| null` | Yes | When the booking starts (UTC). | | `ends_at` | `string (date-time) \| null` | Yes | When the booking ends (UTC). | | `meeting_type` | `"online" \| "in_person" \| "phone" \| null` | Yes | `online`, `in_person` or `phone`; null when not stored. | | `source` | `"ai" \| "customer" \| "operator" \| null` | Yes | Who booked it: `ai`, `customer` or `operator`; null when not recorded. | | `rescheduled_from` | `string \| null` | Yes | The booking this one replaced in a reschedule, or null. For a few seconds the replaced booking can still read `scheduled`; bookings rescheduled before Totem started recording this link may not have it. | | `rescheduled_to` | `string \| null` | Yes | The booking that replaced this one in a reschedule, or null. When it is set, this booking reads `cancelled`. | | `created_at` | `string (date-time) \| null` | Yes | When the booking was recorded in Totem (UTC). | | `version` | `integer` | Yes | Increases every time one of the record's exported fields changes. Apply a change only when its version is greater than the one you hold. | | `updated_at` | `string (date-time)` | Yes | When Totem recorded this version (UTC). It is not the time the business event happened. | ### Example ```json { "id": "bkg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3ttMKHRR9ADk3N7Cr3NSuY0", "object": "booking", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "kind": "service_booking", "service": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "status": "scheduled", "starts_at": "2026-10-06T15:00:00.000Z", "ends_at": "2026-10-06T15:30:00.000Z", "meeting_type": "in_person", "source": "ai", "rescheduled_from": null, "rescheduled_to": null, "created_at": "2026-10-01T09:01:00.000Z", "version": 1, "updated_at": "2026-10-01T09:01:02.000Z" } ``` ## The BookingService object A service customers can book, with its meeting type. ### Attributes | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record. | | `object` | `"booking_service"` | Yes | Always `booking_service`. | | `livemode` | `boolean` | Yes | `true` for real data; `false` in example data. | | `team` | `string` | Yes | The team this record belongs to. | | `name` | `string \| null` | Yes | The service's name, or null when none is stored. | | `meeting_type` | `"online" \| "in_person" \| "phone"` | Yes | How the service happens: `online` (the default when none is stored), `in_person` or `phone`. | ### Example ```json { "id": "svc_h42kgwwJGPzsm2Zoy1A1RBv1KIrX5", "object": "booking_service", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "name": "First visit", "meeting_type": "in_person" } ``` ## The FeedItem object One change: the record's current state, or its removal. ### Attributes of every variant | Field | Type | Always present | Description | | --- | --- | --- | --- | | `kind` | `"lead" \| "lead_attribution" \| "lead_response" \| "activity" \| "call" \| "conversation" \| "booking" \| "board"` | Yes | The kind of the record, such as `lead` or `call`. | | `version` | `integer` | Yes | The record's version. Apply the item only when it is greater than the version you hold. | ### Variants Each `FeedItem` is exactly one of these 2 variants. #### With `object` | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the record; the facets `lead_attribution` and `lead_response` use their lead's id. | | `object` | `Lead \| LeadAttribution \| LeadResponse \| Activity \| Call \| Conversation \| Booking \| Board` | Yes | The record as it is now. | #### With `removed` | Field | Type | Always present | Description | | --- | --- | --- | --- | | `id` | `string` | Yes | Public id of the removed record. | | `removed` | `object` | Yes | Present only on a removal. | | `removed.reason` | `"erased" \| "deleted"` | Yes | `erased` after an erasure, which is also what deleting a lead, call or conversation in Totem does: erasing a lead erases its calls, conversations and merged duplicates too, while its bookings stay with `lead` set to `null`. `deleted` for any other removal. Either way, delete your copy and keep this version, so an older copy delivered later is ignored. | ### Example ```json { "kind": "lead", "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "version": 3, "object": { "id": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG", "object": "lead", "livemode": false, "team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR", "record_kind": "lead", "name": "Alex", "surname": "Rivera", "email": "alex.rivera@example.com", "company": "Example Dental", "phone": "+12025550123", "phone_status": "valid", "status": "contacted", "status_set_by": "ai", "archive_reason": null, "archived_from_status": null, "contact_permissions": { "do_not_contact": false, "email_opt_out": false, "email_opt_out_at": null, "whatsapp_consent": null, "provenance": { "do_not_contact": null, "email_opt_out": null, "whatsapp_consent": null } }, "merged_into": null, "transferred_to": null, "revived_at": null, "pipeline_cleared": false, "converted_at": null, "value_at_conversion": null, "priority": "high", "tags": [ "spring-check-up" ], "board": "brd_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMX3trzsVUZQuHcMSuDh1aRIst", "stage": "stg_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXWBYWcKwVTY3CRZlVS3oYvs0", "assignees": [ "mem_h548E7UiYhutHhucVrQCzkM0eaea0" ], "estimated_value": 1200, "estimated_value_currency": null, "source": "meta_lead_ads", "source_channel": null, "source_detail": null, "source_provenance": "member_editable", "created_at": "2026-09-28T10:15:00.000Z", "version": 3, "updated_at": "2026-09-28T10:20:01.250Z" } } ``` --- # Changelog > Changes to the Totem API, newest first, each under the date it shipped. Source: https://totemstudio.ai/developers/changelog Each entry is dated, and an entry that asks you to change your integration or to run a full resync starts with **Action required**. [Conventions](https://totemstudio.ai/developers/conventions#what-beta-means) explains what can change during the Beta. ## 1.0a: Beta updates (2026-10-18) - `GET /v1/calls`, `GET /v1/conversations` and `GET /v1/bookings` take an optional `lead` filter: one lead's calls, conversations or bookings, without paging the whole list. - `POST /v1/leads/search` by `phone` also finds the number stored in the layouts phone libraries write (`+1 202-555-0123`, `(202) 555-0123` and `2025550123`), as older imports stored it. - The Spanish docs show each error's title in Spanish. The `title` of a problem body stays in English. - Totem stops exporting teams in a sensitive business sector, such as health care, until it changes its sector policy. Such a team can no longer be added to an integration, and a key that covers one gets `403 sector_restricted` for it. Skip the team. The changes feed delivers nothing for it, removals included, so keep what you already have only as long as your retention policy allows, then delete your copy. - Activities whose first capture failed are now captured later, so a lead can receive activities older than ones you already hold. - `transferred_to.lead` now documents that it is also `null` when the transfer landed on an existing lead-ads lead or another lead with a fixed id. - The docs now say that the API is reachable over IPv4 only, so an IPv6 entry in an IP allowlist never matches. - Calls that Totem reconciled or imported from its AI voice provider now report their real `outcome`, instead of `unknown`. A call still in progress reports `duration_seconds` as `null`. - A lead whose status this version does not know now reads `record_kind: "lead"` with `status: null`, instead of `client`. - A lead's `attribution.original_capture.origin.kind` is always `conversation` or `call`, and `original_capture.source` uses the same values as the lead's `source`. A LinkedIn submission the person withdrew is no longer reported as the latest submission. - **Action required.** A one-team key stops reading its team, with `404 team_not_accessible`, once the team joins, leaves or changes organization. Revoke it and create a new one. - The per-key rate limit charges every request it counts at least one item, including a search with no match and a `404`. [Rate limits](https://totemstudio.ai/developers/rate-limits) lists what is not charged. - A tampered `cursor` on `GET /v1/sync/changes` gets `400 invalid_request`, instead of a `500`. - Failed attempts with a revoked or expired key no longer count toward a lockout, and a locked key keeps working for the holder of its real secret. - `limit` documents its default of 25. The docs now explain what deleting a lead, call or conversation in Totem sends through the changes feed, and what the firewall answers outside `/v1/`. - [TypeScript SDK](https://totemstudio.ai/developers/typescript) 0.1.0-beta.2: typed error actions and codes, deadline-aware retries, open enum types and Node.js 22.12 support. ## 1.0a: Beta (2026-10-18) Public Beta. Eligible team and organization administrators can create integrations in **Settings → Developers** without an invitation. - The first version of the API: read-only, and in beta. - Endpoints for the integration and its teams, boards, members, bookable services, leads (with their attribution, response and activities), calls, conversations and bookings. - The changes feed, [`GET /v1/sync/changes`](https://totemstudio.ai/developers/reference-sync#sync.changes), with removals and full resync. - Secret keys with roll, revoke and an optional IP allowlist, managed in **Settings → Developers**. - No webhooks: poll the changes feed every 1 to 5 minutes. --- # Support > How to get help with the Totem API, what to include in a request, and what to do if a key leaks. Source: https://totemstudio.ai/developers/support ## How to reach us The Totem team handles API support requests. - **From Totem.** An administrator of your team opens [Help](https://app.totemstudio.ai/help) in the Totem app and sends a support request. It is the fastest route, because the request arrives tied to your team. - **By email.** Write to hello@totemstudio.ai. A developer working for a customer can write too: say which customer and which integration the question is about. There is no separate API status page yet. If the API seems down, call [`GET /v1/health`](https://totemstudio.ai/developers/reference-account#health), which needs no key, and tell us what it answered. ## What to include - the `Totem-Request-Id` of the response that failed, if it has one, and its `Totem-Release` header, if it has one; - the time of the request, in UTC; - the method and the path, such as `GET /v1/sync/changes`; - the HTTP status and the error's `code`; - the integration's name and the last four characters of its key; - the team id (`team_…`) and the ids of the records concerned. ## What never to send Never send a secret key, in full or in part beyond its last four characters. Do not paste personal data from responses, such as names, phone numbers or email addresses: ids and request ids are enough for us to investigate. ## If a key leaks Choose **Roll key** at once in [**Settings → Developers**](https://app.totemstudio.ai/settings?tab=developers) and tick **This key may have been compromised**, so the old key is revoked with no grace period. Then tell us the integration's name, when you think the key leaked and who or what had access to it. [Authentication](https://totemstudio.ai/developers/authentication#roll-and-revoke) explains rolling and revoking. To report a suspected vulnerability in the API, these docs or the TypeScript SDK, write privately to hello@totemstudio.ai, as [Security and personal data](https://totemstudio.ai/developers/security#report-a-vulnerability) explains. ## Notices The API is in **Beta**. The [changelog](https://totemstudio.ai/developers/changelog) lists changes under the date they shipped, and [Conventions](https://totemstudio.ai/developers/conventions#what-beta-means) explains what can change during the Beta. --- # Glossary > The terms these docs use, defined in one place, each with a link to the page that explains it. Source: https://totemstudio.ai/developers/glossary The words these docs use, in one place. Each entry links to the page that explains it in full. ## Account **Integration.** The connection to the Totem API that an administrator creates in **Settings → Developers**: a name and the teams it reads, with an optional description and IP allowlist. The interface calls this **Create API key**. Its id starts with `prn_`, and [`GET /v1/me`](https://totemstudio.ai/developers/reference-account#me.get) describes it. See [Authentication](https://totemstudio.ai/developers/authentication). **Key.** The secret, starting with `tsk_live_`, that your server sends in the `Authorization` header. It belongs to one integration. Totem shows it once; you can roll it or revoke it at any time. See [Authentication](https://totemstudio.ai/developers/authentication). **Team.** A workspace in Totem, with its own boards, members, leads, calls, conversations and bookings. Its id starts with `team_`. A key reads one team or several, and requests for one team's collections name it in the `Totem-Team` header when the key has several. See [Authentication](https://totemstudio.ai/developers/authentication). **Organization.** A billing account and its sub-teams. One integration can cover several teams, as long as they belong to the same organization. In **Settings → Developers**, an integration that covers several of its teams has the access scope **Selected family teams**, and API names such as `family_transfer` and `family_origin` refer to it. See [Authentication](https://totemstudio.ai/developers/authentication). **Family administrator.** An administrator of an organization's billing account who is also listed on it as a family administrator. Only a family administrator can create or change an integration that covers several teams. See [Authentication](https://totemstudio.ai/developers/authentication#teams-and-the-totem-team-header). **Member.** A person in a team, known by display name only. Member ids start with `mem_` and belong to one team: the same person has a different id in each team. See [`GET /v1/members`](https://totemstudio.ai/developers/reference-account#members.list). ## CRM records **Board.** A pipeline that a team uses to organize its leads, with its stages in order. Board ids start with `brd_`. See [`GET /v1/boards`](https://totemstudio.ai/developers/reference-leads#boards.list). **Stage.** One step of a board, such as "Contacted". Stage ids start with `stg_`. A lead's `stage` names the step it is on, and stages travel inside their board. See [`GET /v1/boards`](https://totemstudio.ai/developers/reference-leads#boards.list). **Lead.** A person or company a team works with, with their place in the pipeline, their origin and their contact permissions. Lead ids start with `lead_`. See the [Leads reference](https://totemstudio.ai/developers/reference-leads). **Client.** A record of the leads endpoints with `record_kind: "client"`. It has no pipeline status, so its `status` is `null`. See [`GET /v1/leads`](https://totemstudio.ai/developers/reference-leads#leads.list). **Activity.** One stage, status or assignee change of a lead. An activity never changes once written. Activity ids start with `act_`. See [`GET /v1/leads/{id}/activities`](https://totemstudio.ai/developers/reference-leads#leads.activities). **Facet.** A part of a lead that the API delivers as an object of its own, with the lead's id and a version of its own. A lead has two: its **attribution**, where it came from, and its **response**, how the team has responded to it across conversations and calls. A new message changes the response, not the lead. See [`GET /v1/leads/{id}`](https://totemstudio.ai/developers/reference-leads#leads.get). **Call.** The metadata of a phone call with a lead, such as its direction, duration and outcome. Never a recording or a transcript. Call ids start with `call_`. See the [Calls reference](https://totemstudio.ai/developers/reference-calls). **Conversation.** The metadata of a conversation with a lead on one channel, such as its status and when the last messages came and went, without the messages themselves. Conversation ids start with `conv_`. See the [Conversations reference](https://totemstudio.ai/developers/reference-conversations). **Booking.** An appointment on the team's calendar: booked for a bookable service, an event from the connected calendar, or an import from an external booking system. Booking ids start with `bkg_`. See the [Bookings reference](https://totemstudio.ai/developers/reference-bookings). **Bookable service.** Something people can book with the team, such as a first consultation, held online, in person or by phone. Its id starts with `svc_`. See [`GET /v1/booking_services`](https://totemstudio.ai/developers/reference-bookings#booking_services.list). ## Sync **Changes feed.** [`GET /v1/sync/changes`](https://totemstudio.ai/developers/reference-sync#sync.changes): every change to one team's records, in order, page by page. Each item is a record of one `kind`, or a removal. See [Keep a copy in sync](https://totemstudio.ai/developers/sync). **Cursor.** An opaque string that marks your place in a list or in the changes feed. Store it as you received it, and never build or edit one. A changes-feed cursor belongs to one team, and stops working 30 days after the point in the feed it reached or, while an initial sync is still running, that many days after the sync started. See [Lists and pagination](https://totemstudio.ai/developers/pagination) and [Save the cursor](https://totemstudio.ai/developers/sync#save-the-cursor). **Version.** An integer on every record the changes feed delivers, which grows each time the record changes. Apply an item only when its version is greater than the one you hold. See [Conventions](https://totemstudio.ai/developers/conventions). **Initial sync.** The first pass through the changes feed, without a cursor. It delivers every record of the team, then catches up with the changes made meanwhile. See [Run the initial sync](https://totemstudio.ai/developers/sync#run-the-initial-sync). **Full resync.** A new initial sync that rebuilds your copy of a team, after `410 sync_cursor_expired` or `409 resync_required`. It works by **mark and sweep**: forget the versions you stored, mark every record the feed delivers and, once it has caught up, delete every record of that team you did not mark. See [Full resync](https://totemstudio.ai/developers/sync#full-resync). **Removal.** A changes-feed item with `removed` instead of `object`. Delete your copy of that record, everywhere you copied it. Its `reason` says why: `deleted` or `erased`. See [Removals](https://totemstudio.ai/developers/sync#removals). **Deletion.** A removal with `reason: "deleted"`: the record went away in Totem without being erased. See [Removals](https://totemstudio.ai/developers/sync#removals). **Erasure.** A removal with `reason: "erased"`: the record was erased for privacy reasons, for example because the person exercised the right to erasure (GDPR Article 17), or because someone permanently deleted a lead, a call or a conversation in Totem, which always erases it. See [How do I honor erasure requests?](https://totemstudio.ai/developers/faq#how-do-i-honor-erasure-requests) in the FAQ. **Tombstone.** What you keep after you apply a removal: the record's `kind`, `id` and `version`, without its data. It stops an older copy of the record, delivered again later, from bringing it back. See [Apply a page](https://totemstudio.ai/developers/sync#apply-a-page). **Merge.** Two leads for the same person, combined into one. The merged lead is not removed: it stays as an archived lead whose `merged_into` names the lead that carries on. See [Merges](https://totemstudio.ai/developers/sync#merges). **Transfer.** A lead moved to another team of the same organization. The original stays as an archived lead whose `transferred_to` names the destination `team`, and the destination `lead` when Totem can name it. The destination team may be one your key does not read. See [Transfers](https://totemstudio.ai/developers/sync#transfers). ## Limits and errors **Rate limit.** How fast a key may read: up to 2,000 items a minute, with bursts of up to 10,000. Past it, the API answers `429 rate_limited` with `Retry-After`. See [Rate limits](https://totemstudio.ai/developers/rate-limits#rate-limit). **Daily quota.** How many items a key may read in one UTC day from the changes feed and every list, including teams, members, bookable services and a lead's activities: 250,000 items, or 3 times the number of records in its teams if that is more. Single-record lookups, search and `GET /v1/me` do not count. Past it, those endpoints answer `429 quota_exceeded` until 00:00 UTC. See [Daily quota](https://totemstudio.ai/developers/rate-limits#daily-quota). **livemode.** A field on every resource. It is `true` on everything the API returns, and `false` on the example data of these docs and of the kit's `fixtures.json`. See [Conventions](https://totemstudio.ai/developers/conventions). **Request id.** The `Totem-Request-Id` header of every response the API itself sends, repeated as `request_id` in an error body. Quote it when you ask for help. See [Support](https://totemstudio.ai/developers/support). **Error code.** The `code` of an error body, such as `team_required`. Branch on it, and look it up on the [Errors](https://totemstudio.ai/developers/errors) page. --- # FAQ > Short answers to the questions developers ask most about the Totem API, with links to the details. Source: https://totemstudio.ai/developers/faq ## Access and keys ### How do I get a key? An administrator of your Totem team creates an integration in [**Settings → Developers**](https://app.totemstudio.ai/settings?tab=developers) and copies its secret key into your server's secret store. The API is in **Beta**, with no invitation required. The administrator needs a verified email address, your organization needs Pro or a higher plan, or an agreed API entitlement, and the team must have completed its legal setup; the [Quickstart](https://totemstudio.ai/developers/quickstart#before-you-begin) lists every requirement and what to check when **Create API key** does not appear. It then takes you through your first requests. ### Can one key read several teams? Yes, when the teams belong to the same organization, and only a family administrator can create such a key. An integration covers an explicit list of up to 30 teams. Requests for one team's collections then name the team in the `Totem-Team` header, and you keep one changes-feed cursor per team. See [Authentication](https://totemstudio.ai/developers/authentication). ### Can I limit a key to my servers' IP addresses? Yes. An integration can carry an allowlist of up to 20 IP addresses or CIDR ranges, set in **Settings → Developers**. The API is reachable over IPv4 only, so list the public IPv4 addresses your requests leave from. A request from any other address gets `403 ip_not_allowed`. [Authentication](https://totemstudio.ai/developers/authentication#ip-allowlist) explains how to set it up. ### Can I call the API from a browser or a mobile app? No. A key reads administrator-level data, so it must stay on your server. The API sends no CORS headers, and it answers `400 secret_key_in_browser` to a request that carries a key and an `Origin` header. Call the API from your backend, and send your app only the data it needs. ## Data ### Can I write data through the API? No. Version 1.0a is read-only: no endpoint creates or changes anything in Totem. The [changelog](https://totemstudio.ai/developers/changelog) announces any new capability. ### Can I read message text, call transcripts or recordings? No. Version 1.0a returns contact details, such as names, email addresses and phone numbers, and metadata, but no message text, call transcripts, summaries, recordings, notes, free-text answers or custom form fields. A conversation tells you its channel, its status and when the last messages came and went. A call tells you its direction, its duration and its outcome. ### How do I find a lead by phone number or email? Send the phone number or the email address in the JSON body of [`POST /v1/leads/search`](https://totemstudio.ai/developers/reference-leads#leads.search), never in a URL. Send exactly one of the two. Search matches exactly, returns at most 10 leads and never pages, so when many records share the same phone number or email address, it may not return them all. ## Sync and freshness ### Are there webhooks? No. The Totem API has no webhooks: read the changes feed with your stored cursor every 1 to 5 minutes. The webhooks that Totem's plans list are a separate feature of the product, set up in Totem, not with an API key. A poll that finds nothing new returns an empty page that still moves your cursor forward, so store its `next_cursor` too. See [Keep a copy in sync](https://totemstudio.ai/developers/sync). ### How fast do changes appear? Shortly after they happen in Totem. The changes feed holds back its last 10 seconds, so that a change still being written is never skipped. If you poll every 1 to 5 minutes, your copy stays about that close to Totem. ### Why does a new record answer 404 for a moment? Totem prepares each record for the API shortly after it changes. Until then, lists and search skip a new record, and `GET` on its id answers `404 resource_missing`. The changes feed delivers the record as soon as it is ready. See [Lists and pagination](https://totemstudio.ai/developers/pagination). ## Testing and tools ### Is there a sandbox? Not yet. Version 1.0a has no test environment and no test keys: every key reads a real team. Use a dedicated team containing only fictitious contacts. The kit's [`fixtures.json`](https://totemstudio.ai/developers/kit/fixtures.json) holds example data for your unit tests. See [Testing and go-live](https://totemstudio.ai/developers/testing). ### Is there an SDK? Yes. The official [TypeScript SDK](https://totemstudio.ai/developers/typescript), `@totemstudioai/sdk`, covers every endpoint and provides typed responses, pagination and API errors. It runs on your server, on the Node.js versions its page lists. To keep a copy of a team, start from the [tested reference client](https://totemstudio.ai/developers/sync#tested-reference-client) of the sync guide. The [OpenAPI document](https://totemstudio.ai/developers/kit/openapi.json) remains available for other languages. ## Limits ### What are the limits? A key reads up to 2,000 items a minute, with bursts of up to 10,000. Each UTC day, list endpoints and the changes feed return it up to 250,000 items, or 3 times the number of records in its teams if that is more; requests for one record, search and `GET /v1/me` do not count toward that quota. A page holds up to 100 items. Past a limit, the API answers `429` with `Retry-After`: wait that many seconds, then continue with the same cursor. During the Beta these numbers are approximate, and an organization can hold up to 10 active integrations. See [Rate limits](https://totemstudio.ai/developers/rate-limits). ## Privacy ### How do I honor erasure requests? When a record is erased in Totem, the changes feed delivers a removal with `reason: "erased"`. Erasing a lead also erases its calls, its conversations and the archived leads merged into it, and each arrives as a removal of its own; its bookings stay, with `lead` set to `null`. Delete the record from your copy and from every system you fed from it, and keep only its tombstone: its `kind`, `id` and `version`. Removals stay in the feed for 45 days, longer than a cursor lives, so a sync that keeps polling successfully never misses one. While the API refuses a team, no removals arrive for it, and when access returns after its cursor has expired, the full resync deletes what was removed meanwhile. A team in an excluded sector (`sector_restricted`) gets no removals until Totem changes its sector policy, so keep its copy only as long as your retention policy allows. See [Keep a copy in sync](https://totemstudio.ai/developers/sync). ### Where does the API run, and who protects the data I copy? The API runs on Google Cloud in the European Union and answers only over HTTPS. Once you copy data, protecting it is up to you and the customer whose data it is. [Security and personal data](https://totemstudio.ai/developers/security) explains both, and how to report a vulnerability.