# Authentication Source: https://docs.ringee.io/api/authentication Create a Custom Integration and authenticate your requests Public API requests are authenticated with a **Custom Integration API key** (`cik_live_…`). The key identifies the integration and its workspace — there is no separate account or organization id to send. ## Create a Custom Integration Custom Integrations are available to organization admins and personal workspace owners. Sign in to Ringee and open **Integrations** from the sidebar. The direct route is `/dashboard/settings/integrations`. Select the **Custom Integrations** tab, then click **New custom integration**. Give it a name that identifies the system you are connecting, such as `Acme CRM`. Ringee shows two secrets when the integration is created: * the **API key** (`cik_live_…`) — used to authenticate your requests to Ringee; * the **webhook signing secret** (`whsec_…`) — used to verify webhooks Ringee sends to you. Copy both immediately and store them in your server-side secret manager. Under **Settings**, set the **outbound URL** to the public HTTPS endpoint that will receive Ringee events, and select which events you want to subscribe to. Click **Test webhook**. Ringee sends a `test.ping` event to your endpoint and shows the HTTP status and latency it got back. ## Authenticate a request Send the API key in the `X-Ringee-Api-Key` header for inbound CRM events and click-to-call. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl -X POST https://api.ringee.io/api/integrations/custom/webhook \ -H "Content-Type: application/json" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" \ -d '{ "event": "contact.upserted", "eventId": "crm_9f1c2a7e", "occurredAt": "2026-05-23T14:30:00.000Z", "data": { "externalId": "ext_42", "phoneNumber": "+14155550123" } }' ``` AI Voice Agent endpoints accept either `X-Ringee-Api-Key` or `Authorization: Bearer cik_live_…`. Send exactly one authentication header; a request containing both is rejected with `401`. The API key is a **server-side secret**. Never send it from a browser, never commit it, never log it, and never store it in `localStorage`. If your CRM has a serverless layer (Supabase Edge Functions, Vercel functions, Lambda), all Ringee calls must happen there. ## Key types Ringee issues two different key types from the same integration. They are not interchangeable. | Key | Used by | Sent from | Safe in frontend? | | ------------ | ------------------------------------------------- | ----------- | ----------------- | | `cik_live_…` | Inbound events, click-to-call and AI Voice Agents | Your server | **No** | | `pk_live_…` | [Dialer SDK](/dialer-sdk/overview) | The browser | Yes | | `whsec_…` | Webhook signature verification | Never sent | **No** | ## How keys are stored * The API key is stored **hashed** (SHA-256). Ringee can never show it to you again after creation — only its prefix, for example `cik_live_a1b2c3d4`. * The signing secret is stored **encrypted at rest** and decrypted only when signing an outbound delivery. ## Rotating credentials Click **Regenerate API key** in the integration settings. The previous key stops working immediately. Rotating the API key also **revokes every Dialer SDK publishable key** (`pk_live_…`) minted for this integration. If you use both, mint a new publishable key right after rotating. Click **Regenerate signing secret**. Ringee starts signing with the new secret on the next delivery and does not keep an overlap window. Store and deploy the returned secret immediately, then run **Test webhook**. Setting an integration's status to `disabled` rejects its Public API key, stops outbound deliveries and invalidates its publishable keys. Queued deliveries are marked failed with `integration disabled`. ## Workspace scope An integration belongs to either a **personal account** or an **organization**. Everything it reads and writes is scoped to that workspace: * contacts and companies you upsert are created in that workspace; * only calls and activity from that workspace are sent to your webhook; * AI Voice Agent lists and call results are limited to that organization; * `ownerEmail` and `agentEmail` are resolved against that workspace's members. When an integration is organization-scoped, `agentEmail` is **required** for [click-to-call](/api/click-to-call) — Ringee needs to know which member places the call. AI Voice Agents require an organization-scoped integration. A key belonging to a personal workspace returns `403` for `/api/v1/ai-voice-agents/*`. ## Manage the integration Create integrations, change webhook subscriptions, rotate credentials and inspect delivery logs from **Integrations → Custom Integrations** in the Ringee dashboard. Those dashboard operations are not part of the Public API and do not accept a `cik_live_…` key. ## Next steps Push contacts and companies into Ringee Validate the HMAC signature on incoming events # Build an integration Source: https://docs.ringee.io/api/build-an-integration An end-to-end blueprint for connecting a CRM to Ringee This guide is the shape of a production-grade Ringee integration: what to build, in what order, and the failure modes that bite everyone the first time. ## Architecture The Custom Integration API key and webhook signing secret are server-side. Your frontend does not call the Public API directly. The separate Dialer SDK `pk_live_…` key is browser-safe when restricted to approved origins. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef ui fill:#86EFAC,color:black,stroke:#fff classDef srv fill:#15803D,color:white,stroke:#fff classDef ext fill:#22C55E,color:white,stroke:#fff ui[CRM frontend]:::ui api[Your backend / Edge Function]:::srv hook[Your webhook endpoint]:::srv ringee[Ringee]:::ext ui -- "Call with Ringee" --> api api -- "X-Ringee-Api-Key" --> ringee ringee -- "Ringee-Signature" --> hook hook -- "activities, outcomes, recordings" --> ui ``` Two server-side entry points are enough: | Component | Responsibility | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | **Public API client** | Sends CRM events, creates click-to-call sessions and can start AI Voice Agent calls. Holds the `cik_live_…` API key. | | **Webhook endpoint** | Receives Ringee events. Verifies the HMAC. Must be publicly reachable and must not require your application's normal auth token. | ## Configuration and secrets | Setting | Used for | | ------------------------------- | -------------------------------------------------------- | | `RINGEE_API_BASE_URL` | `https://api.ringee.io`, or your self-hosted backend URL | | `RINGEE_API_KEY` | The `cik_live_…` key | | `RINGEE_WEBHOOK_SIGNING_SECRET` | The `whsec_…` secret used to verify incoming events | Never expose `RINGEE_API_KEY` or `RINGEE_WEBHOOK_SIGNING_SECRET` in the frontend, in `localStorage`, in the repository, or in logs. ## Build order Start here. It is the piece most likely to be wrong, and `test.ping` gives you a tight feedback loop with no data at stake. See [Outbound webhooks](/api/outbound-webhooks#verify-the-signature). Insert `eventId` with a `UNIQUE` constraint before processing. On conflict, return `200` and stop. Handle `call.completed`, `call.missed` and `call.failed`, keyed by `data.callId`. Then handle `call.outcome.updated`, `note.created`, `callback.created`, `meeting.created`, `recording.ready` and `dnc.created`. `call.outcome.updated` includes `data.call` when available, so it can upsert the activity even if it arrives first. Send `contact.upserted` and `company.upserted` when the relevant fields change — through an outbox, not inline with the save. Read `status` on every response, and log `warnings`: they are how you catch a field name Ringee does not know, or an `ownerEmail` that matches nobody. To enrol a synced contact in an outbound campaign in the same request, add [`data.campaignId`](/api/inbound-events#adding-a-contact-to-a-campaign). Wire [click-to-call](/api/click-to-call), or embed the [Dialer SDK](/dialer-sdk/overview) if you want agents to stay in your UI. Expose status, the webhook URL to paste into Ringee, last sync, last error, **Test connection** and **Retry pending** — without ever showing a secret. ## Matching contacts Resolve an event to your record in this order: 1. `data.externalId` — when you started an AI Voice Agent call with `metadata.external_id` or `metadata.externalId`. 2. `data.contact.externalId` — when the contact was linked through `contact.upserted` and the event includes a contact reference. 3. Normalized phone number if neither correlation id is present. * For **inbound** calls, match on `fromNumber`. * For **outbound** calls, match on `toNumber`. 4. No match: create an **unlinked activity** showing the raw phone number. `data.agent.id` is a Ringee AI Voice Agent id. Do not use it as a contact id. `data.user.id` identifies the Ringee user the event belongs to. Never create a duplicate contact from a call event. An unlinked activity is always better than a duplicate record. Normalize direction for display — the wire values may be `inbound`/`incoming` or `outbound`/`outgoing`. ## Prevent sync loops The classic failure: Ringee writes an activity → your CRM sees a row change → your CRM sends `contact.upserted` → Ringee updates the contact → repeat. Set `sync_origin = 'ringee'` on anything a Ringee webhook created or updated. Your CRM → Ringee sync must ignore: * changes where `sync_origin = 'ringee'`; * changes limited to call-activity fields; * recordings; * outcomes. Compare the fields Ringee actually uses. Do not emit an event because a timestamp or a UI-only column moved. ## Outbox and retries A contact save must never fail because Ringee is briefly unreachable. Record the event first, deliver second. ```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} create table ringee_sync_outbox ( id uuid primary key default gen_random_uuid(), event_id text unique not null, event_type text not null, entity_type text not null, entity_id text not null, payload jsonb not null, status text not null default 'pending', attempt_count int not null default 0, last_error text, next_attempt_at timestamptz, created_at timestamptz not null default now(), sent_at timestamptz ); ``` * States: `pending` → `processing` → `sent` | `failed`. * Retry with backoff; keep `last_error` for the settings screen. * Offer an administrative **Retry** action. * Never send two events for the same record concurrently — you will apply them out of order. ## Multi-tenancy If your CRM is multi-tenant, every new table needs an `organization_id` (or equivalent) and row-level security: * users may read only their own organization's rows; * integration settings and retry actions are limited to authorized users; * payloads, recordings, activities and logs are never readable across organizations. Your webhook function may use administrative database credentials — but **only after** the signature has been verified. ## Test checklist * Valid HMAC signature is accepted. * Invalid signature returns `401`. * Expired timestamp (more than 5 minutes old) is rejected. * Body modified after signing is rejected. * Duplicate `eventId` returns `200` and creates nothing. * `test.ping` returns any `2xx` response. * Inbound completed call creates an activity on the right contact. * Outbound completed call creates an activity on the right contact. * Missed call is flagged as missed. * Failed call stores the error code or message. * Outcome received **before** `call.completed` still lands on one activity. * Outcome received **after** `call.completed` updates, never duplicates. * Recording received later attaches to the existing activity. * Human calls omit `data.agent`; AI Voice Agent calls include its `{ id, name }`. * `metadata.external_id` from an AI Voice Agent call is returned as `data.externalId` on its terminal, outcome, callback, meeting and recording events. * A missing `data.user` or `data.agent` never prevents processing by `callId`. * Contact created or updated in Ringee from a CRM change. * Contact archived in Ringee from a CRM delete. * A payload with a bad field returns `failed` and leaves nothing behind in Ringee. * A clean payload returns no `warnings`. * Click-to-call works for an authenticated user. * Contact without a phone, or on DNC, cannot be called. * No cross-organization data leaks. ## What Ringee does not send Ringee notifies the **terminal** result of a call. There are no `call.started`, `call.ringing` or `call.answered` events — do not build a real-time incoming-call screen on top of this API. For live call state in your own UI, use the [Dialer SDK](/dialer-sdk/overview), which emits `dialing`, `ringing`, `answered` and `ended` in the browser. ## Next steps Debug deliveries and inbound events Put the dialer inside your own UI # Click-to-call Source: https://docs.ringee.io/api/click-to-call POST /api/integrations/custom/click-to-call Open a secure Ringee dialer for a contact from your CRM Click-to-call exchanges a contact for a **short-lived, signed dialer URL**. Your agent opens that URL and Ringee handles caller ID selection, credit checks and the WebRTC call. Click-to-call is the fastest way to add calling to an existing CRM: one server-side request, one redirect, no frontend SDK. If you want the dialer to live **inside** your own UI instead of a Ringee tab, use the [Dialer SDK](/dialer-sdk/overview). ## Request ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl -X POST https://api.ringee.io/api/integrations/custom/click-to-call \ -H "Content-Type: application/json" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" \ -d '{ "contactExternalId": "ext_contact_42", "agentEmail": "rep@example.com" }' ``` | Field | Type | Required | Description | | ------------------- | ------ | ----------- | -------------------------------------------------------------------------------------------- | | `contactExternalId` | string | Conditional | Contact id previously linked through `contact.upserted` | | `phoneNumber` | string | Conditional | Destination number; required if the external contact does not resolve to a number | | `fromNumber` | string | No | Explicit caller ID; otherwise Ringee resolves one | | `agentEmail` | string | Conditional | Ringee member who will place the call; required in organizations unless `ownerEmail` is sent | | `ownerEmail` | string | Conditional | Alias for `agentEmail` when your CRM models a record owner | | `ownerExternalId` | string | No | Reserved; it cannot currently resolve the agent by itself | Send at least `contactExternalId` or `phoneNumber`. If both resolve a destination, the explicit `phoneNumber` wins. An unknown `contactExternalId` can still be used with `phoneNumber`, and the returned `contactId` will be `null`. ## Response A successful request returns **201 Created**: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "contactId": "6b2f8f1e-6a2e-4f1c-9a3d-2d1b0c7e4a55", "agentUserId": "a91c4d77-1f0b-4a2e-8c3d-77b1e2f4a9c0", "toNumber": "+14155550123", "fromNumber": "+14155550100", "dialerUrl": "https://app.ringee.io/dashboard/dialer?session=…", "sessionToken": "eyJhbGciOi…", "expiresAt": "2026-09-04T14:05:00.000Z" } ``` | Field | Description | | -------------- | --------------------------------------------------------- | | `contactId` | Ringee contact UUID, or `null` when calling a raw number | | `agentUserId` | Ringee user authorized for the call | | `toNumber` | Resolved destination number | | `fromNumber` | Selected caller ID | | `dialerUrl` | URL to open in the browser; valid for **5 minutes** | | `sessionToken` | Same signed authorization already embedded in `dialerUrl` | | `expiresAt` | ISO-8601 expiry for the session | Pass only `dialerUrl` and `expiresAt` to your frontend. Do not expose the separate `sessionToken` field, persist it, or write it to logs. The token is already inside `dialerUrl`, so treat that URL as sensitive and short-lived too. ## Agent resolution | Workspace | Behavior | | ------------ | -------------------------------------------------------------------------------------------------------------------- | | Personal | The workspace owner always places the call. `agentEmail` is ignored. | | Organization | `agentEmail` (or `ownerEmail`) must match a member of that organization, or the request fails with 422. | Take `agentEmail` from your **authenticated session**, not from a value the browser sent. Otherwise any user could place calls as any colleague. `ownerExternalId` is accepted in the payload but is not yet wired to a resolution strategy — sending it without `agentEmail` returns `422`. Use `agentEmail`. ## Caller ID resolution An explicit `fromNumber` is normalized and used as the caller ID. When you do not send it, Ringee picks one in this order: 1. the agent's first active **verified caller ID** in that workspace; 2. any **purchased number** on the workspace; 3. otherwise the request fails with `422 No caller ID or purchased number is available for this workspace.` ## Wiring the button The request must be made by your backend (or an Edge Function). The `cik_live_` key must never reach the browser. Open a blank tab **before** the async request so popup blockers do not swallow it, then navigate it once you have the URL. ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const tab = window.open("", "_blank"); try { const { dialerUrl } = await fetch("/api/ringee/click-to-call", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contactExternalId: contact.id }), }).then((r) => r.json()); tab!.location.href = dialerUrl; } catch (error) { tab?.close(); showError(error); } ``` Disable it when the contact has no valid phone number, or when the contact is on the Do Not Call list. Ringee blocks DNC numbers server-side too, but failing early is a better experience. Do not request a dialer URL on page load. Calls must start from a real click. ## Errors | Status | Cause | | ---------------- | ------------------------------------------------------------------------------------------------------------- | | 400 | Neither `contactExternalId` (linked to a contact) nor `phoneNumber` was provided. | | 401 | Missing or invalid `X-Ringee-Api-Key`. | | 422 | `agentEmail` missing on an organization workspace, no member matched the email, or no caller ID is available. | ## Next steps Log the call in your CRM when it ends Keep agents inside your own UI # Event reference Source: https://docs.ringee.io/api/events-reference Every event Ringee sends to your application All outbound events share the envelope described in [Outbound webhooks](/api/outbound-webhooks#delivery-format) and add `workspaceId` and `integrationId`. | Event | Fires when | Subscription | | ----------------------------------------------- | ---------------------------------------- | ---------------------- | | [`call.completed`](#call-completed) | A call technically ended | Opt in | | [`call.outcome.updated`](#call-outcome-updated) | A person or AI agent produced an outcome | Opt in | | [`call.missed`](#call-missed) | An inbound call was not answered | Opt in | | [`call.failed`](#call-failed) | A call failed technically | Opt in | | [`note.created`](#note-created) | A note was added to a contact | Opt in | | [`callback.created`](#callback-created) | A callback was scheduled | Opt in | | [`meeting.created`](#meeting-created) | A meeting was scheduled | Opt in | | [`recording.ready`](#recording-ready) | A recording finished processing | Opt in | | [`dnc.created`](#dnc-created) | A number was added to Do Not Call | Opt in | | [`test.ping`](#test-ping) | You click **Test webhook** | Test only | Select which events you receive in **Integrations → Custom Integrations → Configure → Settings**. ## Shared fields Ringee enriches outbound payloads with actor and correlation data before it queues them. These fields are optional so a lookup failure never drops the event. | Field | Shape or source | | ----------------- | --------------------------------------------------------------------------------------------------------------- | | `data.user` | `{ id, email?, fullName? }`; the Ringee user the event belongs to. `email` is the primary address. | | `data.agent` | `{ id, name }`; the AI Voice Agent behind the event. Absent for human-operated calls. | | `data.externalId` | `metadata.external_id` or `metadata.externalId` from the originating AI Voice Agent call. | | `data.contact` | `{ id, externalId?, phoneNumber, fullName?, email? }` | | `data.company` | `{ id, externalId?, name }` | | `data.call` | Telephony detail embedded by `call.outcome.updated`; it uses the same timing and number fields as a call event. | `data.externalId` is the caller-supplied correlation id for AI Voice Agent calls. It is independent from `data.contact.externalId`, which identifies a CRM contact linked through [`contact.upserted`](/api/inbound-events#contact-upserted). Prefer the top-level `data.externalId` when present, then a linked contact id, then a normalized phone number. `data.user` is omitted only when that user can no longer be resolved. `data.agent` and `data.externalId` are ownership-checked against the event's workspace before Ringee adds them. *** ## `call.completed` A call has technically ended, regardless of whether anyone recorded an outcome yet. **Required:** `data.callId`, `data.fromNumber`, `data.toNumber`, `data.status`, `data.direction`, `data.endedAt` Ringee call UUID. Use it as the unique key for the activity in your CRM. Originating E.164 number. Destination E.164 number. Final call status reported by the carrier. `inbound` or `outbound`; legacy call records may use `incoming` or `outgoing`. When the call ended. When the call was initiated. When the call was answered. Total billed duration. `pending`, `ready` or `unavailable`. Contact reference. Company reference. Ringee user the event belongs to, including the primary email when available. AI Voice Agent reference with `id` and `name` when one placed the call. Absent for a human call. Your correlation id from the AI Voice Agent call's `metadata.external_id` or `metadata.externalId`. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "call.completed", "eventId": "evt_01HX5ZBN7Y0Q3S4M2K1WJ8V5DC", "occurredAt": "2026-05-23T14:42:18.000Z", "workspaceId": "org_2k7yX…", "integrationId": "ci_…", "data": { "callId": "f3b1…", "externalId": "crm-contact-42", "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" }, "fromNumber": "+14155550100", "toNumber": "+14155550123", "status": "completed", "direction": "outbound", "durationSeconds": 142, "startedAt": "2026-05-23T14:39:56.000Z", "endedAt": "2026-05-23T14:42:18.000Z" } } ``` This event does **not** include the outcome. Outcomes arrive separately via `call.outcome.updated`, and may never arrive if the user never records one. *** ## `call.outcome.updated` A person recorded or changed an outcome, or an AI Voice Agent produced its post-call outcome. This can happen well after `call.completed`. **Required:** `data.callId`, `data.outcome`, `data.updatedAt` Ringee call UUID — match it against an existing activity. The `CallOutcome` value, for example `sale`, `interested`, `meeting_booked`, `voicemail`, `no_answer`. When the outcome was set. Telephony detail for the call: `callId`, `fromNumber`, `toNumber`, `status`, `direction`, `startedAt`, `answeredAt`, `endedAt` and `durationSeconds`. Omitted only when the call row can no longer be resolved. Free-text note attached to the outcome. Contact reference. Company reference. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when the outcome came from an agent call. Your correlation id from the originating AI Voice Agent call. AI Voice Agent call UUID. AI Voice Agent UUID. Caller-supplied metadata stored on the AI Voice Agent call. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "call.outcome.updated", "eventId": "evt_…", "occurredAt": "2026-05-23T14:50:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "callId": "f3b1…", "call": { "callId": "f3b1…", "fromNumber": "+14155550100", "toNumber": "+14155550123", "status": "completed", "direction": "outbound", "startedAt": "2026-05-23T14:39:56.000Z", "answeredAt": "2026-05-23T14:40:02.000Z", "endedAt": "2026-05-23T14:42:18.000Z", "durationSeconds": 142 }, "externalId": "crm-contact-42", "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" }, "agentCallId": "8ddf384a-9b9d-44dd-a120-23395b1f4c98", "agentId": "7c819553-25be-43b5-9fc6-58effe1569f8", "outcome": "meeting_booked", "outcomeNote": "Demo scheduled for next Tuesday", "metadata": { "external_id": "crm-contact-42" }, "updatedAt": "2026-05-23T14:50:00.000Z" } } ``` Update or create the activity by `callId` — never create a second activity for the outcome. AI Voice Agent results and webhooks use Ringee's canonical public names `meeting_booked` and `callback_scheduled`; historical stored names are normalized before they leave Ringee. When the outcome is `meeting_booked`, a separate `meeting.created` event also fires. *** ## `call.missed` An inbound call arrived but nobody picked up. **Required:** `data.callId`, `data.fromNumber`, `data.toNumber`, `data.occurredAt` Ringee call UUID. Originating number; normally the contact for a missed inbound call. Workspace number that received the call. When the missed call was recorded. Carrier-provided reason. Contact reference. Company reference. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when the event belongs to an agent call. Your correlation id from the originating AI Voice Agent call. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "call.missed", "eventId": "evt_…", "occurredAt": "2026-05-23T15:20:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "callId": "c_…", "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "fromNumber": "+14155550123", "toNumber": "+14155550100", "occurredAt": "2026-05-23T15:20:00.000Z" } } ``` For missed calls, resolve the contact primarily from `fromNumber`. *** ## `call.failed` The telephony stack reported a hard failure — busy, invalid number, network error. **Required:** `data.callId`, `data.fromNumber`, `data.toNumber`, `data.occurredAt` Ringee call UUID. Originating number. Destination number. When the failure was recorded. Carrier error code, for example `USER_BUSY`. Human-readable description. Contact reference. Company reference. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when one placed the failed call. Your correlation id from the originating AI Voice Agent call. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "call.failed", "eventId": "evt_…", "occurredAt": "2026-05-23T15:25:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "callId": "c_…", "externalId": "crm-contact-42", "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" }, "fromNumber": "+14155550100", "toNumber": "+14155550123", "occurredAt": "2026-05-23T15:25:00.000Z", "errorCode": "USER_BUSY" } } ``` *** ## `note.created` **Required:** `data.noteId`, `data.contact`, `data.content`, `data.createdAt` Ringee note UUID. Contact the note belongs to. Note text. When the note was created. User who wrote the note. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when one produced the event. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "note.created", "eventId": "evt_…", "occurredAt": "2026-05-23T15:00:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "noteId": "n_…", "contact": { "id": "…", "externalId": "ext_contact_42", "phoneNumber": "+14155550123" }, "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "content": "Prefers email follow-up.", "createdAt": "2026-05-23T15:00:00.000Z" } } ``` *** ## `callback.created` **Required:** `data.callbackId`, `data.contact`, `data.scheduledAt`, `data.status`, `data.createdAt` Ringee callback UUID. Contact to call back. Scheduled callback time. Current callback status. When the callback was created. Originating Ringee call UUID. Free-text note. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when the callback came from an agent call. Your correlation id from the originating AI Voice Agent call. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "callback.created", "eventId": "evt_…", "occurredAt": "2026-05-23T15:05:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "callbackId": "cb_…", "callId": "f3b1…", "externalId": "crm-contact-42", "contact": { "id": "…", "externalId": "ext_contact_42", "phoneNumber": "+14155550123" }, "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" }, "scheduledAt": "2026-05-24T10:00:00.000Z", "status": "scheduled", "createdAt": "2026-05-23T15:05:00.000Z" } } ``` *** ## `meeting.created` Fires when a user schedules a meeting. Independent of outcomes — if the outcome is `meeting_booked`, both `call.outcome.updated` and `meeting.created` fire. **Required:** `data.meetingId`, `data.contact`, `data.scheduledAt`, `data.status`, `data.createdAt` Ringee meeting UUID. Contact invited to the meeting. Scheduled meeting time. Current meeting status. When the meeting was created. Meeting title. Duration in minutes. Physical location or video link. Notes. Calendar provider event id, when synced to Google or Microsoft. Originating Ringee call UUID. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when the meeting came from an agent call. Your correlation id from the originating AI Voice Agent call. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "meeting.created", "eventId": "evt_…", "occurredAt": "2026-05-23T15:10:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "meetingId": "m_…", "callId": "f3b1…", "externalId": "crm-contact-42", "contact": { "id": "…", "externalId": "ext_contact_42", "phoneNumber": "+14155550123" }, "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" }, "scheduledAt": "2026-05-30T16:00:00.000Z", "status": "scheduled", "title": "Demo with Babbage Engines", "duration": 30, "createdAt": "2026-05-23T15:10:00.000Z" } } ``` *** ## `recording.ready` The recording has been processed and is downloadable. **Required:** `data.recordingId`, `data.callId`, `data.url`, `data.createdAt` Ringee recording UUID. Call UUID used to attach the recording to an activity. Signed URL to the recording. When the recording became available. Audio container, for example `mp3` or `wav`. Duration in seconds. Plain-text transcript, when available. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when the recording belongs to an agent call. Your correlation id from the originating AI Voice Agent call. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "recording.ready", "eventId": "evt_…", "occurredAt": "2026-05-23T14:43:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "recordingId": "r_…", "callId": "f3b1…", "externalId": "crm-contact-42", "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" }, "url": "https://recordings.ringee.app/…", "format": "mp3", "durationSec": 142, "createdAt": "2026-05-23T14:43:00.000Z" } } ``` Attach the recording to the activity matched by `callId`, and protect the URL behind your CRM's existing access policies. Never expose recordings across organizations. *** ## `dnc.created` A phone number or contact was added to the Do Not Call list. **Required:** `data.phoneNumber`, `data.createdAt` Number added to the Do Not Call list. When the DNC entry was created. Free-text reason. Channel that triggered the addition. Contact reference. Ringee user the event belongs to. AI Voice Agent reference with `id` and `name` when one produced the event. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "dnc.created", "eventId": "evt_…", "occurredAt": "2026-05-23T15:30:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "phoneNumber": "+14155550123", "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "createdAt": "2026-05-23T15:30:00.000Z" } } ``` On receipt, mark the contact as Do Not Call and disable your **Call with Ringee** button for it. *** ## `test.ping` Sent when you click **Test webhook**. Use it to validate signature verification end to end. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "test.ping", "eventId": "evt_test_1747999938000", "occurredAt": "2026-05-23T14:00:00.000Z", "workspaceId": "org_…", "integrationId": "ci_…", "data": { "message": "Hello from Ringee custom integrations." } } ``` Return any `2xx` response. Ringee ignores the response body; for example: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "received": true, "event": "test.ping" } ``` ## Machine-readable spec The same definitions are available at runtime: ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} GET /api/integrations/custom/event-specs ``` It returns each event's `requiredFields`, `optionalFields`, `examplePayload` and `notes` — useful for generating types or validating your handler in CI. This is a management endpoint. It requires a signed-in Ringee admin session, not `X-Ringee-Api-Key`. # Inbound events Source: https://docs.ringee.io/api/inbound-events POST /api/integrations/custom/webhook Push contacts and companies from your CRM into Ringee Send everything your CRM knows about a contact or company to a single Ringee endpoint. Ringee upserts the record, links it to your `externalId`, and makes it callable. Pick the event you are sending in the request body above — each one has its own `data` shape. ## The envelope Every request uses the same four top-level fields: | Field | Type | Required | Description | | ------------ | ------ | -------- | ----------------------------------------- | | `event` | string | Yes | One of the four inbound event names below | | `eventId` | string | Yes | Stable unique id for this logical event | | `occurredAt` | string | Yes | ISO-8601 timestamp from your system | | `data` | object | Yes | Event-specific payload | ## Reading the response The endpoint returns **202 Accepted** for any authenticated request with a complete envelope. The real result is in the JSON body. | Status | Meaning | What to do | | ---------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- | | processed | The event was applied. | Mark the sync as successful. | | skipped | Ringee already recorded this `eventId`. | Do not process it again. Preserve the original result; retry a corrected failure with a new id. | | failed | The event was accepted but could not be applied. | Show `message`, mark as a sync failure, fix and resend with a **new** `eventId`. | A `202` status code only means the transport was accepted. Do not treat it as a successful sync without checking `status` in the body. ### Warnings A `processed` response can also carry a `warnings` array. The event applied, but something you sent had no effect: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "status": "processed", "eventId": "crm_9f1c2a7e", "warnings": [ "Unknown contact.upserted fields were ignored: campagnId. Check them for typos.", "data.ownerEmail \"rep@example.com\" does not match a member of this workspace; the contact was left unassigned." ] } ``` Warnings are how you find the bugs that do not fail: a misspelled field name, a number without a country code, an owner nobody in Ringee matches. Log them while you build and surface them on your settings screen — a field Ringee dropped silently is the most expensive kind of sync bug to find later. ## What the endpoint guarantees Ringee validates the whole payload — field shapes, the phone number, the campaign you named — before it writes anything. A `failed` response means nothing was created or updated, so you can correct the payload and send it again knowing Ringee holds no half-synced record. Problems are reported together, not one per round trip. A missing `externalId` **and** a missing `phoneNumber` come back in the same message: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "status": "failed", "eventId": "crm_9f1c2a7e", "message": "data.externalId is required; data.phoneNumber is required" } ``` `eventId` is the idempotency key, scoped to your integration. A transport retry is free: the second delivery returns `skipped` and changes nothing. That also means a **corrected** payload needs a new id — the old one is already recorded. Ringee patches, it does not replace. Send only what changed: `null`, `undefined` and empty strings leave the stored value alone. To blank a field, clear it in Ringee. ## Event examples Create or update a contact from your CRM's current state. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "contact.upserted", "eventId": "crm_01HX5Z7K8MZP1Q4V0G9YJ2RH3T", "occurredAt": "2026-05-23T14:30:00.000Z", "data": { "externalId": "ext_contact_42", "phoneNumber": "+14155550123", "firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.com", "companyExternalId": "ext_company_7", "jobTitle": "Founder", "ownerEmail": "rep@example.com", "customFields": { "tier": "vip" } } } ``` Stable contact id in your system. The primary deduplication key. Dialable number. Required because the point of a synced contact is that somebody can call it — see [Phone numbers](#phone-numbers). Display name. Given name. Family name. Pre-composed full name. Primary email. External company id to link. Ringee creates the company when no link exists yet and `companyName` is present. Fallback company name when a linked company is unavailable. Contact position or title. Ringee campaign to add this contact to as a lead — see [Adding a contact to a campaign](#adding-a-contact-to-a-campaign). Resolves a Ringee member in an organization workspace. An email nobody matches leaves the contact unassigned and returns a warning. Accepted and kept in the stored snapshot, but not currently used for owner resolution. Lead source label. Arbitrary contact fields. Provider-specific metadata. **Deduplication order:** `externalId` first, then the normalized `phoneNumber` within the same workspace. In a personal workspace the integration owner owns every synced contact, and `ownerEmail` is not used. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "company.upserted", "eventId": "crm_01HX5Z8FYBKD7QY5W3JN9NF2EA", "occurredAt": "2026-05-23T14:30:05.000Z", "data": { "externalId": "ext_company_7", "name": "Babbage Engines Ltd.", "domain": "babbage.example.com", "industry": "Hardware", "size": "11-50" } } ``` Stable company id in your system. Display name. Registered legal name. Primary domain, used as a secondary deduplication signal. Industry label. Headcount band, such as `11-50`. Main phone. Homepage URL. Source label. Arbitrary company fields. Provider-specific metadata. **Deduplication order:** `externalId`, then `domain` within the same workspace. Marks the contact as archived in Ringee. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "contact.deleted", "eventId": "crm_01HX5Z9MQ7G3T2DR0NXJW1B8VC", "occurredAt": "2026-05-23T14:35:00.000Z", "data": { "externalId": "ext_contact_42" } } ``` External contact id previously sent in `contact.upserted`. Ringee never deletes the underlying contact. Call history, notes, callbacks and meetings are preserved — only the integration's link is archived. Re-sending `contact.upserted` with the same `externalId` un-archives it. If no link exists, the delete is a successful no-op. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "company.deleted", "eventId": "crm_01HX5ZA1H4T6N2DR9CB7YP6F0K", "occurredAt": "2026-05-23T14:35:30.000Z", "data": { "externalId": "ext_company_7" } } ``` External company id previously sent in `company.upserted`. This archives only the link created by this integration. Historical company relationships remain available in Ringee. If no link exists, the delete is a successful no-op. ## Phone numbers Ringee normalizes `data.phoneNumber` to E.164 against real numbering plans. That normalized value is what agents dial and what deduplication matches on. | You send | Ringee stores | Response | | ------------------ | -------------- | ------------------------------------- | | `+14155550123` | `+14155550123` | Clean. | | `+34 911 23 45 67` | `+34911234567` | Clean — punctuation is not a problem. | | `(415) 555-0123` | `+14155550123` | A warning: no country code was sent. | | `n/a` | nothing | `failed` — `not a dialable number`. | A number with no leading `+` is read with **United States** as the assumed country, and the response tells you so. That assumption is wrong for most of the world, so send E.164. Deduplication matches on the normalized number, so a contact stored under a differently normalized value will not match and a second contact is created. Your `externalId` link is immune to this: it resolves the same contact regardless of phone formatting. ## Adding a contact to a campaign Include `data.campaignId` on `contact.upserted` and the contact joins that outbound campaign as a lead, in the same request that syncs it. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "contact.upserted", "eventId": "crm_01HX6B2P4WQF8ND3ZR7KT5YVMA", "occurredAt": "2026-05-23T14:31:00.000Z", "data": { "externalId": "ext_contact_42", "phoneNumber": "+14155550123", "firstName": "Ada", "campaignId": "3f8a1c54-9d2b-4e7a-b1c6-5a0d9e8f7c21" } } ``` The campaign id is the last segment of the campaign URL in the dashboard, under **Campaigns**. What it does: * The contact is added as a **pending** lead. If the campaign is already running, the lead is queued for dialing immediately. * A contact that is **already a lead** of that campaign is left untouched. Its attempts, status and disposition history survive every re-sync, so a CRM that replays `contact.upserted` on each edit never resets a lead's progress. * Membership is only ever added. Dropping `campaignId` from a later event does **not** remove the lead — remove it in Ringee. Campaigns exist only in organization workspaces, and the campaign must belong to the same workspace as your integration. A `campaignId` that is not a UUID, matches no campaign, or belongs to another workspace fails the event — and because validation runs before any write, the contact is not synced either. Correct it and resend with a new `eventId`. ## Sending events well Sync only when a field Ringee cares about actually changes: name, phone, email, company, job title, owner. Sending an event on every UI update wastes quota and makes your logs unreadable. `+14155550123` always wins over `(415) 555-0123`. See [Phone numbers](#phone-numbers) for what Ringee does with the difference. An ignored field and an unmatched owner are invisible in production and obvious in the response. Fail your integration tests on any unexpected `warnings` entry. If your CRM record was created or updated by a Ringee webhook, do not send it back. Store a `sync_origin` marker on those rows and skip them. See [Build an integration](/api/build-an-integration#prevent-sync-loops). Write the event to a local outbox table first, then attempt delivery. A contact save in your CRM should never fail because Ringee is momentarily unreachable. ## Next steps Open a secure dialer for a synced contact Receive call activity back in your CRM Every inbound error message and how to fix it The full two-way sync, end to end # Logs and errors Source: https://docs.ringee.io/api/logs-and-errors Debug inbound events and outbound deliveries Accepted inbound events and queued outbound deliveries are inspectable from the dashboard or the management API. A duplicate inbound `eventId` is returned as `skipped` without creating another row, and the immediate `test.ping` action is not stored as a delivery. ## Where to look | Symptom | Look at | | ----------------------------------------- | ---------------------------------------------------------------- | | Your event never appeared in Ringee | **Inbound logs** | | Ringee never called your endpoint | **Outbound logs** | | Ringee called you but nothing was created | Your own idempotency table — the event was probably deduplicated | | Everything stopped at once | Integration status, and whether the API key was rotated | In the dashboard: **Integrations → Custom Integrations → Configure**, then the inbound and outbound log tabs. ## Log endpoints Both require an admin dashboard session and return newest-first arrays. ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} GET /api/integrations/custom/:id/inbound-logs?limit=50&cursor=… GET /api/integrations/custom/:id/outbound-logs?limit=50&cursor=… ``` `limit` defaults to 50 and is capped at 200. To load the next page, pass the last returned row's `id` as `cursor`. **Inbound rows** contain `id`, `integrationId`, `eventType`, `externalEventId`, `status`, `receivedAt`, `processedAt`, `errorMessage` and `rawPayload`. Their normal lifecycle is `received` → `processing` → `processed` or `failed`. **Outbound rows** contain `id`, `integrationId`, `eventType`, `subjectId`, `destinationUrl`, `payload`, `signature`, `status`, `attemptCount`, `lastError`, `nextAttemptAt`, `sentAt`, `dedupeKey`, `createdAt` and `updatedAt`. Status is `pending`, `sending`, `sent` or `failed`. Outbound logging is one aggregate row per delivery, not one row per attempt. `attemptCount` counts failed attempts and `lastError` stores only the most recent failure. HTTP status and latency are returned by **Test webhook**, but are not persisted for each queued attempt. ## Inbound errors ### Rejected before Ringee records anything A bad key or an incomplete envelope is answered synchronously, and nothing reaches the inbound log. Every envelope problem is reported in one message, joined by `; `: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "statusCode": 400, "message": "eventId is required; occurredAt is required; data must be an object", "error": "Bad Request" } ``` | Status | Message | Fix | | ------ | ---------------------------- | ------------------------------------------------------------------------ | | `401` | `Invalid API key format` | The header is missing or the value is not a well-formed `cik_live_` key. | | `401` | `Invalid API key` | The key does not match any integration — it was likely rotated. | | `401` | `Integration is disabled` | Re-enable the integration in the dashboard. | | `400` | `body must be a JSON object` | Send `Content-Type: application/json` and a JSON object. | | `400` | `event is required` | Add `event` to the envelope. | | `400` | `eventId is required` | Add a unique `eventId`. | | `400` | `occurredAt is required` | Add an ISO-8601 `occurredAt`. | | `400` | `data must be an object` | `data` must be a JSON object, even when nearly empty. | | `400` | `Unsupported event: …` | The message lists the four event names Ringee accepts inbound. | ### Recorded, then rejected A `202` with `"status": "failed"` means the event was authenticated and stored, then rejected while being applied. It is in the inbound log with the reason in `errorMessage`, and **nothing was written**: validation runs before the first record is touched. | Message | Cause | | -------------------------------------------- | ----------------------------------------------------------------------------- | | `data. is required` | A required field is missing or blank. All of them are listed at once. | | `data. must be a string \| an object` | A required field has the wrong JSON type. | | `data.campaignId must be a UUID` | The id is not a UUID. Ids are never ignored silently. | | `data.phoneNumber is not a dialable number` | Nothing in the value could be read as a phone number. | | `Campaign not found` / `Access denied` | `data.campaignId` matches no campaign in this workspace. | | `Campaigns require an organization` | `data.campaignId` was sent from a personal workspace, which has no campaigns. | Resending a failed event with the **same** `eventId` returns `skipped`, not a retry. Fix the payload and send it with a **new** `eventId`. ### Applied, with warnings A `processed` response can carry a `warnings` array — an unknown field name, a phone number read with an assumed country, an `ownerEmail` matching nobody. The event applied; something in it did not. See [Inbound events](/api/inbound-events#warnings). Warnings are returned in the response only. They are not stored on the inbound log row, so capture them when you send. ## Outbound delivery failures A delivery is retried when your endpoint returns a non-2xx status, times out after 15 seconds, or is unreachable. | Recorded error | Meaning | | ---------------------------- | ---------------------------------------------------------------- | | `HTTP 4xx` / `HTTP 5xx` | Your endpoint responded with a non-2xx status. | | Timeout or network error | No response within 15 seconds, or the host could not be reached. | | `integration not found` | The integration was deleted while the delivery was queued. | | `integration disabled` | The integration's status is no longer `active`. | | `no outbound URL configured` | Returned by **Test webhook** when no URL is set. | After 10 failed attempts the delivery is marked `failed`. Ringee sends email and device push notifications to the integration owner or organization admins, throttled to at most one alert per integration every six hours. ## Common causes You parsed the body before verifying. Read the **raw text** and HMAC exactly those bytes — a `JSON.parse` followed by `JSON.stringify` produces different bytes and a different digest. Also confirm you are hashing `` `${timestamp}.${rawBody}` ``, not the body alone, and that you are using the `whsec_` signing secret rather than the `cik_live_` API key. Rotating the API key invalidates the old one immediately — and also revokes every Dialer SDK publishable key on that integration. Update both. You are sending a different `externalId` for the same record, or omitting it. Dedup runs on `externalId` first, then `phoneNumber`. Keep `externalId` stable for the lifetime of the record. That is intentional. `null`, `undefined` and empty strings never overwrite existing values on upsert — this protects Ringee data from partial CRM payloads. Expected. Transport retries reuse the `eventId`. Deduplicate on it with a `UNIQUE` constraint — see [Outbound webhooks](/api/outbound-webhooks#idempotency). Send your record id as `metadata.external_id` or `metadata.externalId` when you start the call. Ringee exposes it as `data.externalId` on every call-linked outbound event. `data.agent.id` identifies the Ringee AI Voice Agent, not your CRM record. `recording.ready` is a separate opt-in event that can arrive minutes after `call.completed`. Confirm it is selected in the outbound event list, and that recording is enabled for the workspace. ## Self-hosted debugging When running Ringee yourself, deliveries are drained by the Temporal orchestrator (`apps/orchestrator`). If outbound events queue up but never send, check that the orchestrator is running and connected to Temporal — see [How it works](/howitworks). # Outbound webhooks Source: https://docs.ringee.io/api/outbound-webhooks Receive Ringee activity in your application, verified and idempotent Ringee pushes the events selected on your Custom Integration to its configured HTTP(S) endpoint. Every delivery is **HMAC-signed** and retried up to 10 times until your endpoint acknowledges it. Calls generate terminal events only. Human and AI Voice Agent calls use the same delivery pipeline, envelope, subscriptions and retry policy. ## Configure the endpoint In **Integrations → Custom Integrations → Configure → Settings**: 1. Set **outbound URL** to your public HTTPS endpoint. 2. Select the events you want under the outbound event selector. 3. Click **Test webhook** to send a `test.ping`. The endpoint must be reachable from the public internet and must **not** require your own auth token — Ringee authenticates by signing the body, not by sending credentials. Disable JWT verification on the route and verify the signature instead. ## Delivery format ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} POST https://your-crm.example.com/webhooks/ringee Content-Type: application/json User-Agent: Ringee-Webhooks/1.0 Ringee-Signature: t=1747999938,v1=8f4c… Ringee-Timestamp: 1747999938 ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "call.completed", "eventId": "evt_01HX5ZBN7Y0Q3S4M2K1WJ8V5DC", "occurredAt": "2026-05-23T14:42:18.000Z", "workspaceId": "org_2k7yX…", "integrationId": "ci_…", "data": { "callId": "f3b1…", "externalId": "crm-contact-42", "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" } } } ``` ## Identify the source Outbound events can include these shared `data` fields: | Field | When present | | ----------------- | ------------------------------------------------------------------------------------------------------------ | | `data.user` | The Ringee user the event belongs to, including `id`, primary `email` and `fullName`. | | `data.agent` | The AI Voice Agent that produced the event, as `{ id, name }`. It is absent for human-operated calls. | | `data.externalId` | Your correlation id for an AI Voice Agent call, copied from `metadata.external_id` or `metadata.externalId`. | Actor enrichment is best-effort. Ringee still delivers the event if the user or agent can no longer be resolved, so treat `data.user` and `data.agent` as optional. `data.externalId` follows every call-linked event from the same AI Voice Agent call, including terminal, outcome, callback, meeting and recording events. Set `metadata.external_id` when you start an AI Voice Agent call. You can then join every related webhook to your own record without parsing arbitrary metadata. ## Verify the signature Read the request body as **raw text** first. Parsing the JSON before verifying — or re-serializing it — changes the bytes and the signature will never match. The signature is `HMAC_SHA256(signingSecret, ".")`, hex-encoded. `Ringee-Signature` has the form `t=,v1=`. Extract both parts. Confirm `t` matches the `Ringee-Timestamp` header, and reject anything more than **5 minutes** old. This blocks replay attacks. Compute the HMAC over `` `${timestamp}.${rawBody}` `` with your `whsec_…` secret and compare in **constant time**. A missing header or a bad signature is a `401`. Never store or process an unverified event. ```ts Node / TypeScript theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 300; export function verifyRingeeWebhook( rawBody: string, signatureHeader: string | null, timestampHeader: string | null, secret: string, ): boolean { if (!signatureHeader || !timestampHeader) return false; const parts = Object.fromEntries( signatureHeader.split(",").map((p) => p.split("=") as [string, string]), ); const timestamp = parts.t; const signature = parts.v1; if (!timestamp || !signature) return false; if (timestamp !== timestampHeader) return false; if (!/^[0-9a-f]{64}$/i.test(signature)) return false; const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); if (expected.length !== signature.length) return false; return timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(signature, "hex"), ); } ``` ```python Python theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import hashlib, hmac, time TOLERANCE_SECONDS = 300 def verify_ringee_webhook(raw_body: str, signature_header: str, timestamp_header: str, secret: str) -> bool: if not signature_header or not timestamp_header: return False try: parts = dict(p.split("=", 1) for p in signature_header.split(",")) except ValueError: return False timestamp, signature = parts.get("t"), parts.get("v1") if not timestamp or not signature or timestamp != timestamp_header: return False if len(signature) != 64 or any(c not in "0123456789abcdefABCDEF" for c in signature): return False try: age = abs(int(time.time()) - int(timestamp)) except ValueError: return False if age > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) ``` ```ts Supabase Edge Function theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} Deno.serve(async (req) => { const rawBody = await req.text(); // raw first — never req.json() const ok = verifyRingeeWebhook( rawBody, req.headers.get("Ringee-Signature"), req.headers.get("Ringee-Timestamp"), Deno.env.get("RINGEE_WEBHOOK_SIGNING_SECRET")!, ); if (!ok) return new Response("invalid signature", { status: 401 }); const event = JSON.parse(rawBody); await handleEvent(event); return new Response(JSON.stringify({ received: true }), { status: 200 }); }); ``` The Edge Function that receives Ringee webhooks must have `verify_jwt = false`. Ringee does not send a Supabase JWT — the HMAC signature is the authentication. ## Respond correctly | Rule | Detail | | ------------------ | ------------------------------------------------------------------ | | Respond fast | Ringee aborts the request after **15 seconds**. | | Respond `2xx` | Anything else counts as a failed delivery and is retried. | | Do the work after | Acknowledge first, process asynchronously if your handler is slow. | | Answer `test.ping` | Any `2xx` response succeeds; the response body is ignored. | `200 { "received": true }` and `204 No Content` are both valid acknowledgements. ## Idempotency The same `eventId` can arrive more than once — that is by design, not a bug. Transport retries reuse the exact stored payload and `eventId`. Store received events in a table with a `UNIQUE` constraint on the event id: ```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} create table ringee_webhook_events ( id uuid primary key default gen_random_uuid(), event_id text unique not null, event_type text not null, occurred_at timestamptz, payload jsonb, processing_status text not null default 'pending', error_message text, received_at timestamptz not null default now(), processed_at timestamptz ); ``` Insert the `eventId` **before** processing. If the insert conflicts, the event is a duplicate: return `200` and do nothing else. ## Retries | Setting | Value | | --------------- | ----------------------------------------- | | Max attempts | 10 | | Backoff | Exponential from 2s, doubling per attempt | | Backoff cap | 5 minutes | | Request timeout | 15 seconds | After the final attempt the delivery is marked `failed` and Ringee sends email and device push notifications to the integration owner or organization admins. Alerts are throttled per integration for six hours. Deliveries are also failed immediately if the integration has been disabled or deleted. The queued delivery worker runs every 5 seconds and claims up to 100 due deliveries. It sends independent deliveries in parallel, so neither the queue cadence nor creation time establishes ordering. Inspect the delivery status, cumulative attempt count and last error in the dashboard, or via `GET /api/integrations/custom/:id/outbound-logs`. See [Logs and errors](/api/logs-and-errors). ## Event ordering Deliveries are independent rows sent in parallel waves — **order is not guaranteed**. Design for this: * `call.outcome.updated` can arrive before `call.completed`. Upsert the activity by `callId` from either event. * `call.outcome.updated` includes `data.call` when the telephony row can still be resolved, so it can create or update the activity by itself. * `recording.ready` can arrive seconds or minutes after the call ended. * `meeting.created` and `call.outcome.updated` both fire when an outcome is `meeting_booked`. * A subscribed AI Voice Agent call can produce `call.completed` or `call.failed`, then `call.outcome.updated`, and later callback, meeting or recording events. Do not wait for a fixed sequence. ## Next steps Every outbound payload, field by field Mapping, loop prevention and a test checklist # Public API overview Source: https://docs.ringee.io/api/overview Sync CRM data and run AI Voice Agents with a Custom Integration API key The Ringee **Public API** uses **Custom Integrations** to let an external system sync contacts and companies, receive call activity, open a secure dialer and run AI Voice Agents. CRM synchronization is bidirectional and event-based. AI Voice Agent execution uses resource-oriented endpoints that return call ids for asynchronous result polling. Create an integration and get your `cik_live_` API key List agents, start calls and read structured results End-to-end guide with idempotency, retries and loop prevention ## What you can do | Capability | Direction | Endpoint | | -------------------------------------------- | ------------ | --------------------------------------------- | | Create and update contacts and companies | CRM → Ringee | `POST /api/integrations/custom/webhook` | | Archive deleted contacts and companies | CRM → Ringee | `POST /api/integrations/custom/webhook` | | Open a secure dialer for a contact | CRM → Ringee | `POST /api/integrations/custom/click-to-call` | | List configured AI Voice Agents | App → Ringee | `GET /api/v1/ai-voice-agents` | | Start an autonomous AI voice call | App → Ringee | `POST /api/v1/ai-voice-agents/:id/calls` | | Read AI voice call results | App → Ringee | `GET /api/v1/ai-voice-agents/calls/:callId` | | Receive completed, missed and failed calls | Ringee → App | Your webhook URL | | Receive outcomes, notes, callbacks, meetings | Ringee → App | Your webhook URL | | Receive recordings and Do Not Call entries | Ringee → App | Your webhook URL | ## How it works ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef crm fill:#22C55E,color:white,stroke:#fff classDef ringee fill:#15803D,color:white,stroke:#fff crm[Your CRM]:::crm ringee[Ringee]:::ringee crm -- "POST /webhook (X-Ringee-Api-Key)" --> ringee crm -- "POST /click-to-call" --> ringee crm -- "POST /v1/ai-voice-agents/:id/calls" --> ringee ringee -- "signed webhook (Ringee-Signature)" --> crm ``` * **Inbound** (CRM → Ringee): you send events to a single Ringee endpoint, authenticated with your secret API key. See [Inbound events](/api/inbound-events). * **Outbound** (Ringee → CRM): Ringee sends HMAC-signed events to the URL you configure, with automatic retries. See [Outbound webhooks](/api/outbound-webhooks). * **Click-to-call**: you exchange a contact for a short-lived, signed dialer URL your agent opens in the browser. See [Click-to-call](/api/click-to-call). * **AI Voice Agents**: your server lists configured agents, starts calls and polls normalized results. See [AI Voice Agents API](/voice-agents/api-reference). ## The event envelope Every event in both directions shares the same envelope: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "event": "contact.upserted", "eventId": "crm_01HX5Z7K8MZP1Q4V0G9YJ2RH3T", "occurredAt": "2026-05-23T14:30:00.000Z", "data": {} } ``` Outbound events (Ringee → your application) add two fields: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "workspaceId": "org_2k7yX...", "integrationId": "ci_..." } ``` They can also add shared source fields under `data`: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "user": { "id": "usr_…", "email": "ada@babbage.example.com", "fullName": "Ada Lovelace" }, "agent": { "id": "va_…", "name": "Sofia" }, "externalId": "crm-contact-42" } ``` `data.user` identifies the Ringee user the event belongs to. `data.agent` is present when an AI Voice Agent produced the event. If you start that call with `metadata.external_id` or `metadata.externalId`, Ringee copies it to `data.externalId` on every related outbound event. `eventId` must identify one logical event and remain stable across transport retries. If Ringee returns `status: "failed"` while processing an inbound event, fix the payload and use a **new** `eventId`; the original id is already recorded. ## Choosing the right integration surface Ringee exposes several ways to build on top of it. They solve different problems and can be combined. **Server to server.** Sync CRM records and receive call activity. Requires a backend. **Browser.** Embed a real dialer in your own web app. No backend required. **AI agents.** Let Claude or ChatGPT operate Ringee on the user's behalf. **Autonomous calls.** Place server-originated conversations and read structured results. A typical CRM integration uses **both** the Public API (to sync data and log activity) and either **click-to-call** or the **Dialer SDK** (to actually place calls from the CRM UI). Every inbound, click-to-call and AI Voice Agent request uses the Custom Integration `cik_live_…` key. AI Voice Agent endpoints also accept that same key as a Bearer credential. ## Base URL | Deployment | Base URL | | ----------- | ---------------------------------------------------------------------------- | | Hosted | `https://api.ringee.io` | | Self-hosted | Your `BACKEND_URL` (see [Configuration reference](/configuration/reference)) | All endpoints in this section are relative to that base URL and live under the global `/api` prefix. ## Limits and expectations * Ringee considers an outbound delivery failed if your endpoint does not respond within **15 seconds** or returns a non-2xx status. * Failed deliveries are retried up to **10 times** with exponential backoff, capped at **5 minutes** between attempts. * Outbound deliveries are independent and can arrive out of order. Use `eventId` for idempotency and `data.callId` for call-level correlation. * Event payloads can gain optional fields over time. Ignore unknown keys and validate the fields your integration actually needs. * Ringee currently emits **terminal** call events only. There are no `call.started`, `call.ringing` or `call.answered` events — do not build a real-time incoming-call screen on top of this API. ## Next steps Set up a Custom Integration in the dashboard Every outbound payload, field by field # Ways to build on Ringee Source: https://docs.ringee.io/build-on-ringee Choose the right integration surface for what you are building Ringee exposes five ways to build on top of it. They are complementary—most real integrations use more than one. Server to server. Sync CRM records, open a secure dialer and run AI Voice Agents. Browser. Embed a real dialer inside your own web app. AI agents. Let Claude or ChatGPT read and act on Ringee data. Terminal and scripts. The same capabilities as MCP, in a shell. Autonomous calling. Hold a real conversation and return a structured result. ## Pick by what you are building | I want to… | Use | | -------------------------------------------------- | -------------------------------------------------- | | Log Ringee calls as activities in my CRM | [Public API](/api/overview) | | Push my contacts into Ringee so they are callable | [Public API — inbound events](/api/inbound-events) | | Add a "Call" button with the least possible work | [Click-to-call](/api/click-to-call) | | Keep agents inside my product while they call | [Dialer SDK](/dialer-sdk/overview) | | Build a completely custom call interface | [Dialer SDK — headless](/dialer-sdk/headless) | | Let an assistant find leads and book follow-ups | [MCP](/mcp/overview) | | Script bulk operations or run them in CI | [CLI](/cli/overview) | | Give an autonomous agent access to Ringee | [CLI from an agent](/cli/agents) | | Have an AI agent call, converse and book a meeting | [AI Voice Agents](/voice-agents/overview) | | Trigger an AI voice call from my application | [AI Voice Agents API](/voice-agents/api-reference) | ## Side by side | | Public API | Dialer SDK | MCP | CLI | AI Voice Agents | | ---------------- | ------------------------- | ------------------------- | ------------------- | ------------------------ | ------------------------------------------- | | Runs in | Your server | The browser | An AI client | A shell | Ringee's backend | | Credential | `cik_live_` | `pk_live_` | MCP URL or OAuth | MCP URL | `cik_live_`, MCP URL or OAuth | | Main purpose | CRM sync and webhooks | Human-operated calling UI | AI tool access | Scripts and shell agents | Autonomous phone conversations | | Places calls | Human dialer and AI voice | Yes, over browser WebRTC | AI voice calls only | AI voice calls only | Yes, server-originated | | Receives results | Signed webhooks | Live browser events | Read on demand | Read on demand | Status, outcome, summary and extracted data | Human-operated calls still run over WebRTC in a browser or mobile app. AI Voice Agents are the deliberate exception: Ringee starts those calls server-side, the configured agent holds the conversation, and API, MCP or CLI can read the asynchronous result. ## A typical CRM integration Most teams combine two surfaces: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef crm fill:#86EFAC,color:black,stroke:#fff classDef svc fill:#15803D,color:white,stroke:#fff sync[Public API: sync contacts]:::svc call[Dialer SDK or click-to-call]:::svc hook[Public API: webhooks]:::svc crm[Your CRM]:::crm crm --> sync --> call --> hook --> crm ``` Send `contact.upserted` so Ringee knows who is callable, and keep `externalId` stable so click-to-call and the Dialer SDK can resolve the same contact. For AI Voice Agent webhook correlation, also send it as `metadata.external_id` when you start the call. See [Inbound events](/api/inbound-events). Embed the [Dialer SDK](/dialer-sdk/overview) for an in-product experience, or use [click-to-call](/api/click-to-call) if you want the fastest possible path. Subscribe to `call.completed`, `call.outcome.updated` and `recording.ready` and write them into your CRM. Signed, retried, idempotent. See [Outbound webhooks](/api/outbound-webhooks). ## Credentials at a glance | Credential | Belongs to | Where it lives | | ------------ | ------------------------------ | --------------------------------- | | `cik_live_…` | Public API and AI Voice Agents | Your server only | | `whsec_…` | Webhook verification | Your server only | | `pk_live_…` | Dialer SDK | Safe in the browser | | MCP URL | MCP and CLI | A secret manager or shell profile | Create `cik_live_…`, `whsec_…` and `pk_live_…` credentials from **Integrations → Custom Integrations**. The MCP URL comes from `GET /api/mcp/connection-info`. `cik_live_` and `pk_live_` look similar and are minted by the same integration, but only `pk_live_` is browser-safe. Rotating the `cik_live_` key revokes every `pk_live_` key on that integration. ## Self-hosting Every surface works against a self-hosted deployment. Replace `https://api.ringee.io` with your own backend URL: | Surface | How | | --------------- | ---------------------------------------------------------------------------------------------------- | | Public API | Use your `BACKEND_URL` as the base | | Dialer SDK | Pass `apiUrl` (the API origin, without `/api`) | | MCP | The URL from `GET /api/mcp/connection-info` already points at your host | | CLI | Set `RINGEE_BACKEND_URL` or the full `RINGEE_MCP_URL` | | AI Voice Agents | Set a public `PUBLIC_BACKEND_URL`, configure the voice provider and keep the Temporal worker running | See the [Configuration reference](/configuration/reference) and [AI Voice Agents self-hosting](/voice-agents/self-hosting). # Use it from an agent Source: https://docs.ringee.io/cli/agents Give any shell-capable agent access to Ringee Any agent harness that can run shell commands can operate Ringee through the CLI — no MCP client implementation required. Install it, export the connection, and have the agent call commands with `--json`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} npm i -g ringee export RINGEE_MCP_URL="https://api.ringee.io/api/mcp//sse" ringee config check # confirm connectivity ringee contacts search acme --json # machine-readable for the agent ``` This works with Claude Code, OpenClaw, Hermes, a cron job, or your own loop. If your harness speaks MCP natively, connect the [MCP server](/mcp/connect) directly instead — you get typed tool schemas and the client can surface confirmation prompts itself. ## Why this is safe The guardrails live in the CLI, not in the prompt. An autonomous agent cannot spend credits, mint a magic link or delete a contact without passing a flag that encodes a deliberate decision: | Action | Required | | ----------------------- | ----------------------------------------------- | | `leads reveal` | `--yes` | | `sessions create` | `--yes` | | `sessions revoke` | `--yes` | | `contacts delete` | `--confirm-phone ` **and** `--yes` | | `campaigns delete-lead` | `--yes` | | `dnc remove` | `--yes` | | `voice-agents call` | `--yes` | Without them the command exits non-zero with an explanation, which the agent can relay to the user instead of guessing. ## Brief the agent Three commands print the shared operating knowledge, no connection needed: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee tools # capability catalog with sensitivity tags ringee flow # the outbound flow, step by step ringee prompt # the full agent system prompt ``` Feeding `ringee prompt` into your agent's system message gives it the same rules the Claude skills and the ChatGPT app follow — which tool maps to which action, the prospect → contact → session → outcome → follow-up flow, and when to stop and ask. ## A typical loop ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts search "ada" --json ``` Never act on an id the user did not approve. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee outcomes log interested --note "Wants pricing" --json ringee callbacks create 2026-05-24T10:00:00-04:00 --json ``` Ask the user, then pass the flag: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee sessions create --contact --title "Tue outbound" --yes --json ``` For an autonomous phone conversation, resolve the agent first, name the destination to the user and wait for approval before starting it: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents list --json ringee voice-agents call --to +13055550123 --var first_name=Carlos --yes --json ``` Sensitive commands exit non-zero when the confirmation flag is missing. Treat that as "ask the human", not as a bug to work around. ## Build on the agent layer directly If you are writing TypeScript instead of shelling out, use `@ringee-io/agent` — the package the CLI itself is built on. It contains no business logic: it validates input, talks to the MCP, and shares the catalog, flows, prompts and rules. ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { RingeeClient } from "@ringee-io/agent"; // Reads RINGEE_MCP_URL, or RINGEE_BACKEND_URL + RINGEE_USER_ID [+ RINGEE_ORG_ID] const ringee = RingeeClient.fromEnv(); const { contacts } = await ringee.searchContacts({ query: "acme" }); const created = await ringee.createContact({ phoneNumber: "+14155552671", name: "Jane Doe", }); // Sensitive — confirm with the user first: const session = await ringee.createCallSession({ title: "Tuesday outbound", contacts: [{ contactId: created.contact!.id }], }); console.log(session.joinUrl); // share exactly as returned await ringee.close(); ``` Reuse the operating knowledge in your own prompts: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { buildSystemPrompt, TOOL_CATALOG, PRIMARY_FLOW, } from "@ringee-io/agent"; ``` | Export | Purpose | | --------------------- | -------------------------------------------- | | `RingeeClient` | Typed facade — one method per capability | | `RingeeMcpClient` | Lower-level MCP SSE transport wrapper | | `TOOL_CATALOG` | Action → tool map with sensitivity classes | | `PRIMARY_FLOW` | The outbound flow definition | | `buildSystemPrompt()` | Composed prompt from catalog, flow and rules | | `schemas`, `types` | Zod input schemas and result shapes | ## Next steps Ready-made `/ringee` commands for Claude and ChatGPT What the server enforces regardless of the client # Command reference Source: https://docs.ringee.io/cli/commands Every ringee CLI command, argument and flag Add `--json` to any command for raw, machine-readable output. ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee [--json] [args] [options] ``` Phone numbers are **E.164** (`+14155552671`). Datetimes are **ISO-8601 with an offset** (`2026-05-23T14:30:00-04:00`). ## Every command at a glance
Read looks, never changes anything  ·  Write creates or updates a record  ·  Sensitive spends money or mints a shareable link  ·  Destructive removes access or data
| Command | Type | Guard | | ------------------------------------------------------------------------------------------------------ | ------------------------ | --------------------------- | | [`contacts search`](#contacts-search) | Read | — | | [`contacts get`](#contacts-get) | Read | — | | [`contacts by-outcome`](#contacts-by-outcome) | Read | — | | [`contacts create`](#contacts-create) | Write | — | | [`contacts update`](#contacts-update) | Write | — | | [`contacts delete`](#contacts-delete) | Destructive | `--confirm-phone` + `--yes` | | [`leads search`](#leads-search) | Read | — | | [`leads reveal`](#leads-reveal) | Sensitive | `--yes` | | [`leads import`](#leads-import) | Write | — | | [`sessions create`](#sessions-create) | Sensitive | `--yes` | | [`sessions get`](#sessions-get) | Read | — | | [`sessions update`](#sessions-update) | Write | — | | [`sessions revoke`](#sessions-revoke) | Destructive | `--yes` | | [`calls list`](#calls-list) | Read | — | | [`outcomes log`](#outcomes-log) | Write | — | | [`callbacks create`](#callbacks-create) | Write | — | | [`callbacks list`](#callbacks-list) | Read | — | | [`meetings schedule`](#meetings-schedule) | Write | — | | [`campaigns list`](#campaigns-list) / [`campaigns get`](#campaigns-get) | Read | — | | [`campaigns status`](#campaigns-status) | Write | org admin | | [`campaigns leads`](#campaigns-leads) | Read | — | | [`campaigns add-lead`](#campaigns-add-lead) / [`campaigns import-leads`](#campaigns-import-leads) | Write | org admin | | [`campaigns delete-lead`](#campaigns-delete-lead) | Destructive | `--yes` + org admin | | [`campaigns analytics`](#campaigns-analytics) | Read | — | | [`analytics calls`](#analytics-calls) / [`analytics day`](#analytics-day) | Read | — | | [`dnc list`](#dnc-list) | Read | — | | [`dnc add`](#dnc-add) | Write | — | | [`dnc remove`](#dnc-remove) | Destructive | `--yes` | | [`pipelines list`](#pipelines-list) / [`pipelines results`](#pipelines-results) | Read | admin policy | | [`voice-agents list`](#voice-agents-list) | Read | organization | | [`voice-agents call`](#voice-agents-call) | Sensitive | `--yes` + organization | | [`voice-agents call-result`](#voice-agents-call-result) | Read | organization | | [`config show`](#config-and-knowledge) / [`config check`](#config-and-knowledge) | Read | — | | [`tools`](#config-and-knowledge) / [`flow`](#config-and-knowledge) / [`prompt`](#config-and-knowledge) | Read | works offline | ## Contacts ### `contacts search` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts search ``` Search by name, phone, email or company. | Option | Description | | ----------------- | ----------------- | | `-p, --page ` | Page number | | `-l, --limit ` | Page size, max 50 | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts search acme ringee contacts search "ada lovelace" --limit 5 ``` ### `contacts get` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts get ``` Full details for one contact, including recent calls, notes and meetings. ### `contacts by-outcome` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts by-outcome ``` Find contacts whose calls reached given outcomes — useful for deriving an ICP from closed business. | Option | Description | | ----------------------- | --------------------------------------------------- | | `--match ` | `any` matches any call; `last` only the most recent | | `--include-unreachable` | Include `doNotCall` and `unsubscribed` contacts | | `-p, --page ` | Page number | | `-l, --limit ` | Page size, max 50 | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts by-outcome sale interested --match last ``` Valid outcomes: `meeting_booked`, `sale`, `interested`, `follow_up`, `callback_scheduled`, `not_interested`, `no_answer`, `voicemail`, `wrong_number`, `gatekeeper`. ### `contacts create` | Option | Description | | ------------------------------------------- | --------------------------------------------- | | `--phone ` | **Required.** Must be unique in the workspace | | `--name ` | Display name | | `--first ` / `--last ` | Name parts | | `--email ` | Email | | `--title ` | Job title | | `--company ` | Company name | | `--note ` | Initial note | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts create --phone +14155552671 --name "Ada Lovelace" --company "Babbage Engines" ``` ### `contacts update` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts update ``` Accepts `--phone`, `--name`, `--first`, `--last`, `--email`, `--title`, `--company`. Only the flags you pass change. ### `contacts delete` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts delete ``` Destructive **Destructive.** Requires both `--confirm-phone` matching the stored number **and** `--yes`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts get # verify the number first ringee contacts delete --confirm-phone +14155552671 --yes ``` ## Leads Prospecting uses your connected enrichment provider. These commands never spend Ringee calling credits. ### `leads search` Returns a `jobId` you pass to `reveal` and `import`. | Option | Description | | ----------------------------------- | ------------------------------------------------- | | `--provider ` | `apollo` or `prospeo`. Auto-selected when omitted | | `--keywords ` | Free-text keywords | | `--title ` | Job title (repeatable or comma-separated) | | `--seniority ` | Seniority (repeatable) | | `--department ` | Department (repeatable) | | `--industry ` | Industry (repeatable) | | `--country ` / `--city ` | Person location (repeatable) | | `--company ` / `--domain ` | Company filters (repeatable) | | `--has-email` / `--has-phone` | Only candidates with that field | | `--page ` / `--per-page ` | Pagination, max 25 per page | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee leads search --title "VP Sales" --country US --industry Software ``` ### `leads reveal` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee leads reveal ``` Sensitive Spends provider credits Unlocks email — and optionally phone — and upserts a Ringee contact. | Option | Description | | ----------- | --------------------------------------------------- | | `--phone` | Also reveal a mobile phone (extra provider credits) | | `-y, --yes` | **Required.** Confirms the credit spend | ### `leads import` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee leads import ``` Bulk-imports candidates as contacts, deduplicated by phone number. Does not reveal hidden emails or phones. ## Call sessions ### `sessions create` Sensitive Mints a magic link someone can open to dial through a queue without logging in. | Option | Description | | ------------------------- | ----------------------------------------------- | | `--contact ` | Contact in the queue (repeatable) | | `--phone ` | Raw phone in the queue (repeatable) | | `--title ` | Session title | | `--campaign <campaignId>` | Attribute calls to a campaign | | `--max-calls <n>` | Auto-complete after N calls | | `-y, --yes` | **Required.** Confirms minting a shareable link | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee sessions create --contact <id> --contact <id> --title "Tue outbound" --yes ``` <Warning> The join URL is printed **once** and cannot be re-fetched. Share it only with someone authorized to call on the workspace's behalf. </Warning> ### `sessions get` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee sessions get <callSessionId> ``` Status and progress. Never prints the raw token. ### `sessions update` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee sessions update <callSessionId> ``` | Option | Description | | ------------------------- | ----------------------------------- | | `--title <title>` | New title | | `--campaign <campaignId>` | Set a campaign, or `null` to detach | | `--expires <minutes>` | Extend validity | ### `sessions revoke` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee sessions revoke <callSessionId> ``` <span>Destructive</span> <Warning> **Destructive.** Requires `--yes`. The link stops working immediately; call history is preserved. </Warning> ## Calls and follow-ups ### `calls list` Full call detail, newest first. | Option | Description | | ------------------------------------ | ------------------------------------------------- | | `--contact <contactId>` | Filter to one contact | | `--outcome <outcomes...>` | Filter by logged outcome | | `--status <statuses...>` | Filter by status, e.g. `completed` | | `--from <iso>` / `--to <iso>` | Created-at range | | `--full` | Print the full transcription instead of a preview | | `-p, --page <n>` / `-l, --limit <n>` | Pagination, max 50 | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee calls list --outcome sale --from 2026-05-01T00:00:00Z --full ``` ### `outcomes log` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee outcomes log <callId> <outcome> ``` | Option | Description | | --------------- | ------------------------ | | `--note <note>` | Free-text follow-up note | ### `callbacks create` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee callbacks create <contactId> <scheduledAt> ``` | Option | Description | | ----------------- | -------------- | | `--call <callId>` | Source call id | | `--note <note>` | Free-text note | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee callbacks create <contactId> 2026-05-24T10:00:00-04:00 --note "Asked for Monday" ``` ### `callbacks list` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee callbacks list ``` Lists callbacks soonest first, with the contact and originating campaign. | Option | Description | | ------------------------------------ | ----------------------------------------------------------------------- | | `--status <status>` | `scheduled`, `due`, `in_progress`, `completed`, `missed` or `cancelled` | | `-p, --page <n>` / `-l, --limit <n>` | Pagination, max 50 | ### `meetings schedule` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee meetings schedule <contactId> <scheduledAt> ``` | Option | Description | | ------------------------- | ------------------------------------------------------ | | `--title <title>` | Meeting title | | `--duration <minutes>` | Defaults to 30 | | `--location <location>` | Address or video URL | | `--email <attendeeEmail>` | Send an external calendar invitation when connected | | `--call <callId>` | Source call; also sets its outcome to `meeting_booked` | | `--notes <notes>` | Notes | ## Campaigns Campaign commands require an active organization workspace. Every write is restricted to organization admins. ### `campaigns list` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns list ``` | Option | Description | | ------------------------------------ | ------------------------------------------ | | `-s, --search <text>` | Filter by name or description | | `--status <status>` | `draft`, `active`, `paused` or `completed` | | `-p, --page <n>` / `-l, --limit <n>` | Pagination, max 50 | ### `campaigns get` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns get <campaignId> ``` Returns the campaign configuration, lead count, dialer mode, attempts, retry delay, wrap-up time and working hours. ### `campaigns status` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns status <campaignId> <status> ``` The server accepts only these transitions: * `draft` → `active` * `active` → `paused` or `completed` * `paused` → `active` or `completed` * `completed` is terminal Activating a campaign also requires at least one lead, one configured disposition and a usable outbound number. ### `campaigns leads` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns leads <campaignId> ``` | Option | Description | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `--status <status>` | `pending`, `queued`, `locked`, `dialing`, `in_call`, `wrap_up`, `dispositioned`, `scheduled`, `completed`, `exhausted`, `dnc`, `called` or `dead` | | `-p, --page <n>` / `-l, --limit <n>` | Pagination, max 50 | ### `campaigns add-lead` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns add-lead <campaignId> --phone <e164> --name <name> ``` Reuses an existing contact with the same phone number when one exists. | Option | Description | | --------------------- | ------------------------------- | | `--phone <e164>` | **Required.** Lead phone number | | `--name <name>` | **Required.** Lead name | | `--email <email>` | Email | | `--company <company>` | Company | | `--title <jobTitle>` | Job title | | `--state <state>` | State or region | | `--website <website>` | Website | ### `campaigns import-leads` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns import-leads <campaignId> <file> ``` The file must contain a non-empty JSON array with at most 200 entries: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} [ { "name": "Ada Lovelace", "phone": "+14155552671", "email": "ada@example.com", "company": "Analytical Engines", "jobTitle": "Founder", "state": "CA", "website": "https://example.com" } ] ``` ### `campaigns delete-lead` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns delete-lead <campaignId> <leadId> --yes ``` <Warning> **Destructive.** Removes the lead, its call attempts and its campaign callbacks. The underlying contact is preserved. A lead that is locked, dialing, in a call or wrapping up cannot be removed. </Warning> ### `campaigns analytics` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee campaigns analytics <campaignId> ``` | Option | Description | | ----------------------------- | ---------------------------------------- | | `--from <iso>` / `--to <iso>` | Reporting window | | `--no-agents` | Skip the per-agent breakdown | | `--hourly` | Include the hourly call-volume histogram | The result includes attempts, connections, conversions, rates, handle time, dispositions and lead status totals. ## Analytics ### `analytics calls` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee analytics calls ``` Reads the same call-performance data shown on the dashboard overview. | Option | Description | | ------------------------------- | -------------------------------------------------------------------------------- | | `-r, --range <range>` | `today`, `yesterday`, `7d`, `30d`, `this_month` or `last_month` | | `--from <iso>` / `--to <iso>` | Custom reporting window | | `--campaign <campaignId\|none>` | One campaign, or only calls outside campaigns | | `--outcome <outcome>` | Restrict every metric to one outcome | | `--scope <scope>` | `personal` or `organization` | | `--member <userId>` | One member; organization admins only | | `--include <blocks...>` | Any of `kpis`, `funnel`, `by-outcome`, `over-time`, `best-time-of-day`, `agents` | If `--include` is omitted, Ringee computes `kpis`, `funnel` and `by-outcome`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee analytics calls --range 30d --campaign none ringee analytics calls --scope organization --include kpis agents ``` ### `analytics day` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee analytics day <YYYY-MM-DD> ``` Returns calls, callbacks and meetings for one calendar day. | Option | Description | | ------------------------------- | ---------------------------------------------------------- | | `--offset <utcOffset>` | Day boundary offset, for example `-04:00`; defaults to UTC | | `--campaign <campaignId\|none>` | One campaign, or calls outside campaigns | | `--outcome <outcomes...>` | Include only these call outcomes | | `--no-callbacks` | Skip callbacks | | `--no-meetings` | Skip meetings | | `-l, --limit <n>` | Maximum calls returned, default 50 and max 100 | ## Do-not-call list ### `dnc list` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee dnc list ``` | Option | Description | | ------------------------------------ | ------------------------------- | | `-s, --search <fragment>` | Filter by phone-number fragment | | `-p, --page <n>` / `-l, --limit <n>` | Pagination, max 50 | ### `dnc add` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee dnc add <phoneNumbers...> ``` Adds one or more E.164 numbers to the workspace suppression list. Every future Ringee dial surface, including AI Voice Agents, checks this list before calling. | Option | Description | | ------------------- | ------------------------------- | | `--reason <reason>` | Why the numbers were suppressed | ### `dnc remove` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee dnc remove <phoneNumber> --yes ``` <Warning> **Destructive compliance change.** Releasing a number makes it callable again. Verify the entry with `dnc list` and remove it only when the person has asked to be callable. </Warning> ## AI pipelines Pipeline reads follow the admin policy: freelancers can read their personal context; inside an organization, the caller must be an organization admin. ### `pipelines list` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee pipelines list ``` Lists the available pipelines, enabled contexts, pending actions and newly eligible calls. ### `pipelines results` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee pipelines results <pipeline> [context] ``` `<pipeline>` is one of `follow_up_recommendations`, `script_optimization` or `objection_intelligence`. Choose exactly one analysis context: | Option | Description | | ------------------------- | -------------------------------------------------------------------- | | `--campaign <campaignId>` | Calls attributed to one campaign | | `--org` | Organization calls outside campaigns; the default in an organization | | `--personal` | Personal calls in a freelancer workspace | | `--status <status>` | `pending` (default), `completed`, `dismissed` or `snoozed` actions | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee pipelines results objection_intelligence --org ringee pipelines results follow_up_recommendations --campaign <campaignId> --status pending ``` ## AI Voice Agents These commands require an active organization workspace. A voice-agent call runs server-side; the CLI can exit while the conversation continues. ### `voice-agents list` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents list ``` Lists agents, their type and status, required variables, default caller number and usable Ringee caller-number ids. ### `voice-agents call` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents call <agentId> --to <phone> --var <name=value> --yes ``` <Warning> This starts a real outbound phone call and spends Ringee credits. `--yes` is required. </Warning> | Option | Description | | -------------------- | ------------------------------------------------------------------------------------------------ | | `--to <phone>` | **Required.** Destination in E.164 | | `--from <numberId>` | Ringee phone-number id to call from; otherwise use the agent default or the sole eligible number | | `--var <name=value>` | Dynamic conversation variable; repeatable and values may contain `=` | | `--external-id <id>` | Your id, returned later under `metadata.external_id` | | `-y, --yes` | **Required.** Confirm the real, billed call | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents call <agentId> \ --to +14155552671 \ --var first_name=Ada \ --var appointment_date=2026-09-10 \ --var appointment_time=14:00 \ --yes ``` ### `voice-agents call-result` ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents call-result <callId> ``` Read the current status and, once analysis is ready, the outcome, summary, sentiment, extracted data and metadata. Poll deliberately; retrying `voice-agents call` starts another call. See [AI Voice Agents from the CLI](/voice-agents/cli) for the end-to-end workflow and type-specific variables. ## Config and knowledge | Command | Description | | --------------------- | -------------------------------------------------- | | `ringee config show` | Resolved connection, token masked | | `ringee config check` | Connect and list every tool the server exposes | | `ringee tools` | The agent capability catalog with sensitivity tags | | `ringee flow` | The outbound sales flow, step by step | | `ringee prompt` | The shared agent system prompt | `tools`, `flow` and `prompt` need no connection — they print the shared knowledge from `@ringee-io/agent`, which is handy when briefing your own agent. ## Global options | Option | Description | | --------------- | ---------------------------------- | | `--json` | Raw JSON instead of formatted text | | `-v, --version` | Print the version | | `--help` | Help for any command or subcommand | ## Next steps <Card title="Drive it from an agent" icon="robot" href="/cli/agents"> Use the CLI as an agent's Ringee interface </Card> # Configuration Source: https://docs.ringee.io/cli/configuration Point the ringee CLI at your workspace The CLI connects to the Ringee MCP server. It needs one thing: the URL of that server for your workspace. ## Get your connection details From the dashboard, open **Settings → MCP / Integrations**, or call the API while signed in: ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} GET /api/mcp/connection-info ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "mode": "freelancer", "userId": "a91c…", "organizationId": null, "url": "https://api.ringee.io/api/mcp/a91c…/sse" } ``` ## Environment variables <ParamField type="string"> The full SSE URL. **Preferred** — takes precedence over everything else. </ParamField> <ParamField type="string"> Your Ringee API base URL. Used together with `RINGEE_USER_ID` when `RINGEE_MCP_URL` is not set. </ParamField> <ParamField type="string"> Your Ringee user UUID. </ParamField> <ParamField type="string"> Organization UUID, to start in an organization workspace instead of your personal one. </ParamField> <ParamField type="string"> Optional bearer token sent as an `Authorization` header. </ParamField> <CodeGroup> ```bash Full URL (preferred) theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} export RINGEE_MCP_URL="https://api.ringee.io/api/mcp/<userId>/sse" ``` ```bash Organization workspace theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} export RINGEE_MCP_URL="https://api.ringee.io/api/mcp/<userId>/<organizationId>/sse" ``` ```bash Built from parts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} export RINGEE_BACKEND_URL="https://api.ringee.io" export RINGEE_USER_ID="<userId>" export RINGEE_ORG_ID="<organizationId>" # optional ``` ```bash Self-hosted theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} export RINGEE_BACKEND_URL="https://ringee-api.example.com" export RINGEE_USER_ID="<userId>" ``` </CodeGroup> <Note> `RINGEE_BACKEND_URL` tolerates a trailing `/api` — the CLI strips it before building the path, so both `https://api.ringee.io` and `https://api.ringee.io/api` work. </Note> <Warning> The MCP URL is a credential for that workspace. Keep it in your shell profile or secret manager, not in a committed `.env` or a shared script. </Warning> ## Verify ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee config show # resolved connection, token masked ringee config check # connect and list every tool the server exposes ``` `config check` is the fastest way to answer "is my Ringee connection working?" — if it prints tools, every other command will work, and so will any MCP client pointed at the same URL. ## Choosing a workspace The URL sets your starting workspace, but it is not a lock. Anything that changes the active workspace — the dashboard switcher, an assistant calling `switch_workspace` — applies to the CLI too, because the active workspace is stored server-side. <Tip> Seeing results you did not expect? Your active workspace has probably changed. See [Workspaces](/mcp/workspaces). </Tip> ## Errors | Message | Fix | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `Missing Ringee connection config` | Set `RINGEE_MCP_URL`, or `RINGEE_BACKEND_URL` **and** `RINGEE_USER_ID`. | | Connection refused or timeout | Confirm the backend is reachable from this machine and the URL includes `/api/mcp/…/sse`. | | Empty tool list from `config check` | The URL resolved but the ids do not match a real user or organization. Re-run `GET /api/mcp/connection-info`. | ## Next steps <CardGroup> <Card title="Command reference" icon="terminal" href="/cli/commands"> Every command, argument and flag </Card> <Card title="Use it from an agent" icon="robot" href="/cli/agents"> Give a shell-capable agent access to Ringee </Card> </CardGroup> # CLI overview Source: https://docs.ringee.io/cli/overview Operate Ringee from the terminal The `ringee` CLI puts contacts, leads, campaigns, analytics, DNC, AI pipelines, AI Voice Agents, dialing sessions and follow-ups behind a consistent command line. It is a thin front end over the Ringee [MCP server](/mcp/overview)—the same tools an AI assistant would call, with the same guardrails. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} npm i -g ringee export RINGEE_MCP_URL="https://api.ringee.io/api/mcp/<userId>/sse" ringee config check ringee contacts search acme ``` <CardGroup> <Card title="Configure the connection" icon="gear" href="/cli/configuration"> Point the CLI at your workspace </Card> <Card title="Command reference" icon="terminal" href="/cli/commands"> Every command, argument and flag </Card> </CardGroup> ## Install <CodeGroup> ```bash Global theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} npm i -g ringee ringee --help ``` ```bash No install theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} npx ringee --help ``` ```bash From the repo theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm ringee contacts search acme pnpm --filter ringee dev -- config check ``` </CodeGroup> Requires **Node 20 or newer**. The published binary pulls in three dependencies — `@modelcontextprotocol/sdk`, `zod` and `commander`. ## What it is good for | Use case | Why the CLI | | ----------------- | --------------------------------------------------------- | | Quick lookups | `ringee contacts search acme` beats opening the dashboard | | Scripting | `--json` on any command gives machine-readable output | | AI agents | Any harness that can run shell commands can drive Ringee | | Debugging MCP | `ringee config check` lists every tool the server exposes | | CI and automation | No browser, no session cookie — just an env var | ## Command groups | Group | Purpose | | ----------------------------------- | ----------------------------------------------------------------- | | `contacts` | Search, view, create, update, delete; find by call outcome | | `leads` | Prospect via Apollo or Prospeo; reveal and import | | `sessions` | Magic-link dialing queues | | `calls` | Browse call history with outcomes, transcriptions and recordings | | `outcomes`, `callbacks`, `meetings` | Log results and book the next touch | | `campaigns` | Inspect campaign configuration, manage leads and read performance | | `analytics` | Dashboard metrics and one-day activity | | `dnc` | Manage the workspace Do Not Call suppression list | | `pipelines` | Read AI pipeline state and results | | `voice-agents` | List agents, start a real billed call and read its result | | `config` | Connection diagnostics and the shared agent knowledge | ## Safety Sensitive and destructive commands refuse to run without an explicit flag, so neither a typo nor an autonomous agent can spend credits, mint a shareable link or delete data by accident. ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee leads reveal <jobId> <externalId> --yes ringee sessions create --contact <id> --yes ringee sessions revoke <sessionId> --yes ringee contacts delete <id> --confirm-phone +14155550123 --yes ringee campaigns delete-lead <campaignId> <leadId> --yes ringee dnc remove +14155550123 --yes ringee voice-agents call <agentId> --to +14155550123 --yes ``` See [Safety and confirmations](/mcp/safety). ## Output modes By default the CLI prints readable, formatted output. Add `--json` to any command for the raw payload: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee contacts search acme --json | jq '.contacts[].phoneNumber' ``` ## Next steps <CardGroup> <Card title="Configuration" icon="gear" href="/cli/configuration"> Environment variables and diagnostics </Card> <Card title="Use it from an agent" icon="robot" href="/cli/agents"> Drive Ringee from Claude Code or any shell-capable harness </Card> <Card title="AI Voice Agents" icon="phone" href="/voice-agents/cli"> Place and monitor autonomous calls from the CLI </Card> </CardGroup> # Clerk Configuration Source: https://docs.ringee.io/configuration/clerk Set up Clerk for authentication Ringee uses [Clerk](https://clerk.com) for authentication and multi-tenant team management. Clerk handles user sign-up, sign-in, organization management, and role-based access control. ## Setup <Steps> <Step title="Create a Clerk application"> 1. Go to [clerk.com](https://clerk.com) and create an account 2. Create a new application 3. Select the authentication methods you want to support (e.g., Email, Google, GitHub) </Step> <Step title="Get your API keys"> In the Clerk dashboard, go to **API Keys** and copy: * **Publishable key** (starts with `pk_`) * **Secret key** (starts with `sk_`) </Step> <Step title="Set up webhooks"> Ringee needs Clerk webhooks to sync user and organization events: 1. Go to **Webhooks** in the Clerk dashboard 2. Click **Add Endpoint** 3. Set the endpoint URL to: `https://your-backend-url/api/clerk/webhook` 4. Select the following events: * `user.created` * `user.updated` * `user.deleted` * `organization.created` * `organization.updated` * `organizationMembership.created` * `organizationMembership.deleted` 5. Copy the **Signing Secret** </Step> <Step title="Configure redirect URLs"> In the Clerk dashboard, go to **Paths** and configure: * **Sign-in URL**: `/auth/sign-in` * **Sign-up URL**: `/auth/sign-up` * **After sign-in URL**: `/dashboard/overview` * **After sign-up URL**: `/dashboard/overview` </Step> <Step title="Configure environment variables"> Add the following to your `.env` file: ```env theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_your_publishable_key" CLERK_PUBLISHABLE_KEY="pk_test_your_publishable_key" CLERK_SECRET_KEY="sk_test_your_secret_key" CLERK_WEBHOOK_SIGNING_SECRET="whsec_your_webhook_secret" NEXT_PUBLIC_CLERK_SIGN_IN_URL="/auth/sign-in" NEXT_PUBLIC_CLERK_SIGN_UP_URL="/auth/sign-up" NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/dashboard/overview" NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/dashboard/overview" ``` </Step> </Steps> ## Environment Variables | Variable | Required | Description | | ------------------------------------- | -------- | ------------------------------------------------------- | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | Clerk publishable key (exposed to frontend) | | `CLERK_PUBLISHABLE_KEY` | Yes | Clerk publishable key (backend) | | `CLERK_SECRET_KEY` | Yes | Clerk secret key | | `CLERK_WEBHOOK_SIGNING_SECRET` | Yes | Webhook signing secret | | `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | No | Sign-in page path (default: `/auth/sign-in`) | | `NEXT_PUBLIC_CLERK_SIGN_UP_URL` | No | Sign-up page path (default: `/auth/sign-up`) | | `NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL` | No | Redirect after sign-in (default: `/dashboard/overview`) | | `NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL` | No | Redirect after sign-up (default: `/dashboard/overview`) | <Note> `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_PUBLISHABLE_KEY` can be set to the same value. The `NEXT_PUBLIC_` prefix makes it available to the Next.js frontend. </Note> # Docker Compose Configuration Source: https://docs.ringee.io/configuration/docker-compose Docker-specific configuration and overrides ## Overview When running Ringee with `docker-compose.app.yml`, several environment variables are **automatically overridden** to use Docker's internal networking. You don't need to manually set these. ## Automatic Overrides The following variables are set automatically by Docker Compose: | Variable | Docker Value | | -------------- | -------------------------------------------------------------------------------------- | | `DATABASE_URL` | `postgresql://ringee-user:ringee-password@postgres:5432/ringee-db-local?schema=public` | | `REDIS_HOST` | `redis` | | `REDIS_PORT` | `6379` | | `REDIS_URL` | `redis://redis:6379` | <Note> The hostnames `postgres` and `redis` refer to the Docker internal service names. These are resolved automatically by Docker's internal DNS. </Note> ## Volumes The default Docker Compose configuration creates the following **persistent volumes**: | Volume | Purpose | | --------------- | ------------------------- | | `postgres-data` | PostgreSQL database files | | `redis-data` | Redis persistence data | ## Ports Default port mapping: | Service | Host Port | Container Port | | ----------- | --------- | -------------- | | Frontend | `4200` | `4200` | | Backend API | `3000` | `3000` | | PostgreSQL | `5432` | `5432` | | Redis | `6379` | `6379` | | Temporal | `7233` | `7233` | | Temporal UI | `8080` | `8080` | ## Customizing ports To change the host ports, modify the `ports` section in `docker-compose.app.yml`: ```yaml theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} services: frontend: ports: - "8080:4200" # Access admin on port 8080 instead ``` <Warning> If you change the frontend or backend ports, remember to update the corresponding `FRONTEND_URL`, `PUBLIC_BACKEND_URL`, and `NEXT_PUBLIC_API_BASE_URL` environment variables accordingly. </Warning> ## Rebuilding After making changes to the codebase or environment: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} # Rebuild and restart all containers docker-compose -f docker-compose.app.yml up --build -d # Rebuild only a specific service docker-compose -f docker-compose.app.yml up --build -d backend ``` # Email Configuration Source: https://docs.ringee.io/configuration/email Set up transactional email with Resend Ringee uses [Resend](https://resend.com) for sending transactional emails such as notifications and team invitations. ## Setup <Steps> <Step title="Create a Resend account"> Go to [resend.com](https://resend.com) and create an account. </Step> <Step title="Add and verify your domain"> In the Resend dashboard, navigate to **Domains** and add your domain. Follow the instructions to verify it by adding the required DNS records. </Step> <Step title="Get your API key"> Go to **API Keys** in the Resend dashboard and create a new key. </Step> <Step title="Configure environment variables"> Add the following to your `.env` file: ```env theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} RESEND_API_KEY="re_your_api_key_here" EMAIL_FROM_ADDRESS="no-reply@yourdomain.com" EMAIL_FROM_NAME="Ringee" ``` </Step> </Steps> ## Environment Variables | Variable | Required | Description | | -------------------- | -------- | --------------------------------------------------------- | | `RESEND_API_KEY` | Yes | Your Resend API key | | `EMAIL_FROM_ADDRESS` | Yes | The sender email address (must be from a verified domain) | | `EMAIL_FROM_NAME` | Yes | The sender display name shown in emails | <Warning> The `EMAIL_FROM_ADDRESS` must use a domain that you've verified in Resend. Using an unverified domain will cause email delivery failures. </Warning> # R2 Configuration Source: https://docs.ringee.io/configuration/r2 Set up Cloudflare R2 for file storage Ringee uses [Cloudflare R2](https://developers.cloudflare.com/r2/) for storing files such as call recordings, contact imports, and other media. ## Setup <Steps> <Step title="Create a Cloudflare account"> If you don't have one already, create an account at [cloudflare.com](https://dash.cloudflare.com/sign-up). </Step> <Step title="Create an R2 bucket"> 1. Navigate to **R2 Object Storage** in the Cloudflare dashboard 2. Click **Create bucket** 3. Give it a name (e.g., `ringee-storage`) 4. Select a location (or leave as automatic) </Step> <Step title="Generate API credentials"> 1. Go to **R2** → **Manage R2 API Tokens** 2. Click **Create API token** 3. Grant **Object Read & Write** permissions 4. Copy the **Access Key ID** and **Secret Access Key** </Step> <Step title="Set up public access (optional)"> If you need public URLs for stored files: 1. Go to your bucket → **Settings** → **Public Access** 2. Enable public access and note the public bucket URL </Step> <Step title="Configure environment variables"> Add the following to your `.env` file: ```env theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} CLOUDFLARE_BUCKETNAME="ringee-storage" CLOUDFLARE_ACCOUNT_ID="your_account_id" CLOUDFLARE_ACCESS_KEY="your_access_key" CLOUDFLARE_SECRET_ACCESS_KEY="your_secret_access_key" CLOUDFLARE_BUCKET_URL="https://your-bucket-url.r2.dev" CLOUDFLARE_REGION="auto" STORAGE_PROVIDER="cloudflare" ``` </Step> </Steps> ## Environment Variables | Variable | Required | Description | | ------------------------------ | -------- | ---------------------------------------- | | `CLOUDFLARE_BUCKETNAME` | Yes | Name of your R2 bucket | | `CLOUDFLARE_ACCOUNT_ID` | Yes | Your Cloudflare account ID | | `CLOUDFLARE_ACCESS_KEY` | Yes | R2 API access key ID | | `CLOUDFLARE_SECRET_ACCESS_KEY` | Yes | R2 API secret access key | | `CLOUDFLARE_BUCKET_URL` | Yes | Public URL for the bucket | | `CLOUDFLARE_REGION` | No | Region (default: `auto`) | | `STORAGE_PROVIDER` | No | Storage provider (default: `cloudflare`) | <Tip> Your **Cloudflare Account ID** can be found in the Cloudflare dashboard URL or under **Account Home** → **Overview** on the right sidebar. </Tip> # Configuration Reference Source: https://docs.ringee.io/configuration/reference Environment variables reference for Ringee Ringee is configured entirely through environment variables. Any change to environment variables requires an **application restart**. The full example file can be found at: [`.env.example` on GitHub](https://github.com/ringee-io/ringee-app/blob/main/.env.example) ## Required Settings ### Application #### `DATABASE_URL` PostgreSQL connection string. Ringee uses Prisma ORM. ``` postgresql://ringee-user:ringee-password@localhost:5432/ringee-db-local?schema=public ``` #### `REDIS_URL` ``` redis://localhost:6379 ``` #### `REDIS_HOST` ``` localhost ``` #### `REDIS_PORT` ``` 6379 ``` #### `FRONTEND_URL` URL where the admin frontend is served. ``` http://localhost:4200 ``` #### `BACKEND_URL` / `PUBLIC_BACKEND_URL` URL where the backend API is served. In production, set both variables to the same public HTTPS origin without an `/api` suffix. Ringee uses it to build provider callback URLs for AI Voice Agents and desk phones. ``` http://localhost:3000 ``` #### `APP_ENCRYPTION_SECRET` A random string used for encrypting sensitive data at the application level. Should be unique per installation. *** ### Background jobs (Temporal) The orchestrator (`apps/orchestrator`) runs durable workflows and schedules. Both the backend and the orchestrator read these. | Variable | Description | | --------------------- | --------------------------------------------------- | | `TEMPORAL_ADDRESS` | Temporal server address (default: `localhost:7233`) | | `TEMPORAL_NAMESPACE` | Temporal namespace (default: `default`) | | `TEMPORAL_TASK_QUEUE` | Task queue name (default: `ringee`) | *** ### Authentication (Clerk) See the [Clerk Configuration](/configuration/clerk) guide for setup details. | Variable | Description | | ------------------------------------- | ------------------------------------------------------- | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk publishable key | | `CLERK_PUBLISHABLE_KEY` | Clerk publishable key (backend) | | `CLERK_SECRET_KEY` | Clerk secret key | | `CLERK_WEBHOOK_SIGNING_SECRET` | Webhook signing secret | | `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | Sign-in page URL (default: `/auth/sign-in`) | | `NEXT_PUBLIC_CLERK_SIGN_UP_URL` | Sign-up page URL (default: `/auth/sign-up`) | | `NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL` | Redirect after sign-in (default: `/dashboard/overview`) | | `NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL` | Redirect after sign-up (default: `/dashboard/overview`) | *** ### Telephony (Telnyx) See the [Telnyx Configuration](/providers/telnyx) guide for setup details. | Variable | Description | | ---------------------------------- | ------------------------------------------------------------------------------------------ | | `TELNYX_API_KEY` | Your Telnyx API key | | `TELNYX_CONNECTION_ID` | Telnyx WebRTC connection ID | | `TELNYX_CALL_CONTROL_APP_ID` | Call Control Application for server-originated calls; falls back to `TELNYX_CONNECTION_ID` | | `TELNYX_OUTBOUND_VOICE_PROFILE_ID` | Outbound Voice Profile used by AI Voice Agents and desk phones | | `TELNYX_PUBLIC_KEY` | Public key used to verify Telnyx webhooks | | `TELNYX_SIP_USER` | SIP username for WebRTC | | `TELNYX_SIP_PASSWORD` | SIP password for WebRTC | | `NEXT_PUBLIC_TELNYX_LOGIN` | SIP login exposed to frontend | | `NEXT_PUBLIC_TELNYX_PASSWORD` | SIP password exposed to frontend | `TELNYX_CALL_CONTROL_APP_ID` may use its connection-id fallback when both ids point to the same Telnyx resource. `TELNYX_OUTBOUND_VOICE_PROFILE_ID` is recommended for explicit AI Voice Agent routing and billing, and is required when Desk Phones are enabled; without it, AI Voice Agents retain the provider's default outbound route. *** ### Payments (Stripe) See the [Stripe Configuration](/configuration/stripe) guide for setup details. | Variable | Description | | ----------------------- | ----------------------------- | | `STRIPE_SECRET_KEY` | Your Stripe secret key | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | *** ### Email (Resend) See the [Email Configuration](/configuration/email) guide for details. | Variable | Description | | -------------------- | -------------------- | | `RESEND_API_KEY` | Your Resend API key | | `EMAIL_FROM_ADDRESS` | Sender email address | | `EMAIL_FROM_NAME` | Sender display name | *** ### Storage (Cloudflare R2) See the [R2 Configuration](/configuration/r2) guide for details. | Variable | Description | | ------------------------------ | ---------------------------------------- | | `CLOUDFLARE_BUCKETNAME` | R2 bucket name | | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID | | `CLOUDFLARE_ACCESS_KEY` | R2 access key | | `CLOUDFLARE_SECRET_ACCESS_KEY` | R2 secret access key | | `CLOUDFLARE_BUCKET_URL` | Public bucket URL | | `CLOUDFLARE_REGION` | Region (default: `auto`) | | `STORAGE_PROVIDER` | Storage provider (default: `cloudflare`) | *** ## Optional Settings ### Frontend | Variable | Description | | -------------------------- | ---------------------------------------------- | | `NEXT_PUBLIC_API_BASE_URL` | Backend API URL for the frontend | | `NEXT_PUBLIC_SITE_URL` | Public site URL | | `NEXT_PUBLIC_APP_ENV` | App environment (`development` / `production`) | ### Sentry (Error Tracking) | Variable | Description | | ----------------------------- | --------------------------------- | | `NEXT_PUBLIC_SENTRY_DSN` | Sentry DSN | | `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization slug | | `NEXT_PUBLIC_SENTRY_PROJECT` | Sentry project slug | | `SENTRY_AUTH_TOKEN` | Sentry auth token | | `NEXT_PUBLIC_SENTRY_DISABLED` | Disable Sentry (`true` / `false`) | ### AI | Variable | Description | | ---------------- | ------------------------------------- | | `OPENAI_API_KEY` | OpenAI API key (used for AI features) | ### AI Voice Agents AI Voice Agents use the normal PostgreSQL, Redis, Temporal, Telnyx and storage settings in addition to these controls. See [Self-host AI Voice Agents](/voice-agents/self-hosting) for the deployment flow. | Variable | Default | Description | | ----------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------- | | `AI_VOICE_AGENT_PROFIT_MARGIN` | `1` | Multiplier applied to the provider's AI conversation cost before debiting Ringee credits; minimum 1 | | `AI_VOICE_AGENT_RINGEE_MODEL` | `moonshotai/Kimi-K2.6` | Concrete model behind the Ringee AI choice | | `AI_VOICE_AGENT_OPENAI_MODEL` | `openai/gpt-5.2` | Concrete model behind the OpenAI BYOK choice | | `AI_VOICE_AGENT_ANTHROPIC_MODEL` | `anthropic/claude-haiku-4-5` | Concrete model behind the Anthropic BYOK choice | | `AI_VOICE_AGENT_GOOGLE_MODEL` | `google/gemini-2.5-flash` | Concrete model behind the Google BYOK choice | | `AI_VOICE_AGENT_MAX_CALL_SECONDS` | `900` | Hard limit for one agent call; minimum 30 seconds | | `AI_VOICE_AGENT_TEST_SESSION_TTL_SECONDS` | `600` | Browser test-session lifetime; minimum 60 seconds | | `AI_VOICE_AGENT_STORAGE_REGION` | `us-central-1` | Provider object-storage region for agent knowledge bases | | `AI_VOICE_AGENT_MAX_DOCUMENT_MB` | `20` | Maximum uploaded knowledge-document size in megabytes | <Note> OpenAI, Anthropic and Google keys selected in the product are per-agent BYOK credentials. Ringee verifies each key, stores it in the voice provider's secret store and persists only the provider reference; they are not global environment variables. </Note> ### MCP and AI assistants Only needed for the authenticated ChatGPT connector. The public MCP endpoints work with no extra configuration — see [Connect a client](/mcp/connect). | Variable | Description | | --------------------------------- | ---------------------------------------------------------------------------------------- | | `MCP_OAUTH_AUTHORIZATION_SERVERS` | OAuth authorization server(s) advertised to ChatGPT — your Clerk issuer. Comma-separated | | `MCP_OAUTH_SCOPES` | Scopes advertised (default: `ringee:use`) | | `OPENAI_APPS_CHALLENGE_TOKEN` | Domain-verification token served at `/.well-known/openai-apps-challenge` | # Stripe Configuration Source: https://docs.ringee.io/configuration/stripe Set up Stripe for billing and payments Ringee uses [Stripe](https://stripe.com) for managing credits, subscriptions, and phone number purchases. ## Setup <Steps> <Step title="Create a Stripe account"> Go to [stripe.com](https://stripe.com) and create an account if you don't have one. </Step> <Step title="Get your API keys"> 1. In the Stripe dashboard, go to **Developers** → **API keys** 2. Copy your **Secret key** <Warning> For development, use **test mode** keys (prefix `sk_test_`). Switch to live keys only for production. </Warning> </Step> <Step title="Set up webhooks"> Ringee needs Stripe webhooks to process payment events: 1. Go to **Developers** → **Webhooks** 2. Click **Add endpoint** 3. Set the endpoint URL to: `https://your-backend-url/api/stripe/webhook` 4. Select the events to listen to: * `checkout.session.completed` * `customer.subscription.created` * `customer.subscription.updated` * `customer.subscription.deleted` * `invoice.payment_succeeded` * `invoice.payment_failed` 5. Copy the **Signing secret** </Step> <Step title="Configure environment variables"> Add the following to your `.env` file: ```env theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} STRIPE_SECRET_KEY="sk_test_your_secret_key" STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret" ``` </Step> </Steps> ## Environment Variables | Variable | Required | Description | | ----------------------- | -------- | ------------------------------------------- | | `STRIPE_SECRET_KEY` | Yes | Your Stripe secret API key | | `STRIPE_WEBHOOK_SECRET` | Yes | Webhook signing secret for verifying events | ## Testing locally For local development, use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to forward webhook events: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} stripe listen --forward-to localhost:3000/api/stripe/webhook ``` This will output a webhook signing secret that you can use as `STRIPE_WEBHOOK_SECRET` during development. <Tip> The Stripe CLI is the easiest way to test webhooks locally. Install it with `brew install stripe/stripe-cli/stripe` on macOS. </Tip> # Inline bar Source: https://docs.ringee.io/dialer-sdk/bar Render the dialer inside an element of your own layout Bar renders the dialer inside a container you provide. It adapts to the available width and creates no floating launcher — use it when your layout already has a dedicated place for calling, like a toolbar, a sidebar, or a contact record panel. ## CDN ```html theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <div id="ringee-bar"></div> <script src="https://unpkg.com/@ringee/dialer-sdk"></script> <script> const ringeeBar = Ringee.createBar({ key: "pk_live_xxxxx", container: "#ringee-bar", locale: "en", }); ringeeBar.setContact({ name: "Avery Stone", number: "+14155550142", externalContactId: "contact-802", }); </script> ``` ## npm ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { createBar } from "@ringee/dialer-sdk/ui"; const ringeeBar = createBar({ key: "pk_live_xxxxx", container: document.getElementById("ringee-bar")!, agentEmail: currentUser.email, locale: "en", }); ``` ## The container `container` is **required** and accepts: | Form | Example | | -------------- | --------------------------------------- | | An element | `document.getElementById("ringee-bar")` | | An id | `"ringee-bar"` | | A CSS selector | `"#sidebar .dialer"` | <Warning> The element must exist when `createBar()` runs — the SDK throws otherwise. In a framework, create the controller after the container has rendered. </Warning> Bar accepts every [common option](/dialer-sdk/reference#common-ui-options). The Floating-only options (`side`, `defaultOpen`, `rememberOpen`) do not apply. ## Controller methods | Method | Description | | --------------------- | -------------------------------------- | | `setContact(contact)` | Attach the contact currently on screen | | `prefill(number)` | Prefill just the destination | | `setTheme(theme)` | Update the theme at runtime | | `on(event, handler)` | Subscribe to a headless event | | `destroy()` | Remove the UI and its listeners | | `dialer` | The underlying headless instance | Bar has no `open()`, `close()`, `toggle()` or `startCall()` — it is always visible, and calls start from the rendered controls. To place a call programmatically, go through the headless instance: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} await ringeeBar.dialer.call({ to: "+13055550142" }); ``` ## Follow the selected record ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} function onContactSelected(contact) { ringeeBar.setContact({ name: contact.fullName, number: contact.phone, imageUrl: contact.avatarUrl, externalContactId: contact.id, }); } ``` See [CRM contacts](/dialer-sdk/contacts) for how `contactId` and `externalContactId` differ. ## Layout notes The bar sizes itself to the container. Make sure your layout does not collapse it: ```css theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} #ringee-bar { width: 100%; min-width: 320px; } ``` If the bar does not appear, a zero-width or zero-height container is the usual cause. See [Troubleshooting](/dialer-sdk/troubleshooting). ## Clean up ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringeeBar.destroy(); await ringeeBar.dialer.destroy(); ``` ## Sharing one engine between surfaces If you render both a Floating panel and a Bar, pass the same headless instance so they share one session and one active call: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { RingeeDialer } from "@ringee/dialer-sdk"; import { createBar, createFloating } from "@ringee/dialer-sdk/ui"; const dialer = new RingeeDialer({ key: "pk_live_xxxxx" }); const bar = createBar({ key: "pk_live_xxxxx", container: "#ringee-bar", dialer }); const floating = createFloating({ key: "pk_live_xxxxx", dialer }); ``` Destroy the shared dialer only after every consumer is done with it. ## Next steps <CardGroup> <Card title="Headless" icon="code" href="/dialer-sdk/headless"> Build the UI yourself </Card> <Card title="Theming" icon="palette" href="/dialer-sdk/theming"> Blend the bar into your design system </Card> </CardGroup> # CRM contacts Source: https://docs.ringee.io/dialer-sdk/contacts Attach the record your app has on screen Tell the dialer who the agent is looking at. The bundled UIs show the name and avatar, prefill the destination, and Ringee attributes the call to the right contact. ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.setContact({ name: "Morgan Reed", number: "+13055550142", imageUrl: "https://crm.example.com/avatars/294.png", externalContactId: "crm-contact-294", }); ``` ## Fields | Field | Purpose | | ------------------- | ------------------------------ | | `name` | Visual label in the bundled UI | | `number` | Prefills the destination | | `imageUrl` | Avatar in the bundled UI | | `contactId` | Native Ringee contact UUID | | `externalContactId` | The contact's id in your CRM | ## `contactId` or `externalContactId`? You need one, not both. <CardGroup> <Card title="contactId" icon="fingerprint"> Use it when you already know the internal Ringee UUID — for example because your app reads Ringee data directly. </Card> <Card title="externalContactId" icon="link"> Use it when your [Custom Integration](/api/overview) maps a CRM id to a Ringee contact. </Card> </CardGroup> `externalContactId` is the same value you send as `data.externalId` in [`contact.upserted`](/api/inbound-events#contact-upserted). Sync contacts through the Public API first and Ringee resolves the link automatically. <Tip> Keep `externalContactId` consistent so Ringee resolves the correct contact. When you process [`call.completed`](/api/events-reference#call-completed), do not rely on a contact reference alone: keep a normalized phone-number fallback for activity matching. </Tip> ## Prefill just a number When there is no contact record — a manual dial, an inbound lookup: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.prefill("+13055550142"); ``` ## Start a call directly Floating can skip straight to dialing: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.startCall({ to: "+13055550142", name: "Morgan Reed", externalContactId: "crm-contact-294", }); ``` In Headless: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} await dialer.call({ to: "+13055550142", callerIdId: selectedCallerId, externalContactId: "crm-contact-294", }); ``` ## Phone number format Destinations must be **E.164** — a `+`, the country code, then the number, with no extension: ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} +13055550198 +34911234567 +18095550123 ``` Anything else fails with `INVALID_PHONE_NUMBER`. Normalize in your CRM before passing it in; the SDK validates locally before it reaches the network. ## Keeping it in sync Call `setContact()` whenever the selected record changes. It is cheap and idempotent: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} function onRecordOpened(contact) { ringee.setContact({ name: contact.fullName, number: contact.phone, imageUrl: contact.avatarUrl, externalContactId: contact.id, }); } ``` To detach the current record — the user closed the panel, navigated away, deselected — pass `null`: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.setContact(null); ``` ## After the call The SDK emits `ended` with a call snapshot in the browser, but the durable record of what happened arrives through your [outbound webhooks](/api/outbound-webhooks): | Event | Carries | | ---------------------- | ------------------------------------------- | | `call.completed` | Direction, status, timing, duration | | `call.outcome.updated` | The outcome the agent logged, plus the note | | `recording.ready` | Recording URL and transcript | Write your CRM activity from the webhooks — they are signed, retried and idempotent. Use the browser events for live UI only. ## Next steps <CardGroup> <Card title="Sync contacts to Ringee" icon="arrows-rotate" href="/api/inbound-events"> Establish the `externalId` link </Card> <Card title="Receive call activity" icon="webhook" href="/api/outbound-webhooks"> Close the loop back into your CRM </Card> </CardGroup> # Errors Source: https://docs.ringee.io/dialer-sdk/errors Every RingeeError code and how to react to it Rejected promises throw a `RingeeError` carrying a stable `code`, a human-readable `message`, and a `retryable` flag. ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { RingeeError } from "@ringee/dialer-sdk"; try { await dialer.call({ to: "+13055550198" }); } catch (error) { if (error instanceof RingeeError) { console.log(error.code); console.log(error.message); console.log(error.retryable); } } ``` The bundled UIs already translate these codes into actionable messages. In [Headless](/dialer-sdk/headless), that mapping is yours. ## Installation Something is wrong with the key or the origin. These are configuration problems, not runtime ones — fail loudly during development. | Code | Meaning | Fix | | ------------------------- | --------------------------------------------------------------------- | ------------------------------------------- | | `INVALID_PUBLISHABLE_KEY` | Malformed key, deleted integration, or the secret API key was rotated | Generate a new publishable key | | `DOMAIN_NOT_ALLOWED` | `window.location.origin` is not in the key's allowed origins | Add the exact origin and generate a new key | | `INTEGRATION_DISABLED` | The Custom Integration is not active | Re-enable it in the dashboard | See [Publishable keys](/dialer-sdk/publishable-keys). ## Authentication | Code | Meaning | Fix | | ------------------------------ | ------------------------------------- | --------------------------------------- | | `INVALID_EMAIL` | The address is not well-formed | Validate before submitting | | `INVALID_EMAIL_CODE` | Wrong code | Let the agent retry | | `EMAIL_CHALLENGE_EXPIRED` | The challenge timed out | Request a new code | | `EMAIL_CODE_ATTEMPTS_EXCEEDED` | Too many wrong attempts | Start a new challenge | | `AUTH_REQUIRED` | No authenticated agent | Run the OTP flow, then wait for `ready` | | `SESSION_EXPIRED` | The stored session is no longer valid | Re-authenticate | ## Permissions | Code | Meaning | | ------------------------ | -------------------------------------------------------- | | `AGENT_NOT_ALLOWED` | This agent may not use this installation | | `AGENT_NOT_IN_WORKSPACE` | The agent is not a member of the integration's workspace | | `USER_BLOCKED` | The account is blocked | | `CALLING_DISABLED` | Calling is disabled for this workspace or user | <Note> These are decided server-side, on every call. A publishable key never grants calling rights on its own. </Note> ## Calls | Code | Meaning | Fix | | ----------------------- | --------------------------------------------- | ---------------------------------------------- | | `INVALID_PHONE_NUMBER` | Not valid E.164 | Send `+`, country code, number, no extension | | `NO_CALLER_ID` | No caller ID available to this agent | Add or verify one in Ringee | | `CALLER_ID_NOT_ALLOWED` | The requested `callerIdId` is not permitted | Use one from `getCallerIds()` | | `INSUFFICIENT_CREDIT` | Not enough balance | Top up the workspace | | `DNC_BLOCKED` | The destination is on the Do Not Call list | Do not retry; disable the call button | | `CALL_ALREADY_ACTIVE` | A call exists in this instance or another tab | End it first | | `NO_ACTIVE_CALL` | A control method ran with no call in progress | Guard on `getActiveCall()` | | `CALL_FAILED` | The call could not be completed | Show the message; the reason varies by carrier | ## Browser and network | Code | Meaning | Fix | `retryable` | | -------------------------- | ----------------------------------- | -------------------------------------------------------------------- | ---------------- | | `MICROPHONE_DENIED` | Permission refused or unavailable | Serve over HTTPS, allow the mic, add `allow="microphone"` to iframes | <span>No</span> | | `NO_AUDIO_DEVICE` | No usable input device | Ask the agent to connect a microphone | <span>No</span> | | `AUDIO_PLAYBACK_BLOCKED` | Autoplay policy blocked audio | Start calls from a real click or tap | <span>No</span> | | `TELNYX_CONNECTION_FAILED` | The provider connection dropped | Back off and retry | <span>Yes</span> | | `NETWORK_ERROR` | Request failed in transit | Back off and retry | <span>Yes</span> | | `TIMEOUT` | The operation exceeded its deadline | Back off and retry | <span>Yes</span> | ## Retry policy `retryable` is `true` for known transient failures — rate limits, timeouts, network failures and provider connection failures. Every other code in this page reports <span>No</span>. <Warning> Never auto-retry permission, credit, Do Not Call or authentication errors. Retrying `INSUFFICIENT_CREDIT` or `DNC_BLOCKED` cannot succeed, and retrying auth errors can lock the agent out of the challenge. </Warning> ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} async function callWithRetry(input, attempts = 3) { for (let i = 0; i < attempts; i++) { try { return await dialer.call(input); } catch (error) { const last = i === attempts - 1; if (!(error instanceof RingeeError) || !error.retryable || last) throw error; await new Promise((r) => setTimeout(r, 2 ** i * 500)); } } } ``` ## Where errors surface | Channel | When | | ---------------- | --------------------------------------------------------------- | | Rejected promise | The method you awaited failed | | `failed` event | Authorization or the call itself failed, with `{ call, error }` | | `error` event | A typed general error occurred outside a call | | `onError` option | Convenience hook on the UI controllers | Subscribe **before** `initialize()` so errors raised during startup are not missed. ## Next steps <CardGroup> <Card title="Troubleshooting" icon="bug" href="/dialer-sdk/troubleshooting"> Symptom-first fixes for the common cases </Card> <Card title="Security" icon="shield-check" href="/dialer-sdk/security"> CSP, microphone and iframe requirements </Card> </CardGroup> # Floating Source: https://docs.ringee.io/dialer-sdk/floating A launcher in the page corner that opens a complete dialer panel Floating mounts a launcher in a corner of the page. The panel handles agent sign-in, caller ID selection, number entry and in-call controls — nothing else to build. ## CDN No build step required. ```html theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <script src="https://unpkg.com/@ringee/dialer-sdk"></script> <script> const ringee = Ringee.mount({ key: "pk_live_xxxxx", locale: "en", side: "right", }); </script> ``` `Ringee.mount(options)` is an alias for `Ringee.createFloating(options)`. Pin a version in production: ```html theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <script src="https://unpkg.com/@ringee/dialer-sdk@0.1.1/dist/ringee.global.js"></script> ``` ## npm ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { createFloating } from "@ringee/dialer-sdk/ui"; const ringee = createFloating({ key: "pk_live_xxxxx", agentEmail: currentUser.email, locale: "en", side: "right", defaultOpen: false, rememberOpen: true, allowHold: true, }); ``` The UI calls `initialize()` for you. `agentEmail` only prefills the email field — Ringee always requires the agent to prove access to that address via one-time code. ## Options Floating accepts every [common option](/dialer-sdk/reference#common-ui-options) plus: | Option | Type | Default | Description | | -------------- | ------------------- | --------------- | -------------------------------------- | | `side` | `"left" \| "right"` | `"right"` | Which side the launcher sits on | | `defaultOpen` | `boolean` | `false` | Open the panel on mount | | `rememberOpen` | `boolean` | `true` | Remember the open state within the tab | | `container` | `HTMLElement` | `document.body` | Shadow DOM parent | ## Control it from your app ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.open(); ringee.close(); ringee.toggle(); ringee.setContact({ name: "Morgan Reed", number: "+13055550142", externalContactId: "crm-contact-294", }); ringee.startCall({ to: "+13055550142", name: "Morgan Reed", externalContactId: "crm-contact-294", }); ``` <Note> `startCall()` is safe to call before session restoration finishes. The controller keeps the requested call and places it once the dialer reaches `ready`. If the agent is not authenticated, the OTP flow appears first. </Note> ## Wiring it to a contact record ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} // When the user opens a contact ringee.setContact({ name: contact.fullName, number: contact.phone, imageUrl: contact.avatarUrl, externalContactId: contact.id, }); // When they click "Call" callButton.addEventListener("click", () => { ringee.startCall({ to: contact.phone, externalContactId: contact.id }); }); ``` <Warning> Start calls from a real click or tap. Browsers block audio playback that was not triggered by a user gesture — auto-dialing on page load produces `AUDIO_PLAYBACK_BLOCKED`. </Warning> ## React to call state The controller forwards the headless events: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.on("answered", ({ call }) => startTimer(call.answeredAt)); ringee.on("ended", ({ call }) => logActivity(call)); ringee.on("failed", ({ error }) => toast(error.message)); ``` Full list: [Events](/dialer-sdk/reference#events). ## Clean up ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.destroy(); // removes the UI await ringee.dialer.destroy(); // releases WebRTC, audio and the call lock ``` <Warning> `destroy()` on the controller only unmounts the visual surface. Destroy the underlying dialer too, or WebRTC stays connected. </Warning> ## Next steps <CardGroup> <Card title="Theming" icon="palette" href="/dialer-sdk/theming"> Match your product's look </Card> <Card title="React and Next.js" icon="react" href="/dialer-sdk/react"> Mount and unmount safely in a component </Card> </CardGroup> # Headless Source: https://docs.ringee.io/dialer-sdk/headless Authentication, calls, devices and events with no UI at all Headless exposes the full engine and renders nothing. You build every screen and react to every state. Choose it only when you need that control — the bundled UIs already handle sign-in, caller ID selection, keypad, call controls and error copy. ## Create an instance ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { RingeeDialer, RingeeError } from "@ringee/dialer-sdk"; const dialer = new RingeeDialer({ key: "pk_live_xxxxx", debug: false, }); // Subscribe BEFORE initialize() so the first state changes are not missed. dialer.on("authStateChanged", ({ state }) => renderAuthState(state)); dialer.on("stateChanged", ({ state }) => renderCallState(state)); dialer.on("authRequired", () => showEmailForm()); dialer.on("ready", () => enableDialButton()); dialer.on("answered", ({ call }) => startTimer(call.answeredAt)); dialer.on("ended", ({ call }) => showSummary(call)); dialer.on("failed", ({ error }) => showError(error.code, error.message)); try { await dialer.initialize(); } catch (error) { if (error instanceof RingeeError) { showError(error.code, error.message); } } ``` `initialize()` may resolve authenticated or anonymous: * a valid session in `sessionStorage` is restored, emitting `signedIn` and `ready`; * otherwise it emits `authRequired` and waits for the OTP flow. ## Email one-time code ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const challenge = await dialer.requestEmailCode("agent@company.com"); const agent = await dialer.verifyEmailCode({ challengeId: challenge.id, code: "184279", }); console.log("Signed in as", agent.email); ``` Resend: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const nextChallenge = await dialer.resendEmailCode(challenge.id); ``` Use `challenge.resendAvailableAt` to gate the resend button and `challenge.expiresAt` to show a countdown. <Note> Ringee does not reveal whether an email exists. A code is "sent" for any well-formed address — do not build enumeration hints into your UI. </Note> ## Place and control a call ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const call = await dialer.call({ to: "+13055550198", callerIdId: selectedCallerId, externalContactId: "crm-contact-294", }); dialer.mute(); dialer.unmute(); await dialer.hold(); await dialer.resume(); dialer.sendDigits("123#"); await dialer.hangup(); ``` <Warning> Only one call can be active per instance or agent. The SDK uses Web Locks to prevent a second tab from starting one, and the server validates the restriction again. </Warning> ## Caller IDs ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const callerIds = dialer.getCallerIds(); ``` Returns the caller IDs the authenticated agent may use. Render them as a selector and pass the chosen `callerIdId` to `call()`. Omit it to let Ringee choose. ## Audio devices ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const inputs = await dialer.getInputDevices(); const outputs = await dialer.getOutputDevices(); await dialer.setInputDevice(inputs[0].id); await dialer.setOutputDevice(outputs[0].id); ``` Output selection depends on browser support. `deviceChanged` fires when the selection changes. ## Read current state ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} dialer.getState(); // DialerState dialer.getAuthState(); // AuthState dialer.getAgent(); // RingeeAgent | null dialer.getActiveCall(); // RingeeCall | null ``` Full lists: [call states](/dialer-sdk/reference#call-states) and [auth states](/dialer-sdk/reference#authentication-states). ## Unsubscribe Every `on()` returns an unsubscribe function: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const off = dialer.on("stateChanged", ({ state }) => console.log(state)); off(); ``` ## Sign out and clean up ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} await dialer.signOut(); // removes the persisted agent session await dialer.destroy(); // disconnects WebRTC and releases browser resources ``` <Warning> These are different operations. Destroying an instance does **not** sign the agent out; signing out does **not** release WebRTC. </Warning> ## Building a good headless UI <AccordionGroup> <Accordion title="Do not assume every state fires"> The exact sequence varies with the browser, the network and the remote destination. Render from the current state rather than from an expected transition order. </Accordion> <Accordion title="Translate error codes yourself"> The bundled UIs turn backend error codes into actionable messages. Headless gives you raw `RingeeError` codes — map them to copy your users understand. See [Errors](/dialer-sdk/errors). </Accordion> <Accordion title="Require a user gesture"> Start calls from a click. Browsers block audio playback that was not user-initiated. </Accordion> <Accordion title="Handle reconnecting"> `reconnecting` is a normal transient state on flaky networks. Show it, but do not tear the call down. </Accordion> </AccordionGroup> ## Next steps <CardGroup> <Card title="API reference" icon="book" href="/dialer-sdk/reference"> Every method, event and state </Card> <Card title="Errors" icon="triangle-exclamation" href="/dialer-sdk/errors"> Every error code and how to react </Card> </CardGroup> # Dialer SDK overview Source: https://docs.ringee.io/dialer-sdk/overview Embed a real Ringee dialer inside your own web app The **Ringee Dialer SDK** puts working outbound calling inside your CRM, back office or web app. Your agents never leave your product, and you never touch WebRTC, SIP credentials or a telephony provider's SDK. ```html theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <script src="https://unpkg.com/@ringee/dialer-sdk"></script> <script> Ringee.mount({ key: "pk_live_xxxxx" }); </script> ``` That is a complete integration: a floating launcher, agent sign-in, caller ID selection, keypad, and in-call controls. <CardGroup> <Card title="Quickstart" icon="rocket" href="/dialer-sdk/quickstart"> From zero to a working call in a few minutes </Card> <Card title="Publishable keys" icon="key" href="/dialer-sdk/publishable-keys"> Create a `pk_live_` key scoped to your origin </Card> </CardGroup> ## Three integration modes | Mode | Best for | Initialization | UI | | ------------------------------------ | ----------------------------------- | -------------- | ------------------------- | | [**Floating**](/dialer-sdk/floating) | Fastest path; any page | Automatic | Floating launcher + panel | | [**Bar**](/dialer-sdk/bar) | Toolbars, sidebars, contact records | Automatic | Inline dialer | | [**Headless**](/dialer-sdk/headless) | Fully custom design and state | Manual | None — you build it | Start with **Floating**. Choose **Bar** when your layout already has a place for calling. Choose **Headless** only when you need control over every screen, form, state and message. ## What you get * **No React dependency.** The bundled UIs render in an isolated Shadow DOM and work with plain JavaScript, TypeScript or any framework. * **English and Spanish**, plus per-label copy overrides. * **Themeable** via a theme object or CSS custom properties. * **No provider objects.** The SDK encapsulates WebRTC and Telnyx entirely — your app never sees a SIP password or a Telnyx JWT. * **Real Ringee calls.** Every call lands in call history, respects credits, caller ID rules and Do Not Call, and fires your [webhooks](/api/outbound-webhooks). ## How agents sign in A publishable key identifies an *installation*, not a person. Agent identity is proven separately with a **one-time code sent by email**: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef step fill:#15803D,color:white,stroke:#fff a[Validate pk + origin]:::step b[Agent enters email]:::step c[Ringee emails a code]:::step d[Agent verifies]:::step e[Ready to call]:::step a --> b --> c --> d --> e ``` That means it is safe to ship `pk_live_…` in your frontend. It is not safe to ship the `cik_live_…` Public API key — those are different credentials. See [Publishable keys](/dialer-sdk/publishable-keys). ## Requirements Before embedding: * an active Ringee **Custom Integration**; * a `pk_live_…` publishable key scoped to your exact origin; * a Ringee agent with access to that workspace; * at least one caller ID available to that agent or workspace; * enough credit and permission to place calls; * **HTTPS** in production — browsers require it for microphone access; * a modern browser with WebRTC and `navigator.mediaDevices`. Destination numbers must be **E.164**: `+13055550198`, `+34911234567`, `+18095550123`. <Warning> This is a **browser-only** package. Do not initialize it in Node.js, a React Server Component, an API route or any SSR process. </Warning> ## Not included in this release * There is no public Ringee URL you can drop straight into `<iframe src="…">`. * There is no automatic `data-ringee-key` loader attribute. * There is no separate React wrapper package — mount from `useEffect`, see [React and Next.js](/dialer-sdk/react). You *can* run the SDK inside an iframe your own app creates, but your code must load and control the package inside that document, and the iframe needs `allow="microphone"`, an allowed origin and storage access. There is no ready-made `postMessage` bridge yet. ## Dialer SDK or click-to-call? | | Dialer SDK | [Click-to-call](/api/click-to-call) | | ---------------------------- | --------------- | ----------------------------------- | | Where the call happens | Inside your app | A Ringee tab | | Backend required | No | Yes | | Custom UI | Yes | No | | Live call events in your app | Yes | No | | Setup effort | Low to medium | Very low | Many teams start with click-to-call and move to the SDK once they want the call to live in their own interface. ## Next steps <CardGroup> <Card title="Quickstart" icon="rocket" href="/dialer-sdk/quickstart"> Place your first call from your own page </Card> <Card title="API reference" icon="book" href="/dialer-sdk/reference"> Options, methods, events and states </Card> </CardGroup> # Publishable keys Source: https://docs.ringee.io/dialer-sdk/publishable-keys Create and scope the pk_live key that authorizes your installation A publishable key (`pk_live_…`) identifies a Dialer SDK **installation** and the origins allowed to load it. It is browser-safe by design — it embeds no secret and authenticates no agent. ## Create one from the dashboard Custom Integrations are available to organization admins and to personal accounts with administrative access. <Steps> <Step title="Open Custom Integrations"> Sign in to Ringee, open **Integrations** from the sidebar (`/dashboard/settings/integrations`), and select the **Custom Integrations** tab. </Step> <Step title="Create or open an integration"> Click **New custom integration**, name it something like `My CRM`, and click **Create**. If one already exists, open its card with **Configure**. </Step> <Step title="Add allowed origins"> Open **Settings**, find **Dialer SDK · Publishable keys**, and under **Allowed origins** enter the complete origin that will load the SDK — for example `https://crm.example.com`. Click **Add** and repeat for every environment. For the local playground, use the **localhost:5173 (playground)** shortcut. **This dashboard** adds the origin of the Ringee dashboard you have open — use it only if the SDK will run from that same origin, not for an external CRM. </Step> <Step title="Generate and copy the key"> Review the origin list, click **Generate publishable key**, and copy the `pk_live_…` value. Pass it to the SDK as `key`. </Step> </Steps> <Note> When an integration is created, Ringee also shows a `cik_live_…` API key and a webhook signing secret. Those belong to the [Public API](/api/overview) and are **not** Dialer SDK keys — never put them in frontend code. </Note> ## Origins are matched exactly An origin is scheme + host + optional port. Nothing else. ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} https://crm.example.com http://localhost:5173 ``` | Configured | Does **not** allow | | ------------------------- | ----------------------------- | | `https://crm.example.com` | `http://crm.example.com` | | `https://crm.example.com` | `https://app.crm.example.com` | | `http://localhost:5173` | `http://localhost:3000` | Paths, query strings, credentials, fragments and wildcards are rejected. Every scheme/host/port combination must be added explicitly. <Warning> Existing publishable keys are **not** edited when you change the origin list. Generate a new key after adding an origin. </Warning> ## Key types | Key | Used by | Safe in frontend? | | ------------ | --------------------------------- | ----------------- | | `pk_live_…` | Dialer SDK | **Yes** | | `cik_live_…` | [Public API](/api/authentication) | No | | `whsec_…` | Webhook signature verification | No | ## Lifecycle * The dashboard shows the generated value so you can copy it immediately. Store it in your project configuration. * Lost it, or need a different origin list? Generate a new one. * **Rotating the `cik_live_` API key revokes every publishable key** on that integration. * **Disabling the integration** invalidates them too. ## Create one programmatically For automation or self-hosting, an authenticated admin client can do the same thing: ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} POST /api/integrations/custom/<integrationId>/publishable-keys Authorization: Bearer <admin-session> Content-Type: application/json { "allowedOrigins": [ "https://crm.example.com", "http://localhost:5173" ] } ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "publishableKey": "pk_live_xxxxx", "integrationId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "apiKeyPrefix": "cik_live_a1b2c3d4", "allowedOrigins": ["https://crm.example.com", "http://localhost:5173"] } ``` `allowedOrigins` is required and must contain at least one origin. ## What the key does not do A publishable key does **not** identify or authenticate an agent. Ringee verifies agent identity with an email one-time code, then validates workspace membership, calling permissions, caller ID, credit and Do Not Call server-side on every call. That is why shipping `pk_live_…` in your bundle is safe. See [Security](/dialer-sdk/security). ## Next steps <CardGroup> <Card title="Quickstart" icon="rocket" href="/dialer-sdk/quickstart"> Use the key to place your first call </Card> <Card title="Troubleshooting" icon="bug" href="/dialer-sdk/troubleshooting"> `DOMAIN_NOT_ALLOWED` and friends </Card> </CardGroup> # Quickstart Source: https://docs.ringee.io/dialer-sdk/quickstart Place your first call from your own web app <Steps> <Step title="Create a publishable key"> In Ringee, open **Integrations → Custom Integrations**, open or create an integration, then under **Settings → Dialer SDK · Publishable keys**: 1. add your exact origin, for example `https://crm.example.com`; 2. click **Generate publishable key**; 3. copy the `pk_live_…` value. Full walkthrough: [Publishable keys](/dialer-sdk/publishable-keys). </Step> <Step title="Add the SDK to your page"> <CodeGroup> ```html CDN theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <script src="https://unpkg.com/@ringee/dialer-sdk"></script> <script> const ringee = Ringee.mount({ key: "pk_live_xxxxx", locale: "en", side: "right", }); </script> ``` ```bash npm theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} npm install @ringee/dialer-sdk ``` </CodeGroup> ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} import { createFloating } from "@ringee/dialer-sdk/ui"; const ringee = createFloating({ key: "pk_live_xxxxx", agentEmail: currentUser.email, // prefills the form only locale: "en", }); ``` `Ringee.mount(options)` is an alias for `Ringee.createFloating(options)`. </Step> <Step title="Sign the agent in"> Open the launcher. The agent enters their email, Ringee sends a one-time code, and they verify it. `agentEmail` only prefills the field — Ringee always requires proof of access to that mailbox. Once verified, the panel is ready to dial. </Step> <Step title="Place a call"> Either type a number in the keypad, or trigger it from your own UI: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.startCall({ to: "+13055550142", name: "Morgan Reed", externalContactId: "crm-contact-294", }); ``` `startCall()` can be called before initialization finishes — the SDK queues the request and places it once the dialer reaches `ready`. If the agent is not signed in, the OTP flow appears first. </Step> </Steps> ## Pin a version in production The unpinned CDN URL always serves the latest release. Pin it so a host deployment never picks up an unexpected version: ```html theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <script src="https://unpkg.com/@ringee/dialer-sdk@0.1.1/dist/ringee.global.js"></script> ``` ## Attach the current contact If your app already knows who is on screen, tell the SDK: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.setContact({ name: "Morgan Reed", number: "+13055550142", externalContactId: "crm-contact-294", }); ``` See [CRM contacts](/dialer-sdk/contacts). ## Point at a self-hosted backend `apiUrl` is the API origin **without** `/api`: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} createFloating({ key: "pk_live_xxxxx", apiUrl: "https://ringee-api.example.com", }); ``` For local development: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} createFloating({ key: "pk_live_xxxxx", apiUrl: "http://localhost:3000", debug: true, }); ``` <Warning> `apiUrl` and your frontend origin are different values. The **frontend** origin — for example `http://localhost:4200` — is what must be in the publishable key's allowed origins. </Warning> ## Try the playgrounds The repository ships three: | Path | What it does | | -------------------------------------- | ----------------------------------------------------------------------------------------- | | `apps/sdk-playground/live` | Real Floating, Bar and Headless modes against a real backend — OTP, WebRTC and real calls | | `apps/sdk-playground/vanilla-headless` | Minimal framework-free headless example | | `apps/sdk-playground/ui-gallery` | Visual states driven by a simulated dialer, no network or WebRTC | ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} node apps/sdk-playground/live/build.mjs --serve # http://localhost:5173 ``` Add `http://localhost:5173` to your allowed origins first, and generate the publishable key after that. ## Next steps <CardGroup> <Card title="Floating" icon="circle-dot" href="/dialer-sdk/floating"> Launcher and panel </Card> <Card title="Bar" icon="minus" href="/dialer-sdk/bar"> Inline dialer </Card> <Card title="Headless" icon="code" href="/dialer-sdk/headless"> Build your own UI </Card> </CardGroup> # React and Next.js Source: https://docs.ringee.io/dialer-sdk/react Mount the browser SDK safely in a component tree There is no separate React wrapper package. Mount the SDK after the DOM exists and destroy it when the component unmounts. <Warning> The SDK is browser-only. In Next.js, use a **dynamic import inside `useEffect`** so the package never runs during SSR or in a Server Component. </Warning> ## Floating ```tsx theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} "use client"; import { useEffect, useRef } from "react"; import type { FloatingController } from "@ringee/dialer-sdk/ui"; export function RingeeDialer({ email }: { email: string }) { const controller = useRef<FloatingController | null>(null); useEffect(() => { let cancelled = false; void import("@ringee/dialer-sdk/ui").then(({ createFloating }) => { if (cancelled) return; controller.current = createFloating({ key: process.env.NEXT_PUBLIC_RINGEE_KEY!, agentEmail: email, locale: "en", }); }); return () => { cancelled = true; const current = controller.current; controller.current = null; if (!current) return; current.destroy(); void current.dialer.destroy(); }; }, [email]); return null; } ``` The `cancelled` flag matters: React 18 Strict Mode runs effects twice in development, and the dynamic import can resolve after the first cleanup. ## Bar Render the container first, then create the controller. ```tsx theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} "use client"; import { useEffect, useRef } from "react"; export function RingeeBar() { const container = useRef<HTMLDivElement>(null); useEffect(() => { if (!container.current) return; let mounted = true; let cleanup: (() => void) | undefined; void import("@ringee/dialer-sdk/ui").then(({ createBar }) => { if (!mounted || !container.current) return; const bar = createBar({ key: process.env.NEXT_PUBLIC_RINGEE_KEY!, container: container.current, }); cleanup = () => { bar.destroy(); void bar.dialer.destroy(); }; }); return () => { mounted = false; cleanup?.(); }; }, []); return <div ref={container} />; } ``` ## Expose the controller to the rest of your app Keep one instance in context so any component can start a call. ```tsx theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} "use client"; import { createContext, useContext, useEffect, useRef, useState } from "react"; import type { FloatingController } from "@ringee/dialer-sdk/ui"; const RingeeContext = createContext<FloatingController | null>(null); export function RingeeProvider({ email, children, }: { email: string; children: React.ReactNode; }) { const [controller, setController] = useState<FloatingController | null>(null); const ref = useRef<FloatingController | null>(null); useEffect(() => { let cancelled = false; void import("@ringee/dialer-sdk/ui").then(({ createFloating }) => { if (cancelled) return; const instance = createFloating({ key: process.env.NEXT_PUBLIC_RINGEE_KEY!, agentEmail: email, }); ref.current = instance; setController(instance); }); return () => { cancelled = true; const current = ref.current; ref.current = null; setController(null); if (!current) return; current.destroy(); void current.dialer.destroy(); }; }, [email]); return ( <RingeeContext.Provider value={controller}>{children}</RingeeContext.Provider> ); } export const useRingee = () => useContext(RingeeContext); ``` Then anywhere: ```tsx theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} function CallButton({ contact }) { const ringee = useRingee(); return ( <button disabled={!ringee} onClick={() => ringee?.startCall({ to: contact.phone, name: contact.fullName, externalContactId: contact.id, }) } > Call </button> ); } ``` `startCall()` is safe before the dialer is ready — it queues the request and places it once initialization finishes. ## Keep the contact in sync ```tsx theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} useEffect(() => { if (!ringee || !contact) return; ringee.setContact({ name: contact.fullName, number: contact.phone, imageUrl: contact.avatarUrl, externalContactId: contact.id, }); }, [ringee, contact]); ``` ## Environment variables The publishable key is browser-safe, so a public env var is correct: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} NEXT_PUBLIC_RINGEE_KEY="pk_live_xxxxx" ``` <Warning> Never put a `cik_live_…` API key or a webhook signing secret in a `NEXT_PUBLIC_` variable. Those are server-only — see [Public API authentication](/api/authentication). </Warning> ## Gotchas | Symptom | Cause | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `window is not defined` at build time | The SDK was imported at module scope. Move it inside `useEffect`. | | Two launchers appear in dev | Strict Mode double-invoked the effect. Guard with a `cancelled` flag and destroy on cleanup. | | UI disappears but audio continues | You called `controller.destroy()` without `dialer.destroy()`. | | OTP after every reload | `sessionStorage` is blocked, or the component remounts across tabs. See [Troubleshooting](/dialer-sdk/troubleshooting). | ## Next steps <CardGroup> <Card title="CRM contacts" icon="address-book" href="/dialer-sdk/contacts"> Attach the record on screen </Card> <Card title="Theming" icon="palette" href="/dialer-sdk/theming"> Match your design system </Card> </CardGroup> # API reference Source: https://docs.ringee.io/dialer-sdk/reference Options, controllers, methods, states and events ## Entry points | Import | Exports | | ----------------------- | ----------------------------------------------- | | `@ringee/dialer-sdk` | `RingeeDialer`, `RingeeError`, types | | `@ringee/dialer-sdk/ui` | `createFloating`, `createBar`, controller types | | CDN global `Ringee` | `mount`, `createFloating`, `createBar` | ## Common UI options Accepted by both `createFloating` and `createBar`. | Option | Type | Default | Description | | --------------- | ------------------ | ----------------------- | ----------------------------------------------- | | `key` | `string` | **required** | `pk_live_…` publishable key | | `apiUrl` | `string` | `https://api.ringee.io` | API base URL, **without** `/api` | | `agentEmail` | `string` | — | Prefills the email field; does not authenticate | | `debug` | `boolean` | `false` | Verbose engine logs | | `dialer` | `RingeeDialer` | — | Reuse an existing headless instance | | `theme` | `RingeeTheme` | Ringee theme | Override visual tokens | | `locale` | `string` | `"en"` | `en` or `es` | | `strings` | `Partial<Strings>` | — | Override individual labels | | `allowHold` | `boolean` | `false` | Show Hold and Resume controls | | `workspaceName` | `string` | — | Workspace label in the footer | | `onError` | `(error) => void` | — | Receives typed errors the UI also renders | ### Floating only | Option | Type | Default | Description | | -------------- | ------------------- | --------------- | ---------------------------------- | | `side` | `"left" \| "right"` | `"right"` | Launcher side | | `defaultOpen` | `boolean` | `false` | Open the panel on mount | | `rememberOpen` | `boolean` | `true` | Remember open state within the tab | | `container` | `HTMLElement` | `document.body` | Shadow DOM parent | ### Bar only | Option | Type | Description | | ----------- | ----------------------- | ------------------------------------------ | | `container` | `HTMLElement \| string` | **Required.** Element, id, or CSS selector | ## UI controllers | Method / property | Floating | Bar | Description | | --------------------- | :--------------: | :--------------: | ---------------------------------------------- | | `dialer` | <span>Yes</span> | <span>Yes</span> | Underlying headless instance | | `open()` | <span>Yes</span> | <span>n/a</span> | Open the panel | | `close()` | <span>Yes</span> | <span>n/a</span> | Close the panel | | `toggle()` | <span>Yes</span> | <span>n/a</span> | Toggle the panel | | `startCall(input)` | <span>Yes</span> | <span>n/a</span> | Open and queue or start a call | | `setContact(contact)` | <span>Yes</span> | <span>Yes</span> | Attach the current contact, or `null` to clear | | `prefill(number)` | <span>Yes</span> | <span>Yes</span> | Prefill the number | | `setTheme(theme)` | <span>Yes</span> | <span>Yes</span> | Update the theme | | `on(event, handler)` | <span>Yes</span> | <span>Yes</span> | Subscribe to a headless event | | `destroy()` | <span>Yes</span> | <span>Yes</span> | Remove the UI and its listeners | <Warning> `controller.destroy()` removes the visual surface only. To release WebRTC, audio and the call lock: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} controller.destroy(); await controller.dialer.destroy(); ``` If you supplied a shared `dialer`, destroy it only after every consumer has finished with it. </Warning> ## `RingeeDialer` methods | Method | Returns | Purpose | | ------------------------------ | ------------------------- | ----------------------------------------------- | | `initialize()` | `Promise<void>` | Validate the installation and restore a session | | `destroy()` | `Promise<void>` | Disconnect and release resources | | `requestEmailCode(email)` | `Promise<EmailChallenge>` | Start email OTP | | `verifyEmailCode(input)` | `Promise<RingeeAgent>` | Verify the code | | `resendEmailCode(challengeId)` | `Promise<EmailChallenge>` | Resend the code | | `signOut()` | `Promise<void>` | Sign the agent out | | `getAuthState()` | `AuthState` | Current auth state | | `getAgent()` | `RingeeAgent \| null` | Authenticated agent | | `getCallerIds()` | `RingeeCallerId[]` | Caller IDs the agent may use | | `call(input)` | `Promise<RingeeCall>` | Authorize and start a call | | `hangup()` | `Promise<void>` | End the call | | `mute()` / `unmute()` | `void` | Microphone state | | `hold()` / `resume()` | `Promise<void>` | Hold state | | `sendDigits(digits)` | `void` | Send DTMF | | `getState()` | `DialerState` | Current dialer state | | `getActiveCall()` | `RingeeCall \| null` | Active call snapshot | | `getInputDevices()` | `Promise<AudioDevice[]>` | List microphones | | `getOutputDevices()` | `Promise<AudioDevice[]>` | List output devices | | `setInputDevice(id)` | `Promise<void>` | Store and validate preferred input | | `setOutputDevice(id)` | `Promise<void>` | Select output where supported | | `on(event, handler)` | `() => void` | Subscribe; returns unsubscribe | ### `call(input)` | Field | Type | Description | | ------------------- | -------- | ------------------------------------------- | | `to` | `string` | **Required.** E.164 destination | | `callerIdId` | `string` | Caller ID to use. Omit to let Ringee choose | | `externalContactId` | `string` | The contact's id in your CRM | | `contactId` | `string` | Native Ringee contact UUID | | `name` | `string` | Display label | ## Authentication states ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} checking -> anonymous -> sending_code -> awaiting_code -> verifying -> authenticated -> error authenticated -> expired | signed_out ``` `AuthState`: `checking`, `anonymous`, `sending_code`, `awaiting_code`, `verifying`, `authenticated`, `expired`, `signed_out`, `error`. ## Call states ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} uninitialized -> initializing -> ready -> dialing -> ringing -> active -> error -> held active | held -> reconnecting -> active active | held -> ending -> ended -> ready ``` `DialerState`: `uninitialized`, `initializing`, `ready`, `connecting`, `dialing`, `ringing`, `active`, `held`, `reconnecting`, `ending`, `ended`, `error`. <Warning> The exact sequence may skip states depending on the browser, the network or the remote destination. Render from the current state; do not assume every state always fires. </Warning> ## Events Every subscription returns an unsubscribe function: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const off = dialer.on("stateChanged", ({ state }) => console.log(state)); off(); ``` | Event | Payload | Emitted when | | ------------------ | ----------------- | ---------------------------------------------------- | | `ready` | `{}` | Agent and WebRTC are ready | | `authStateChanged` | `{ state }` | Authentication state changes | | `authRequired` | `{}` | OTP is required | | `codeSent` | `{ challenge }` | A code was sent or resent | | `signedIn` | `{ agent }` | Agent authenticated | | `signedOut` | `{}` | Session signed out | | `sessionExpired` | `{}` | Session expired | | `stateChanged` | `{ state }` | Call state changes | | `dialing` | `{ call }` | Dialing starts | | `ringing` | `{ call }` | Destination rings | | `answered` | `{ call }` | Call answered | | `held` | `{ call }` | Call held | | `resumed` | `{ call }` | Call resumed | | `muted` | `{ call }` | Microphone muted | | `unmuted` | `{ call }` | Microphone unmuted | | `ended` | `{ call }` | Call ended normally | | `failed` | `{ call, error }` | Authorization or the call failed | | `tokenExpiring` | `{}` | Reserved for proactive credential renewal | | `microphoneDenied` | `{}` | Reserved — currently use the `error` / `failed` code | | `deviceChanged` | `{}` | Audio device selection changed | | `error` | `{ error }` | A typed general error occurred | ## Persistence The agent session is stored in `sessionStorage` under a key scoped by integration and origin. It survives reloads in the same tab and is removed when the tab closes or `signOut()` runs. WebRTC credentials live only in memory and are minted again on restore. If a browser blocks `sessionStorage`, the SDK still works but asks for a code after every reload. ## What happens under the hood <AccordionGroup> <Accordion title="During initialization"> 1. The SDK reads `window.location.origin`. 2. It sends the publishable key and origin to Ringee. 3. The backend validates the signature, that the integration is active, and the exact origin. 4. The SDK checks `sessionStorage` for an agent session. 5. If present, Ringee revalidates it and returns a fresh WebRTC credential. 6. Otherwise it requests email OTP authentication. </Accordion> <Accordion title="During agent authentication"> 1. The agent enters an email address. 2. Ringee sends a one-time code without revealing whether the email exists. 3. The agent verifies the code. 4. The backend validates workspace membership and calling permissions. 5. The browser receives a Ringee session and temporary WebRTC credentials. 6. The SDK connects the engine and emits `ready`. </Accordion> <Accordion title="During a call"> 1. The SDK validates E.164 locally. 2. It acquires a lock so another tab cannot start a call. 3. The backend validates the session, caller ID, credit, Do Not Call, blocks and contact. 4. Ringee creates the call record and returns a signed correlation token. 5. The browser requests microphone permission. 6. The SDK starts the WebRTC call and maps provider states to Ringee states. 7. On completion it releases audio and the lock, computes duration, and emits `ended` or `failed`. </Accordion> </AccordionGroup> The SDK never exposes SIP passwords, Telnyx JWTs or provider-specific objects to the host application. ## Next steps <CardGroup> <Card title="Errors" icon="triangle-exclamation" href="/dialer-sdk/errors"> Every code and how to react </Card> <Card title="Security" icon="shield-check" href="/dialer-sdk/security"> CSP, iframes and self-hosting </Card> </CardGroup> # Security and self-hosting Source: https://docs.ringee.io/dialer-sdk/security Why pk_live is safe in the browser, plus CSP, iframes and custom API hosts ## Security does not depend on hiding the key `pk_live_…` is meant to ship in your frontend. Every call requires **all** of the following, validated server-side: * a signed publishable key; * an exact allowed origin; * an agent verified by email one-time code; * current workspace membership; * a valid Ringee session; * server-side permission, caller ID, credit, Do Not Call and block checks. A stolen publishable key on its own dials nothing. <Warning> Never place `cik_live_…` API keys, `whsec_…` signing secrets, SIP credentials or admin tokens in frontend code. Those are different credentials with different powers — see [Public API authentication](/api/authentication). </Warning> ## CORS SDK endpoints live under `/api/v1/sdk/*` and are deliberately **non-credentialed** — they authenticate with a bearer token and the `X-Ringee-Key` header, never cookies. Ringee reflects the requesting origin (with `Vary: Origin`, never a wildcard) so your page can read responses. CORS is not the security boundary here. The publishable key, the origin allow-list and the OTP are. ## Content Security Policy A restrictive CSP must allow the Ringee API and the calling WebSocket: ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} script-src 'self' https://unpkg.com; connect-src 'self' https://api.ringee.io wss://rtc.telnyx.com; media-src 'self' blob:; ``` * An **npm** installation does not need `unpkg.com`. * For self-hosting, replace `https://api.ringee.io` with your API origin. * Regional Telnyx configuration may require an additional WebSocket origin. ## Microphone and secure contexts Calls must run in a secure context — `https://` or `localhost`. Browsers refuse microphone access otherwise. ### Inside an iframe The host document must grant the permission: ```html theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <iframe src="https://dialer.crm.example.com" allow="microphone"></iframe> ``` <Warning> The origin in **allowed origins** is the origin of the document *executing* the SDK, which may differ from the parent page. A sandboxed iframe must also retain the origin and storage permissions the SDK needs. </Warning> Also check your host `Permissions-Policy` header — a restrictive `microphone=()` blocks the call before the SDK ever runs. ## Session storage The agent session lives in `sessionStorage`, keyed by integration and origin: * it survives reloads in the same tab; * it is removed when the tab closes or `signOut()` runs; * it is never shared across origins. WebRTC credentials are held only in memory and re-minted on restore. If a browser or privacy setting blocks `sessionStorage`, the SDK still works — the agent just enters a code after each reload. ## What the SDK never exposes The host application never sees SIP passwords, Telnyx JWTs, or any provider-specific object. The SDK encapsulates the provider entirely, which also means Ringee can change or add providers without breaking your integration. ## Self-hosting Point the SDK at your own API origin, **without** `/api`: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const ringee = createFloating({ key: "pk_live_xxxxx", apiUrl: "https://ringee-api.example.com", }); ``` Local development: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const ringee = createFloating({ key: "pk_live_xxxxx", apiUrl: "http://localhost:3000", debug: true, }); ``` <Warning> `apiUrl` and your frontend origin are different values. The frontend origin — for example `http://localhost:4200` — is what must appear in the publishable key's allowed origins. </Warning> Update your CSP to match your API and WebSocket origins. ## Validating a build In the monorepo: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm --filter @ringee/dialer-sdk typecheck pnpm --filter @ringee/dialer-sdk test pnpm --filter @ringee/dialer-sdk build ``` ## Checklist before going live <AccordionGroup> <Accordion title="Keys and origins"> * Production origin is in the publishable key's allowed origins. * The key was generated **after** the final origin list. * No `cik_live_` key, signing secret or admin token is reachable from the browser bundle. </Accordion> <Accordion title="Transport"> * The page is served over HTTPS. * CSP allows the API origin and `wss://rtc.telnyx.com`. * Any iframe hosting the SDK has `allow="microphone"`. </Accordion> <Accordion title="Lifecycle"> * Every mount has a matching `controller.destroy()` **and** `dialer.destroy()`. * Calls start from a user gesture, never on page load. * Errors are surfaced to the agent rather than swallowed. </Accordion> <Accordion title="Version"> * The CDN URL is pinned to a specific version. </Accordion> </AccordionGroup> ## Next steps <CardGroup> <Card title="Troubleshooting" icon="bug" href="/dialer-sdk/troubleshooting"> Symptom-first fixes </Card> <Card title="Public API" icon="webhook" href="/api/overview"> Sync records and receive call activity </Card> </CardGroup> # Theming and copy Source: https://docs.ringee.io/dialer-sdk/theming Match the dialer to your product The bundled UIs render inside an isolated Shadow DOM, so your page styles never leak in and the dialer never leaks out. Customization goes through the theme object, CSS custom properties, and copy overrides. ## Language English is the default. Spanish is available explicitly: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} createFloating({ key: "pk_live_xxxxx", locale: "en", // default; use "es" for Spanish }); ``` ## Copy overrides `strings` replaces individual labels on top of the selected language: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} createBar({ key: "pk_live_xxxxx", container: "#ringee-bar", strings: { callButton: "Call prospect", numberLabel: "Prospect phone number", }, }); ``` Override only what you need — everything else falls back to the locale. <Note> The bundled UIs translate backend error codes into actionable messages for you. In [Headless](/dialer-sdk/headless), that mapping is yours to write — see [Errors](/dialer-sdk/errors). </Note> ## Theme object ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const ringee = createFloating({ key: "pk_live_xxxxx", theme: { primary: "#4f46e5", primaryHover: "#4338ca", onPrimary: "#ffffff", radius: "14px", fontFamily: "Inter, sans-serif", colorScheme: "auto", }, }); ``` Update it at runtime: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee.setTheme({ primary: "#0f766e", colorScheme: "dark", }); ``` ### Theme fields | Group | Fields | | -------- | ------------------------------------------------------ | | Brand | `primary`, `primaryHover`, `onPrimary` | | Surfaces | `background`, `surface`, `text`, `textMuted`, `border` | | Status | `danger`, `success`, `warning` | | Shape | `radius`, `shadow`, `fontFamily` | | Scheme | `colorScheme` — `"auto"`, `"light"` or `"dark"` | `"auto"` follows the viewer's system preference. ## CSS custom properties The same values are settable as CSS variables on any ancestor, which is often easier when you already have design tokens: ```css theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} #crm-shell { --ringee-primary: #4f46e5; --ringee-primary-hover: #4338ca; --ringee-on-primary: #ffffff; --ringee-background: #ffffff; --ringee-surface: #f8fafc; --ringee-text: #0f172a; --ringee-text-muted: #64748b; --ringee-border: #e2e8f0; --ringee-danger: #dc2626; --ringee-success: #16a34a; --ringee-warning: #d97706; --ringee-radius: 14px; --ringee-shadow: 0 20px 50px rgb(15 23 42 / 18%); --ringee-font-family: Inter, sans-serif; } ``` <Tip> Wire these to your existing tokens and the dialer follows your theme switcher automatically — no `setTheme()` call needed. </Tip> ## Workspace label Show which Ringee workspace the agent is calling from in the panel footer: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} createFloating({ key: "pk_live_xxxxx", workspaceName: "Acme Sales", }); ``` ## Optional controls Hold and resume are hidden by default. Enable them when your team uses them: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} createFloating({ key: "pk_live_xxxxx", allowHold: true, }); ``` ## When theming is not enough If you need a layout the bundled UIs do not offer — a dialer embedded in a table row, a custom call screen, a completely different flow — use [Headless](/dialer-sdk/headless). It exposes authentication, calls, devices and events with no markup at all. The `ui-gallery` playground in the repository renders every Floating and Bar state against a simulated dialer, with no network or WebRTC. It is the fastest way to check a theme against real states: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} apps/sdk-playground/ui-gallery ``` ## Next steps <CardGroup> <Card title="API reference" icon="book" href="/dialer-sdk/reference"> Every option in one table </Card> <Card title="Headless" icon="code" href="/dialer-sdk/headless"> Full control over the interface </Card> </CardGroup> # Troubleshooting Source: https://docs.ringee.io/dialer-sdk/troubleshooting Symptom-first fixes for the Dialer SDK ## `DOMAIN_NOT_ALLOWED` Compare `window.location.origin` **exactly** with the origins in **Integrations → Custom Integrations → Configure → Settings → Dialer SDK · Publishable keys**. ```js theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} console.log(window.location.origin); // paste this into the allowed origins ``` Check the scheme, subdomain and port — `https://` and `http://` are different, and so are `crm.example.com` and `app.crm.example.com`. <Warning> Changing the origin list does **not** update existing keys. Generate a new publishable key after adding an origin, and deploy the new value. </Warning> ## `INVALID_PUBLISHABLE_KEY` One of three things: * the key is malformed or truncated (check for a copy-paste line break); * the integration was deleted; * the integration's `cik_live_` API key was rotated, which revokes every publishable key. Generate a new publishable key. ## `AUTH_REQUIRED`, or OTP appears after every reload Wait for the `ready` event before calling. Calling earlier is safe with `startCall()` on the Floating controller (it queues), but `dialer.call()` in Headless will reject. If the code is requested after *every* reload, the browser is blocking `sessionStorage` — check private browsing mode, an iframe without storage access, or a tracking-prevention setting. ## `MICROPHONE_DENIED` <Steps> <Step title="Serve over HTTPS"> Browsers only grant microphone access in a secure context. `localhost` is exempt. </Step> <Step title="Allow the site"> Check the browser's per-site microphone permission — a previous "Block" is sticky. </Step> <Step title="Check Permissions-Policy"> A host header of `microphone=()` blocks the request before the SDK runs. </Step> <Step title="Allow the iframe"> ```html theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} <iframe src="…" allow="microphone"></iframe> ``` </Step> </Steps> ## `AUDIO_PLAYBACK_BLOCKED` The browser requires a user gesture before playing audio. Start calls from a real click or tap — never automatically during page load or in a `useEffect` that runs on mount. ## `INVALID_PHONE_NUMBER` Use E.164: `+`, country code, then the number, with no extension, spaces or punctuation. ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} +13055550198 ✅ (305) 555-0198 ❌ 305-555-0198 ❌ ``` ## `CALL_ALREADY_ACTIVE` A call already exists — in this instance, or in another tab using the same integration. The SDK uses Web Locks across tabs and the server enforces it again. ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} const active = dialer.getActiveCall(); if (active) await dialer.hangup(); ``` ## The bar does not appear Two usual causes: 1. The container did not exist when `createBar()` ran. In a framework, create the controller after the container renders. 2. The layout collapsed it. Give it a width: ```css theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} #ringee-bar { width: 100%; min-width: 320px; } ``` ## The UI disappears but WebRTC stays connected `controller.destroy()` unmounts the visual surface only. Destroy the engine too: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} controller.destroy(); await controller.dialer.destroy(); ``` ## `window is not defined` at build time The package was imported at module scope in an SSR environment. Move it into `useEffect` with a dynamic import — see [React and Next.js](/dialer-sdk/react). ## Two launchers in development React 18 Strict Mode invokes effects twice. Guard with a `cancelled` flag and destroy on cleanup — the pattern in [React and Next.js](/dialer-sdk/react) handles it. ## Nothing happens and there are no errors Turn on verbose logs: ```ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} createFloating({ key: "pk_live_xxxxx", debug: true }); ``` Then check, in order: 1. **Network tab** — are requests to `/api/v1/sdk/*` reaching your API host? 2. **Console** — is CSP blocking `connect-src` or the WebSocket? 3. **`apiUrl`** — is it the API origin without `/api`, and is it the right environment? 4. **Events** — subscribe to `error` and `failed` before `initialize()`. ## Calls connect but the CRM never learns about them The browser `ended` event is live UI state, not a durable record. Activity reaches your CRM through [outbound webhooks](/api/outbound-webhooks). Check: * the integration has an outbound URL and the relevant events are selected; * your endpoint verifies the signature and returns `2xx` within 15 seconds; * the delivery log at `GET /api/integrations/custom/:id/outbound-logs`. See [Logs and errors](/api/logs-and-errors). ## Still stuck <CardGroup> <Card title="Error reference" icon="triangle-exclamation" href="/dialer-sdk/errors"> Every code, with a fix </Card> <Card title="Open an issue" icon="github" href="https://github.com/ringee-io/ringee-app/issues"> Report a bug or ask a question </Card> </CardGroup> # How it works Source: https://docs.ringee.io/howitworks Learn the architecture of Ringee ## Architecture Ringee is composed of **3 main services** backed by a set of external services. The main services communicate through HTTP and Temporal, and connect to external services for data storage, telephony, auth, and payments. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef ext fill:#22C55E,color:white,stroke:#fff classDef svc fill:#15803D,color:white,stroke:#fff frontend[Frontend - Next.js]:::svc backend[Backend - NestJS]:::svc orchestrator[Orchestrator - Temporal worker]:::svc temporal[Temporal]:::ext db[(PostgreSQL)]:::ext frontend --> backend backend --> db backend -- starts workflows --> temporal temporal -- executes --> orchestrator orchestrator --> db ``` * [Frontend](#frontend) — Web interface for managing calls, contacts, and analytics. * [Backend](#backend) — REST API that handles all business logic and integrations. * [Orchestrator](#orchestrator) — Temporal worker that runs durable background jobs and schedules. ### Frontend A single **Next.js** application on port `4200`, using the App Router with feature-based organization: * the marketing site; * the authenticated dashboard — calls, contacts, campaigns, analytics, numbers, billing; * the browser-based dialer, powered by Telnyx WebRTC. It uses **Clerk** for authentication and talks to the backend API for all operations. ### Backend The backend is the brain of Ringee — a **NestJS** application on port `3000` with a global `/api` prefix. It: * handles REST API requests, protected by user authentication or route-specific proof such as a Custom Integration API key; * manages Telnyx call control, number purchasing and WebRTC credentials; * processes Stripe webhooks for payments, credits and subscriptions; * handles Clerk webhooks for user and organization management; * serves the [Public API](/api/overview) for CRM integrations; * serves the [MCP server](/mcp/overview) for AI assistants; * creates and runs [AI Voice Agents](/voice-agents/overview), including provider tools, callbacks and results; * serves the [Dialer SDK](/dialer-sdk/overview) endpoints under `/api/v1/sdk`; * starts durable workflows on Temporal. ### Orchestrator A separate process (`apps/orchestrator`) that runs the **Temporal worker**. It executes the workflows the backend starts and owns the periodic schedules: * webhook delivery drains and retries; * callback, reminder and retry pollers; * CRM synchronization drains; * recording and transcription processing; * push notifications via Firebase Cloud Messaging. Workflow names and inputs are shared through a contracts file, so the backend and the orchestrator never drift. <Note> Temporal replaced the earlier Redis pub/sub worker. Redis is still used for caching and session state. </Note> ## Shared packages Ringee is a pnpm workspaces monorepo. Business logic lives in shared packages rather than in any single app: | Package | Purpose | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | `@ringee/database` | Prisma client, schema and migrations | | `@ringee/platform` | Auth, Redis, email, storage, Stripe, telephony, voice-agent and AI provider adapters, notifications, crypto | | `@ringee/services` | Domain services — call, contact, campaign, credit, subscription, recording, dashboard and AI Voice Agents | | `@ringee/configuration` | Centralized environment config with startup validation | | `@ringee/frontend-shared` | Shared React components, hooks and utilities | | `@ringee/dialer-core`, `@ringee/dialer-ui` | The dialer engine and UI, shared by the web app and the browser extension | | `@ringee/dialer-sdk` | The public browser [Dialer SDK](/dialer-sdk/overview) | | `@ringee-io/agent` | Shared MCP schemas, tool catalog, prompts and safety rules for MCP, CLI and apps | This is why the Public API—including AI Voice Agents—the MCP server, the CLI and the Dialer SDK preserve the same workspace and business rules: they are different entry points into the same service layer. ## External Services | Service | Purpose | | ----------------- | ----------------------------------------------------------- | | **PostgreSQL** | Primary database via Prisma ORM | | **Redis** | Session state and caching | | **Temporal** | Durable workflow engine for background jobs and schedules | | **Telnyx** | WebRTC telephony plus the current AI Voice Agent provider | | **Clerk** | Authentication and multi-tenant team management | | **Stripe** | Billing, credits, subscriptions, and phone number purchases | | **Cloudflare R2** | File storage for call recordings and media | | **Resend** | Transactional emails | | **Firebase** | Push notifications for real-time call alerts | ## Multi-tenancy Users belong to organizations through memberships. Most entities — calls, contacts, numbers, credits, campaigns — carry both a `userId` and an optional `organizationId`, so a user's personal data and their organizations' data stay separate. Every entry point respects this. The dashboard, the [Public API](/api/overview), [AI Voice Agents](/voice-agents/overview), the [MCP server](/mcp/workspaces) and the [Dialer SDK](/dialer-sdk/overview) all resolve the same ownership context before reading or writing. ## Building on Ringee <CardGroup> <Card title="Ways to build" icon="puzzle-piece" href="/build-on-ringee"> Which integration surface fits your use case </Card> <Card title="Add a provider" icon="phone" href="/providers/add-provider"> Integrate another telephony provider </Card> </CardGroup> # Development Environment Source: https://docs.ringee.io/installation/development Set up Ringee for local development <Info> This guide is for **developers** who want to contribute to Ringee or run it locally for development. For production use, see [Docker Compose](/installation/docker-compose). </Info> ## Prerequisites * **Node.js** 20 or higher * **pnpm** 8 or higher * **PostgreSQL** 16 * **Redis** 7 ## Installation <Steps> <Step title="Clone the repository"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} git clone https://github.com/ringee-io/ringee-app.git cd ringee ``` </Step> <Step title="Install dependencies"> Ringee uses a pnpm monorepo. Install all dependencies: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm install ``` </Step> <Step title="Configure environment"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} cp .env.example .env ``` Edit `.env` and fill in your API keys. For local development, the database and Redis defaults should work if you have them running locally: ```env theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} DATABASE_URL="postgresql://ringee-user:ringee-password@localhost:5432/ringee-db-local?schema=public" REDIS_URL="redis://localhost:6379" REDIS_HOST="localhost" REDIS_PORT=6379 ``` See the [Configuration Reference](/configuration/reference) for all variables. </Step> <Step title="Set up the database"> Generate the Prisma client and run migrations: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm prisma generate pnpm prisma migrate dev ``` </Step> <Step title="Start the development servers"> Run all services concurrently: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm dev ``` `pnpm dev` runs the backend and the frontend in parallel. Start services individually with: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm dev:backend # NestJS API on :3000 pnpm dev:frontend # Next.js on :4200 pnpm dev:orchestrator # Temporal worker ``` </Step> </Steps> ## Access the application | Service | URL | | ----------- | ------------------------------------------------------ | | Frontend | [http://localhost:4200](http://localhost:4200) | | Backend API | [http://localhost:3000/api](http://localhost:3000/api) | | Temporal UI | [http://localhost:8080](http://localhost:8080) | ## Project structure ``` ringee/ ├── apps/ │ ├── frontend/ # Next.js app (marketing + dashboard) │ ├── backend/ # NestJS REST API, Public API, MCP, SDK endpoints │ ├── orchestrator/ # Temporal worker (background jobs + schedules) │ ├── agent-cli/ # The `ringee` CLI │ ├── browser-extension/ # Chrome extension │ ├── chatgpt-app/ # ChatGPT App (Apps SDK MCP server) │ ├── attio/ # Attio CRM integration │ └── sdk-playground/ # Dialer SDK playgrounds ├── packages/ │ ├── database/ # Prisma schema, client, migrations │ ├── platform/ # Auth, Redis, email, storage, Stripe, telephony │ ├── services/ # Domain services │ ├── configuration/ # Env config with startup validation │ ├── agent/ # @ringee-io/agent — the agent layer │ ├── dialer-core/ # Dialer engine │ ├── dialer-ui/ # Shared dialer UI │ ├── dialer-sdk/ # @ringee/dialer-sdk — the public browser SDK │ └── frontend-shared/ # Shared React components and hooks ├── docker-compose.yml # PostgreSQL, Redis, Temporal ├── docker-compose.app.yml # Backend, orchestrator, frontend ├── .env.example └── package.json ``` ## Useful commands ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} # Run database migrations pnpm prisma migrate dev # Open Prisma Studio (database GUI) pnpm prisma studio # Lint all packages pnpm lint # Type check pnpm type-check ``` ## Next steps <CardGroup> <Card title="Configuration" icon="gear" href="/configuration/reference"> Understand all configuration options </Card> <Card title="How it works" icon="diagram-project" href="/howitworks"> Learn the architecture </Card> </CardGroup> # Docker (Standalone) Source: https://docs.ringee.io/installation/docker Run Ringee with standalone Docker containers <Warning> This method is for **advanced users** who want granular control over each container. For most users, we recommend using [Docker Compose](/installation/docker-compose) instead. </Warning> ## Prerequisites * [Docker](https://docs.docker.com/get-docker/) installed * A running **PostgreSQL** instance * A running **Redis** instance * API keys for required services (see [Configuration Reference](/configuration/reference)) ## Setup external services If you don't already have PostgreSQL and Redis running, start them with Docker: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} # PostgreSQL docker run -d \ --name ringee-db \ -e POSTGRES_USER=ringee-user \ -e POSTGRES_PASSWORD=ringee-password \ -e POSTGRES_DB=ringee-db-local \ -p 5432:5432 \ postgres:16 # Redis docker run -d \ --name ringee-redis \ -p 6379:6379 \ redis:7-alpine ``` ## Build the Docker image ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} git clone https://github.com/ringee-io/ringee-app.git cd ringee docker build -t ringee . ``` ## Run the services ### Backend ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} docker run -d \ --name ringee-backend \ --env-file .env \ -e DATABASE_URL="postgresql://ringee-user:ringee-password@host.docker.internal:5432/ringee-db-local?schema=public" \ -e REDIS_URL="redis://host.docker.internal:6379" \ -p 3000:3000 \ ringee backend ``` ### Worker ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} docker run -d \ --name ringee-worker \ --env-file .env \ -e DATABASE_URL="postgresql://ringee-user:ringee-password@host.docker.internal:5432/ringee-db-local?schema=public" \ -e REDIS_URL="redis://host.docker.internal:6379" \ ringee worker ``` ### Frontend ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} docker run -d \ --name ringee-frontend \ --env-file .env \ -p 4200:4200 \ ringee frontend ``` ## Verify Check all containers are running: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} docker ps --filter "name=ringee" ``` Then open: * **Frontend** → [http://localhost:4200](http://localhost:4200) * **Backend API** → [http://localhost:3000/api](http://localhost:3000/api) ## Next steps <CardGroup> <Card title="Configuration" icon="gear" href="/configuration/reference"> Full environment variable reference </Card> <Card title="Telnyx Setup" icon="phone" href="/providers/telnyx"> Configure telephony </Card> </CardGroup> # Docker Compose Source: https://docs.ringee.io/installation/docker-compose Deploy Ringee with Docker Compose (recommended) <Info> This is the **recommended** way to run Ringee for production use. It handles all services (PostgreSQL, Redis, Frontend, Backend, Worker) in a single configuration. </Info> ## Prerequisites * [Docker](https://docs.docker.com/get-docker/) installed * [Docker Compose](https://docs.docker.com/compose/install/) installed * API keys for required services (see [Configuration Reference](/configuration/reference)) ## Installation <Steps> <Step title="Clone the repository"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} git clone https://github.com/ringee-io/ringee-app.git cd ringee ``` </Step> <Step title="Create your environment file"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} cp .env.example .env ``` Open `.env` in your editor and fill in your API keys. At minimum, you need: * `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` — [Get from Clerk](/configuration/clerk) * `TELNYX_API_KEY` and `TELNYX_CONNECTION_ID` — [Get from Telnyx](/providers/telnyx) * `STRIPE_SECRET_KEY` — [Get from Stripe](/configuration/stripe) See the full [Configuration Reference](/configuration/reference) for all available options. </Step> <Step title="Start all services"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} docker-compose -f docker-compose.app.yml up --build -d ``` This will start: * **Backend** API server on port `3000` * **Orchestrator** — the Temporal worker for background jobs * **Frontend** on port `4200` Bring up the infrastructure it depends on — PostgreSQL, Redis and Temporal — with `docker-compose up -d`. </Step> <Step title="Verify the installation"> Check that all containers are running: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} docker-compose -f docker-compose.app.yml ps ``` Then open: * **Frontend** → [http://localhost:4200](http://localhost:4200) * **Backend API** → [http://localhost:3000/api](http://localhost:3000/api) * **Temporal UI** → [http://localhost:8080](http://localhost:8080) </Step> </Steps> <Note> When using `docker-compose.app.yml`, the values for `DATABASE_URL`, `REDIS_HOST`, `REDIS_PORT`, and `REDIS_URL` are **automatically overridden** to use Docker internal hostnames. You don't need to set these manually. </Note> ## Useful commands ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} # View logs docker-compose -f docker-compose.app.yml logs -f # Stop all services docker-compose -f docker-compose.app.yml down # Restart a specific service docker-compose -f docker-compose.app.yml restart backend # Rebuild after code changes docker-compose -f docker-compose.app.yml up --build -d ``` ## Next steps <CardGroup> <Card title="Configuration" icon="gear" href="/configuration/reference"> Fine-tune your environment variables </Card> <Card title="Docker Config" icon="docker" href="/configuration/docker-compose"> Learn about Docker-specific configuration options </Card> </CardGroup> # Introduction Source: https://docs.ringee.io/introduction Welcome to the Ringee documentation <Note> YouTube Tutorials: [https://youtube.com/@RingeeOfficial](https://youtube.com/@RingeeOfficial) </Note> ## What is Ringee? Ringee is your **open-source global calling & communication infrastructure** — an alternative to expensive VoIP providers, up to **50× cheaper** than traditional phone rates. Make and receive crystal-clear calls to **180+ countries**, manage contacts, record calls, and scale your sales team — right from your browser. * 📞 **Global Calls** — Call landlines and mobile phones in 180+ countries. No apps, no SIM cards needed. * 🔊 **WebRTC Telephony** — Powered by Telnyx WebRTC for crystal-clear, low-latency voice. * 🎙️ **Call Recording** — Record conversations for quality assurance, training, or compliance. * 👥 **Contact Management** — Import thousands of contacts via CSV, organize with tags, search instantly. * 📊 **Team Analytics** — See your whole team's calling metrics: calls per day, per month, per agent. * 🤖 **AI Voice Agents** — Book appointments, deliver reminders and return structured outcomes from real conversations. * 💰 **Cost Effective** — Buy numbers from 180+ countries at a fraction of the cost. * 🏗️ **Open Source** — Self-host the entire infrastructure, or use our hosted service. <CardGroup> <Card title="Quickstart" icon="rocket" href="/quickstart"> Get Ringee up and running in under 5 minutes </Card> <Card title="How it works" icon="diagram-project" href="/howitworks"> Learn the architecture behind Ringee </Card> </CardGroup> ## Tech Stack | Layer | Technology | | ----------------- | ------------------------------------------- | | **Monorepo** | pnpm workspaces | | **Frontend** | Next.js 15 (React 19, App Router) | | **Backend** | NestJS (REST & Webhooks) | | **Orchestrator** | Temporal worker for durable background jobs | | **Database** | PostgreSQL with Prisma ORM | | **Cache** | Redis | | **Telephony** | Telnyx WebRTC | | **Auth** | Clerk | | **Payments** | Stripe | | **Notifications** | Firebase Cloud Messaging | | **Storage** | Cloudflare R2 | | **Email** | Resend | ## Use Cases Perfect for building your own: * **Scalable VoIP infrastructure** * **Cold calling platform** * **Call center solution** * **Sales team communication hub** <Info> At the moment there is no difference between the hosted version and the self-hosted version. </Info> ## Build on Ringee Ringee is designed to be embedded, automated and driven by agents. <CardGroup> <Card title="Public API" icon="webhook" href="/api/overview"> Sync any CRM and receive call activity through signed webhooks </Card> <Card title="Dialer SDK" icon="phone" href="/dialer-sdk/overview"> Embed a real dialer inside your own web app </Card> <Card title="MCP" icon="robot" href="/mcp/overview"> Let Claude or ChatGPT operate Ringee on your behalf </Card> <Card title="CLI" icon="terminal" href="/cli/overview"> Run Ringee from the terminal or any agent harness </Card> <Card title="AI Voice Agents" icon="robot" href="/voice-agents/overview"> Create autonomous calling agents and trigger them through API, MCP or CLI </Card> </CardGroup> Not sure which one you need? See [Ways to build on Ringee](/build-on-ringee). # Apps and skills Source: https://docs.ringee.io/mcp/apps Ringee inside Claude Code, claude.ai and ChatGPT The MCP server gives an assistant the *tools*. Skills and apps give it the *operating knowledge* — which tool to use, in what order, and when to stop and ask. All of them sit on top of `@ringee-io/agent`, which holds the shared tool catalog, flows, prompts and safety rules. No business logic is duplicated: the Ringee backend stays the single source of truth. ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} Ringee backend / MCP │ ▼ @ringee-io/agent (catalog · flows · prompts · rules) │ ├── ringee CLI (terminal, any agent harness) ├── Claude skills (Claude Code + claude.ai) └── ChatGPT app (Apps SDK, visual cards) ``` ## Claude Code plugin The plugin bundles the Ringee MCP server and the `/ringee` skill commands in one install. ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} /plugin marketplace add ringee-io/ringee-app /plugin install ringee@ringee-io ``` On enable, Claude Code asks for your **Ringee MCP URL** and stores it in the keychain. Get it from the dashboard, or from `GET /api/mcp/connection-info` — see [Connect a client](/mcp/connect). Update later with `/plugin marketplace update`. ## Skill commands | Command | What it does | | ------------------ | ----------------------------------------------------------- | | `/ringee` | Entry point — routes to the right sub-skill | | `/ringee-prospect` | Find leads, reveal contact info, import as contacts | | `/ringee-contacts` | Search, view, create, update, delete contacts | | `/ringee-session` | Create, inspect, update, revoke magic-link dialing sessions | | `/ringee-followup` | Log an outcome and book the next touch | | `/ringee-flow` | Drive the full outbound flow end to end | Each skill enforces the confirmation rules in [Safety](/mcp/safety) — credit spend and destructive actions always stop for a human yes. ## claude.ai claude.ai does not use Claude Code plugins. It needs the two pieces separately. <Steps> <Step title="Add the MCP as a connector"> **Settings → Connectors → Add custom connector**, then paste your Ringee MCP URL. The server must be reachable from the public internet. </Step> <Step title="Upload the skills"> Build the upload-ready zips from the repo: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm agent:package-skills # → packages/agent/dist/skills/*.zip ``` Then **Settings → Customize → Skills → Upload skill** and upload each zip. Skills require code execution to be enabled. </Step> <Step title="Use them"> Type `/` in any chat and pick a Ringee command, or just describe what you want. </Step> </Steps> <Tip> On Team and Enterprise plans, upload once for everyone under **Organization settings → Skills** using the same zips. </Tip> ## Installing skills into a project For Claude Code, copy the skills straight into a project's `.claude/skills`: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm --filter @ringee-io/agent install-skills # → ./.claude/skills node packages/agent/scripts/install-skills.mjs ~/project # → ~/project/.claude/skills ``` ## ChatGPT app The ChatGPT app is itself an MCP server (`apps/chatgpt-app`) that proxies the Ringee tools and renders them as **visual cards** — contact lists, call sessions, workspace switchers — instead of walls of JSON. Widgets are compiled into a bundle and inlined into each tool's HTML resource, so the MCP server is the only thing you deploy. <Steps> <Step title="Build the widgets"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} pnpm --filter @ringee-io/chatgpt-app build:widgets ``` </Step> <Step title="Run it locally"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} cp apps/chatgpt-app/.env.example apps/chatgpt-app/.env # set RINGEE_MCP_URL pnpm --filter @ringee-io/chatgpt-app mcp:prod # serves :4250/mcp ``` Test with `npx @modelcontextprotocol/inspector` against `http://localhost:4250/mcp`, or expose it with `ngrok http 4250` and add it as a connector in ChatGPT developer mode. </Step> <Step title="Deploy behind HTTPS"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} docker build -f apps/chatgpt-app/Dockerfile -t ringee-chatgpt-mcp . docker run -p 4250:4250 --env-file apps/chatgpt-app/.env ringee-chatgpt-mcp ``` The connector URL is `https://<host>/mcp`. </Step> <Step title="Add OAuth"> ChatGPT requires OAuth 2.1 for connectors that touch user data. Point it at Clerk (or any OAuth 2.1 server with PKCE and published JWKS): ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} RINGEE_REQUIRE_AUTH=true RINGEE_OAUTH_ISSUER=https://auth.example.com RINGEE_OAUTH_JWKS_URL=https://auth.example.com/.well-known/jwks.json RINGEE_OAUTH_AUDIENCE=https://your-host/mcp ``` </Step> </Steps> <Info> If you only need the Ringee tools in ChatGPT without custom widgets, connect the backend's authenticated endpoint directly: `https://api.ringee.io/api/mcp/chatgpt/sse`. See [Connect a client](/mcp/connect#chatgpt). </Info> ## Domain verification OpenAI's developer portal verifies you control the MCP hostname by fetching a fixed token: ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} GET /.well-known/openai-apps-challenge ``` The backend serves it verbatim as `text/plain` from `OPENAI_APPS_CHALLENGE_TOKEN`. Each verified origin gets its own token — the backend and a separately deployed ChatGPT server cannot share one. ## Next steps <CardGroup> <Card title="ringee CLI" icon="terminal" href="/cli/overview"> The same capabilities from a terminal </Card> <Card title="Agent layer" icon="cube" href="/cli/agents"> Build your own agent on `@ringee-io/agent` </Card> </CardGroup> # Connect a client Source: https://docs.ringee.io/mcp/connect Get your Ringee MCP URL and wire it into Claude, ChatGPT or Cursor ## Get your MCP URL While signed in to Ringee, call: ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} GET /api/mcp/connection-info ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "mode": "organization", "userId": "a91c…", "organizationId": "org_2k7yX…", "url": "https://api.ringee.io/api/mcp/a91c…/org_2k7yX…/sse" } ``` The URL matches your currently active workspace: | Mode | URL shape | | ------------ | ------------------------------------------------------------- | | Personal | `https://api.ringee.io/api/mcp/<userId>/sse` | | Organization | `https://api.ringee.io/api/mcp/<userId>/<organizationId>/sse` | <Note> Either URL works from any client — the assistant can move between workspaces at runtime with `switch_workspace`. See [Workspaces](/mcp/workspaces). </Note> <Warning> This URL is a credential. Anyone holding it can read and modify that workspace's contacts, calls and sessions. Do not share it, and generate connection info again if you believe it leaked. </Warning> ## Transport The endpoint speaks MCP over **SSE**: | Method | Path | Purpose | | ------ | --------------------------------------- | ---------------------- | | `GET` | `/api/mcp/:userId/sse` | Open the event stream | | `POST` | `/api/mcp/:userId/messages?sessionId=…` | Send JSON-RPC messages | The org-scoped form adds `/:organizationId` before `/sse` and `/messages`. The `messages` endpoint requires the `sessionId` the stream handed you. ## Claude Desktop and claude.ai <Tabs> <Tab title="claude.ai"> 1. Open **Settings → Connectors → Add custom connector**. 2. Paste your MCP URL. 3. Save, then start a chat and ask Claude to list your Ringee contacts. The server must be reachable from the public internet — `localhost` will not work. </Tab> <Tab title="Claude Desktop"> Add the server to your MCP configuration: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "mcpServers": { "ringee": { "url": "https://api.ringee.io/api/mcp/<userId>/sse" } } } ``` Restart Claude Desktop and confirm the Ringee tools appear in the tools list. </Tab> <Tab title="Claude Code"> ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} claude mcp add --transport sse ringee https://api.ringee.io/api/mcp/<userId>/sse ``` Or install the full Ringee plugin, which bundles the MCP server and the `/ringee` skill commands — see [Apps and skills](/mcp/apps). </Tab> <Tab title="Cursor"> Add the server under **Settings → MCP**, using the same SSE URL. Cursor discovers the tool list automatically once the stream connects. </Tab> </Tabs> ## ChatGPT ChatGPT connectors require OAuth, so they use a different, id-free endpoint: ``` https://api.ringee.io/api/mcp/chatgpt/sse ``` This endpoint resolves the caller from a verified bearer token instead of trusting ids in the URL, so every end user reaches only their own data. It publishes RFC 9728 protected-resource metadata for discovery: ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} GET /api/mcp/chatgpt/.well-known/oauth-protected-resource ``` Self-hosted deployments configure the authorization server with: | Variable | Purpose | | --------------------------------- | ------------------------------------------------------------------------ | | `MCP_OAUTH_AUTHORIZATION_SERVERS` | Your Clerk issuer (comma-separated if more than one) | | `MCP_OAUTH_SCOPES` | Scopes advertised — defaults to `ringee:use` | | `OPENAI_APPS_CHALLENGE_TOKEN` | Domain-verification token served at `/.well-known/openai-apps-challenge` | <Info> OpenAI issues a **distinct** verification token per origin. The backend (`api.ringee.io`) and a separately deployed ChatGPT MCP server each need their own. </Info> ## Verify the connection The fastest check is the [`ringee` CLI](/cli/overview), which connects to the same server: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} export RINGEE_MCP_URL="https://api.ringee.io/api/mcp/<userId>/sse" ringee config check ``` It connects and prints every tool the server exposes. If that works, any MCP client will too. You can also use the MCP Inspector: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} npx @modelcontextprotocol/inspector ``` ## Self-hosting Point clients at your own backend instead: ``` https://ringee-api.example.com/api/mcp/<userId>/sse ``` The URL is built from `BACKEND_URL`, so make sure that variable is the **public** URL of your API — see the [Configuration reference](/configuration/reference). ## Next steps <CardGroup> <Card title="Tool reference" icon="list" href="/mcp/tools"> Everything the assistant can call </Card> <Card title="Safety" icon="shield-check" href="/mcp/safety"> How destructive and credit-spending actions are guarded </Card> </CardGroup> # MCP overview Source: https://docs.ringee.io/mcp/overview Let AI assistants operate Ringee through the Model Context Protocol Ringee ships a built-in **MCP server**. Connect it to Claude, ChatGPT, Cursor or any MCP client and the assistant can search contacts, manage campaigns, review analytics, prospect leads, create dialing sessions, enforce DNC and run AI Voice Agents—using your real Ringee data. <CardGroup> <Card title="Connect a client" icon="plug" href="/mcp/connect"> Get your MCP URL and wire it into Claude or ChatGPT </Card> <Card title="Tool reference" icon="list" href="/mcp/tools"> Every tool, its inputs and what it returns </Card> </CardGroup> ## What the assistant can do | Area | Tools | | --------------- | ---------------------------------------------------------------------------------- | | Workspaces | List available workspaces, switch between personal and organization | | Contacts | Search, fetch details, create, update, soft-delete, find by call outcome | | Calls | List full call history including outcome, transcription and recording URL | | Follow-ups | Log call outcomes, schedule callbacks, book meetings | | Call sessions | Create, inspect, update and revoke magic-link dialing queues | | Prospecting | Search leads, reveal contact info, bulk-import as contacts (Apollo or Prospeo) | | Campaigns | List configuration and leads, change status, add or remove leads, read performance | | Analytics | Dashboard KPIs, outcome funnels, daily activity and per-agent performance | | Compliance | List, add and explicitly remove Do Not Call entries | | AI pipelines | Read follow-up, script and objection-analysis results | | AI Voice Agents | List configured agents, start a real conversation and read its structured result | ## Architecture The MCP server runs inside the Ringee backend. There is nothing extra to deploy. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef client fill:#86EFAC,color:black,stroke:#fff classDef svc fill:#15803D,color:white,stroke:#fff classDef data fill:#22C55E,color:white,stroke:#fff claude[Claude / ChatGPT / Cursor]:::client mcp[Ringee MCP server]:::svc api[Ringee services]:::svc db[(PostgreSQL)]:::data claude -- "SSE + JSON-RPC" --> mcp mcp --> api api --> db ``` Every tool call resolves to the same domain services the dashboard uses, so permissions, credit rules and workspace scoping behave identically no matter where the request comes from. ## Two endpoints, two auth models | Endpoint | Auth | Use for | | ---------------------- | ------------------------------ | ------------------------------------------------------------------------ | | `/api/mcp/:userId/sse` | The ids embedded in the URL | Personal use — Claude Desktop, Cursor, the [`ringee` CLI](/cli/overview) | | `/api/mcp/chatgpt/sse` | OAuth 2.1 bearer token (Clerk) | Multi-user apps — the ChatGPT connector | <Warning> The URL-scoped endpoint grants access to the workspace it names. Treat your MCP URL like a password: do not paste it in shared documents, tickets or screenshots. </Warning> ## Workspace scoping Everything an assistant reads or writes is scoped to the **active workspace** — your personal account or one organization. The assistant can move between them with `switch_workspace`, with no re-authentication. See [Workspaces](/mcp/workspaces). ## Safety model Tools are annotated with the MCP trust hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) so clients can warn before something consequential happens. * **Destructive** tools include contact deletion, session revocation, campaign-lead removal and DNC removal. Their confirmation rules depend on the target. * **Sensitive** tools include lead reveal, call-session creation and `start_ai_voice_agent_call`. * `start_ai_voice_agent_call` starts a real, billed call. `reveal_lead` uses the connected enrichment provider's allowance instead of Ringee calling credits; lead search itself is free. See [Safety and confirmations](/mcp/safety). ## Beyond the raw protocol <CardGroup> <Card title="ringee CLI" icon="terminal" href="/cli/overview"> Drive the same tools from a terminal or a shell-capable agent </Card> <Card title="Claude skills" icon="sparkles" href="/mcp/apps"> `/ringee` commands in Claude Code and claude.ai </Card> <Card title="ChatGPT app" icon="comments" href="/mcp/apps"> Visual Ringee cards inside ChatGPT </Card> </CardGroup> ## Next steps <Card title="Connect your first client" icon="plug" href="/mcp/connect"> Get your MCP URL from the dashboard and paste it into Claude </Card> # Safety and confirmations Source: https://docs.ringee.io/mcp/safety How Ringee guards destructive and credit-spending tools An AI assistant with access to your CRM can delete records, spend money and mint shareable links. Ringee's MCP server treats those three categories differently from ordinary reads and writes. ## Sensitivity classes | Class | Meaning | Tools | | --------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | **Read** | Never modifies state | Workspace, contact, call, campaign, analytics, callback, DNC, pipeline and AI voice-agent reads; `search_leads` | | **Write** | Ordinary modification | `switch_workspace`, contact writes, outcomes, callbacks, meetings, lead import, campaign status/add, `add_to_dnc` | | **Sensitive** | Spends credits, places a call or manages shareable access | `reveal_lead`, `create_call_session`, `update_call_session`, `start_ai_voice_agent_call` | | **Destructive** | Removes access/data or undoes a compliance block | `delete_contact`, `delete_call_session`, `delete_campaign_lead`, `remove_from_dnc` | ## MCP annotations Every tool carries the standard MCP trust hints, so a client can surface the right warning without knowing anything about Ringee: | Hint | Meaning | | ----------------- | --------------------------------------------------------------------------- | | `readOnlyHint` | The tool only reads. | | `destructiveHint` | The tool may perform irreversible updates. | | `idempotentHint` | Repeating the call with the same arguments has no extra effect. | | `openWorldHint` | The tool reaches an external system — an enrichment provider or a calendar. | `schedule_meeting`, `search_leads`, `reveal_lead` and `start_ai_voice_agent_call` are open-world tools: they sync calendars, query Apollo/Prospeo or place a real phone call. ## Hard guards in the server Some protections do not depend on the client behaving well. <AccordionGroup> <Accordion title="delete_contact requires the phone number"> Two conditions must both hold: 1. `confirm` is the literal boolean `true`; 2. `confirmPhoneNumber` exactly matches the contact's stored E.164 number. A mismatch returns an error telling the assistant to re-fetch the contact and ask the user again. This makes it very hard to delete the wrong record from a fuzzy instruction like "delete that lead". </Accordion> <Accordion title="Ownership is verified before every write"> `update_contact` and `delete_contact` re-read the contact and check it belongs to the active workspace before writing. Passing an id from another workspace fails. </Accordion> <Accordion title="Magic-link tokens are shown once"> `create_call_session` returns the `joinUrl` a single time. `get_call_session` reports whether a token is still active but never returns the token itself, so a leaked transcript cannot be replayed into a working link. </Accordion> <Accordion title="Revocation is immediate"> `delete_call_session` marks the session revoked and invalidates every active token at once. Call history is preserved — the revoke is not a data deletion. </Accordion> <Accordion title="Queue replacement is time-bounded"> `update_call_session` can replace the contact queue only before the first call. Once dialing starts, the queue is frozen so progress and attribution stay coherent. </Accordion> <Accordion title="Campaign and DNC removal require confirmation"> `delete_campaign_lead` requires `confirm: true` and refuses a lead that is locked, dialing or in a call. `remove_from_dnc` also requires `confirm: true`, because it makes a suppressed number callable again. </Accordion> <Accordion title="AI voice calls re-check operational gates"> Before dialing, Ringee verifies the organization workspace, agent ownership and readiness, caller number, user's calling permission, DNC list, available credits and the selected agent type's required variables. </Accordion> </AccordionGroup> ## Credits `start_ai_voice_agent_call` creates a real call. Ringee debits the telephony leg and the AI conversation usage when provider records settle. Bring-your-own model providers can also bill your provider account. `reveal_lead` consumes the connected enrichment provider's credits rather than Ringee calling credits. It is explicitly non-idempotent: repeating it can spend the provider's credits again. Assistants must not retry either `reveal_lead` or `start_ai_voice_agent_call` automatically after an ambiguous failure. ## What an assistant should do The server-side rules exist as a backstop. Well-behaved assistants also follow these: <Steps> <Step title="Resolve before acting"> Use `search_contacts` or `get_contact` to resolve a real id before any write. Never act on an id the user did not approve. </Step> <Step title="Read the target back"> Before a destructive action, state what will happen and to which record — name and phone number — and wait for an explicit yes. </Step> <Step title="Confirm spend"> Before `reveal_lead`, say that provider credits will be spent and for which lead. Before `start_ai_voice_agent_call`, name the agent and destination and explain that a real billed call will start. </Step> <Step title="Treat links as secrets"> Share a `joinUrl` only with the person who asked for it. Never post it into a shared channel unprompted. </Step> <Step title="Never auto-confirm"> `confirm: true` must reflect a human decision, not the assistant's own inference. </Step> </Steps> These rules ship as machine-readable guardrails in [`@ringee-io/agent`](/cli/agents), which is what the [`ringee` CLI](/cli/overview), the Claude skills and the ChatGPT app all build on. ## CLI equivalents The CLI enforces the same classes with explicit flags, so an autonomous agent cannot spend or delete by accident: | Action | Required flags | | ----------------------- | ----------------------------------------------- | | `leads reveal` | `--yes` | | `sessions create` | `--yes` | | `sessions revoke` | `--yes` | | `contacts delete` | `--confirm-phone <storedPhone>` **and** `--yes` | | `campaigns delete-lead` | `--yes` | | `dnc remove` | `--yes` | | `voice-agents call` | `--yes` | See [CLI commands](/cli/commands). # Tool reference Source: https://docs.ringee.io/mcp/tools Every tool the Ringee MCP server exposes The Ringee MCP server exposes 37 tools. Each is annotated with MCP trust hints so clients can decide when to ask for confirmation. Trust levels are colour-coded throughout this tab: <div> <span>Read</span> looks, never changes anything  ·  <span>Write</span> creates or updates a record  ·  <span>Sensitive</span> spends money or mints a shareable link  ·  <span>Destructive</span> removes access or data </div> | Tool | Type | Spends credits | | --------------------------------------------------------- | ------------------------ | ----------------- | | [`list_workspaces`](#list_workspaces) | <span>Read</span> | No | | [`switch_workspace`](#switch_workspace) | <span>Write</span> | No | | [`search_contacts`](#search_contacts) | <span>Read</span> | No | | [`get_contact`](#get_contact) | <span>Read</span> | No | | [`find_contacts_by_outcome`](#find_contacts_by_outcome) | <span>Read</span> | No | | [`list_calls`](#list_calls) | <span>Read</span> | No | | [`create_contact`](#create_contact) | <span>Write</span> | No | | [`update_contact`](#update_contact) | <span>Write</span> | No | | [`delete_contact`](#delete_contact) | <span>Destructive</span> | No | | [`log_call_outcome`](#log_call_outcome) | <span>Write</span> | No | | [`create_callback`](#create_callback) | <span>Write</span> | No | | [`schedule_meeting`](#schedule_meeting) | <span>Write</span> | No | | [`create_call_session`](#create_call_session) | <span>Sensitive</span> | No | | [`get_call_session`](#get_call_session) | <span>Read</span> | No | | [`update_call_session`](#update_call_session) | <span>Write</span> | No | | [`delete_call_session`](#delete_call_session) | <span>Destructive</span> | No | | [`search_leads`](#search_leads) | <span>Read</span> | No | | [`reveal_lead`](#reveal_lead) | <span>Sensitive</span> | Provider only | | [`import_leads_as_contacts`](#import_leads_as_contacts) | <span>Write</span> | No | | [`list_campaigns`](#list_campaigns) | <span>Read</span> | No | | [`get_campaign`](#get_campaign) | <span>Read</span> | No | | [`update_campaign_status`](#update_campaign_status) | <span>Write</span> | No | | [`list_campaign_leads`](#list_campaign_leads) | <span>Read</span> | No | | [`add_campaign_leads`](#add_campaign_leads) | <span>Write</span> | No | | [`delete_campaign_lead`](#delete_campaign_lead) | <span>Destructive</span> | No | | [`get_campaign_analytics`](#get_campaign_analytics) | <span>Read</span> | No | | [`get_call_analytics`](#get_call_analytics) | <span>Read</span> | No | | [`get_day_activity`](#get_day_activity) | <span>Read</span> | No | | [`list_callbacks`](#list_callbacks) | <span>Read</span> | No | | [`list_dnc`](#list_dnc) | <span>Read</span> | No | | [`add_to_dnc`](#add_to_dnc) | <span>Write</span> | No | | [`remove_from_dnc`](#remove_from_dnc) | <span>Destructive</span> | No | | [`list_ai_pipelines`](#list_ai_pipelines) | <span>Read</span> | No | | [`get_ai_pipeline_results`](#get_ai_pipeline_results) | <span>Read</span> | No | | [`list_ai_voice_agents`](#list_ai_voice_agents) | <span>Read</span> | No | | [`start_ai_voice_agent_call`](#start_ai_voice_agent_call) | <span>Sensitive</span> | Ringee + provider | | [`get_ai_voice_agent_call`](#get_ai_voice_agent_call) | <span>Read</span> | No | <Note> `start_ai_voice_agent_call` places a real phone call and debits Ringee credits when usage settles. `reveal_lead` uses the allowance of the connected enrichment provider instead; `search_leads` does not spend credits. </Note> ## Conventions * Phone numbers are **E.164**: `+14155552671`. * Dates and times are **ISO-8601 with a timezone offset**: `2026-05-23T14:30:00-04:00`. * Ids are UUIDs returned by other tools. Never invent one. * Paginated tools accept `page` (1-based, default `1`) and `limit` (default `10`, max `50`). *** ## Workspaces ### `list_workspaces` Lists the personal account plus every organization the user belongs to, and flags the active one. Takes no parameters. ### `switch_workspace` Changes the active workspace for every subsequent action. No re-authentication needed. <ParamField type="string"> `personal` for the user's own account, or an organization id from `list_workspaces`. An exact organization name also works. </ParamField> See [Workspaces](/mcp/workspaces) for how scoping behaves. *** ## Contacts ### `search_contacts` Searches the directory by name, phone, email, company, job title, state, website, revenue or company size. Returns id, name, phone, email and `lastCallAt`. <ParamField type="string"> Search text. Pass `*` to list **all** contacts, paginated. </ParamField> <ParamField type="number"> 1-based page number. </ParamField> <ParamField type="number"> Page size, max 50. </ParamField> Use this to resolve a `contactId` before any tool that needs one. ### `get_contact` Fetches one contact in full, including recent calls, notes, meetings and tags. <ParamField type="string (uuid)"> The contact to fetch. </ParamField> ### `find_contacts_by_outcome` Finds contacts whose calls reached given outcomes — who converted, showed interest or booked a meeting. Useful for deriving a real ICP from closed business. <ParamField type="string[]"> One or more of `meeting_booked`, `sale`, `interested`, `follow_up`, `callback_scheduled`, `not_interested`, `no_answer`, `voicemail`, `wrong_number`, `gatekeeper`. </ParamField> <ParamField type="string"> `any` (default) matches a contact with **any** call in those outcomes. `last` considers only the most recent call. </ParamField> <ParamField type="boolean"> Include contacts flagged `doNotCall` or `unsubscribed`. Defaults to `false`. </ParamField> <ParamField type="number"> 1-based page number. </ParamField> <ParamField type="number"> Page size, max 50. </ParamField> Returns ICP-relevant fields — company, job title, seniority, department, country, score, lifecycle stage — plus `lastOutcome` and `lastCallAt`. ### `create_contact` <ParamField type="string"> E.164. Must be unique within the workspace — the tool fails fast on a duplicate. </ParamField> <ParamField type="string"> Display name. </ParamField> <ParamField type="string"> Given name. </ParamField> <ParamField type="string"> Family name. </ParamField> <ParamField type="string"> Primary email. </ParamField> <ParamField type="string"> Role at the company. </ParamField> <ParamField type="string"> Company name. </ParamField> <ParamField type="string"> State or region. </ParamField> <ParamField type="string"> Company website. </ParamField> <ParamField type="string"> Company revenue or range. </ParamField> <ParamField type="string"> Headcount band. </ParamField> <ParamField type="string"> Lead source label. Defaults to `mcp`. </ParamField> <ParamField type="string"> Initial note. </ParamField> <ParamField type="string[]"> Tag UUIDs to assign. </ParamField> ### `update_contact` Patches an existing contact. Only the fields you send change. <ParamField type="string (uuid)"> The contact to update. </ParamField> <ParamField type="string[]"> **Replaces** the contact's tag set. Omit it to leave tags untouched. </ParamField> All other fields match `create_contact`. Ownership is verified before writing. ### `delete_contact` <span>Destructive</span> Soft-deletes a contact by setting `deletedAt`. <ParamField type="string (uuid)"> The contact to delete. </ParamField> <ParamField type="boolean"> Must be the literal `true`. </ParamField> <ParamField type="string"> Must exactly match the contact's stored phone number in E.164. </ParamField> <Warning> Destructive and double-guarded. Fetch the contact with `get_contact` first, read the phone number back to the user, and get an explicit "yes, delete" before calling. Assistants must never auto-confirm. </Warning> *** ## Calls and follow-ups ### `list_calls` Returns calls in full detail: direction, status, timing, duration, logged outcome and note, plus the transcription and recording URL when present. Newest first. Cost and low-level telephony fields are intentionally excluded. <ParamField type="string (uuid)"> Filter to one contact's calls. </ParamField> <ParamField type="string[]"> Only calls with one of these outcomes. </ParamField> <ParamField type="string[]"> Only calls in these states: `pending`, `ringing`, `answered`, `recording`, `completed`, `failed`. </ParamField> <ParamField type="string (ISO-8601)"> Only calls created at or after this. </ParamField> <ParamField type="string (ISO-8601)"> Only calls created at or before this. </ParamField> <ParamField type="number"> 1-based page number. </ParamField> <ParamField type="number"> Page size, max 50. </ParamField> ### `log_call_outcome` Records how a past call went. <ParamField type="string (uuid)"> An existing call belonging to the active workspace. </ParamField> <ParamField type="string"> One of the `CallOutcome` values listed under `find_contacts_by_outcome`. </ParamField> <ParamField type="string"> Free-text note. </ParamField> ### `create_callback` Schedules a reminder to call a contact back. Creates a callback task and a reminder. <ParamField type="string (uuid)"> Who to call back. </ParamField> <ParamField type="string (ISO-8601)"> Future datetime with offset. </ParamField> <ParamField type="string (uuid)"> Originating call. </ParamField> <ParamField type="string"> Free-text note. </ParamField> ### `schedule_meeting` Books a meeting. When the user has a Google or Microsoft calendar connected, the event is synced and a Meet or Teams link is generated. <ParamField type="string (uuid)"> Who the meeting is with. </ParamField> <ParamField type="string (ISO-8601)"> Start time with offset. </ParamField> <ParamField type="string"> Meeting title. </ParamField> <ParamField type="number"> Minutes. Defaults to 30. </ParamField> <ParamField type="string"> Address or video URL. </ParamField> <ParamField type="string"> Notes. </ParamField> <ParamField type="string"> Sends a calendar invite to this address. </ParamField> <ParamField type="string"> Calendar to sync with. </ParamField> <ParamField type="string (uuid)"> Originating call. </ParamField> <Info> This tool reaches an external system (calendar sync and email invites), so it is flagged `openWorldHint`. </Info> *** ## Call sessions A **call session** is a queue of contacts plus a magic link that lets someone dial through them one by one — without logging in. ### `create_call_session` <span>Sensitive</span> <ParamField type="object[]"> Ordered queue, 1 to 500 entries. Each accepts `contactId` and/or `phoneNumber` (E.164, required when `contactId` is absent), plus optional `name`, `company`, `jobTitle`, `state`, `website`, `revenue`, `companySize`. Provide `contactId` when you have it — name and phone are then looked up server-side. </ParamField> <ParamField type="string"> Session title, visible to the magic-link recipient. </ParamField> <ParamField type="string (uuid)"> Attribute calls to a campaign. </ParamField> <ParamField type="number"> Link lifetime. Defaults to 60 minutes, max 30 days. </ParamField> <ParamField type="number"> Auto-complete the session after N calls, max 500. </ParamField> <ParamField type="object"> Free-form JSON persisted with the session; not exposed to the magic-link UI. </ParamField> <Warning> Returns a `joinUrl` containing a magic-link token. The token is shown **once** and cannot be re-fetched. Share the URL exactly as returned, and only with someone authorized to call on the workspace's behalf. </Warning> ### `get_call_session` Returns safe metadata — status, contact count, calls completed, expiry, and whether a token is still active. It never exposes the raw magic link. <ParamField type="string (uuid)"> The session to inspect. </ParamField> ### `update_call_session` <ParamField type="string (uuid)"> The session to update. </ParamField> <ParamField type="string"> New title. </ParamField> <ParamField type="string (uuid) | null"> Set a campaign, or `null` to detach. </ParamField> <ParamField type="number"> Extend validity. </ParamField> <ParamField type="object[]"> Replaces the queue. Only allowed **before the first call** has started. </ParamField> <ParamField type="object"> Replace metadata. </ParamField> ### `delete_call_session` <span>Destructive</span> Revokes the session. Past calls are preserved, but every active magic link stops working immediately. <ParamField type="string (uuid)"> The session to revoke. </ParamField> <Warning> Flagged destructive — the link cannot be restored. Issue a new session instead. </Warning> *** ## Lead prospecting These tools use the workspace's connected enrichment provider. Apollo is preferred when both are connected. ### `search_leads` Searches for candidates. Results are **not** contacts yet. <ParamField type="string"> `apollo` or `prospeo`. Auto-selected when omitted. </ParamField> <ParamField type="string"> Free-text keywords. </ParamField> <ParamField type="string[]"> Job titles to include. </ParamField> <ParamField type="string[]"> Job titles to exclude. </ParamField> <ParamField type="string[]"> Seniority levels. </ParamField> <ParamField type="string[]"> Departments. </ParamField> <ParamField type="string[]"> Countries. </ParamField> <ParamField type="string[]"> Cities. </ParamField> <ParamField type="string[]"> Industries. </ParamField> <ParamField type="string[]"> Company names. </ParamField> <ParamField type="string[]"> Company domains. </ParamField> <ParamField type="string[]"> Headcount bands. </ParamField> <ParamField type="string[]"> Technologies in use. </ParamField> <ParamField type="boolean"> Only candidates with an email. </ParamField> <ParamField type="boolean"> Only candidates with a phone. </ParamField> <ParamField type="boolean"> Only verified emails. </ParamField> <ParamField type="number"> Page number. </ParamField> <ParamField type="number"> Results per page, max 25. </ParamField> Returns a `jobId` plus candidates with `externalId`, person and company summaries, and whether emails and phones are available. Pass the `jobId` to the two tools below. ### `reveal_lead` <span>Sensitive</span> <span>Spends provider credits</span> Reveals a candidate's email — and optionally a mobile phone — and upserts a Ringee contact so the lead is immediately callable. <ParamField type="string (uuid)"> From `search_leads`. </ParamField> <ParamField type="string"> The candidate to reveal. </ParamField> <ParamField type="boolean"> Also reveal a mobile phone. Costs extra provider credits. </ParamField> <Warning> Spends the enrichment provider's credits every time and is **not** idempotent. Call it only after the user explicitly picks that lead. </Warning> ### `import_leads_as_contacts` Bulk-imports candidates from a search job as Ringee contacts, deduplicated by phone number. Does **not** reveal hidden emails or phones — use `reveal_lead` first if you need those. <ParamField type="string (uuid)"> From `search_leads`. </ParamField> <ParamField type="string[]"> Candidates to import. </ParamField> <ParamField type="string[]"> Tags to assign to every imported contact. </ParamField> Returns counts, duplicates, errors and the new contact ids. *** ## Campaigns Campaign tools require an organization workspace. Organization members can read campaigns they are assigned to. Status changes and lead mutations require an organization admin. ### `list_campaigns` Lists campaign ids, names, statuses and lead counts. <ParamField type="string"> Filter name and description. </ParamField> <ParamField type="string"> `draft`, `active`, `paused` or `completed`. </ParamField> <ParamField type="number"> 1-based page number. </ParamField> <ParamField type="number"> Page size, max 50. </ParamField> ### `get_campaign` Returns one campaign's status, dialer mode, working hours, timezone, retry limits, wrap-up settings and lead count. <ParamField type="string (uuid)"> Resolve with `list_campaigns`. </ParamField> ### `update_campaign_status` Changes a campaign's lifecycle state. Allowed transitions are `draft → active`, `active → paused|completed` and `paused → active|completed`. `completed` is terminal. <ParamField type="string (uuid)"> The campaign to change. </ParamField> <ParamField type="string"> The new lifecycle state. </ParamField> Activation requires at least one lead, at least one disposition and a usable outbound number. ### `list_campaign_leads` Returns each CampaignLead id, status, attempts, last and next call time, and the linked contact. <ParamField type="string (uuid)"> The campaign to inspect. </ParamField> <ParamField type="string"> `pending`, `queued`, `locked`, `dialing`, `in_call`, `wrap_up`, `dispositioned`, `scheduled`, `completed`, `exhausted`, `dnc`, or the aggregate aliases `called` and `dead`. </ParamField> <ParamField type="number"> 1-based page number. </ParamField> <ParamField type="number"> Page size, max 50. </ParamField> ### `add_campaign_leads` Adds 1 to 200 leads. Ringee creates or reuses contacts by phone number and skips contacts already in the campaign. <ParamField type="string (uuid)"> The target campaign. </ParamField> <ParamField type="object[]"> Each entry requires `name` and E.164 `phone`. Optional fields are `email`, `company`, `jobTitle`, `state`, `website`, `revenue` and `companySize`. </ParamField> ### `delete_campaign_lead` <span>Destructive</span> Removes one lead from a campaign. Its campaign attempts and callbacks are deleted; the Contact and its general call history remain. A lead currently locked, dialing, in a call or wrapping up cannot be removed. <ParamField type="string (uuid)"> The campaign. </ParamField> <ParamField type="string (uuid)"> The CampaignLead id from `list_campaign_leads`, not the contact id. </ParamField> <ParamField type="boolean"> Must be literal `true` after explicit user approval. </ParamField> ### `get_campaign_analytics` Returns attempts, connects, conversions, contact and conversion rates, average handle time, leads by status and disposition distribution. <ParamField type="string (uuid)"> The campaign. </ParamField> <ParamField type="string (ISO-8601)"> Window start. Pair with `endDate`. </ParamField> <ParamField type="string (ISO-8601)"> Window end. Pair with `startDate`. </ParamField> <ParamField type="boolean"> Include per-agent performance. Defaults to `true`. </ParamField> <ParamField type="boolean"> Include the hourly histogram. Defaults to `false`. </ParamField> Rates are already percentages from 0 to 100. *** ## Analytics and activity ### `get_call_analytics` Returns the numbers behind the dashboard overview: call volume, answer rate, outcomes, conversions, meetings, average duration and an outcome funnel. <ParamField type="string"> `today`, `yesterday`, `7d`, `30d`, `this_month` or `last_month`. </ParamField> <ParamField type="string (ISO-8601)"> Custom start. Pair with `to`. </ParamField> <ParamField type="string (ISO-8601)"> Custom end. Pair with `from`. </ParamField> <ParamField type="string"> Campaign UUID, or literal `none` for calls outside every campaign. </ParamField> <ParamField type="string"> Restrict every metric to one call outcome. </ParamField> <ParamField type="string"> `personal` or `organization`. </ParamField> <ParamField type="string (uuid)"> Narrow organization results to one member; admin only. </ParamField> <ParamField type="string[]"> Any of `kpis`, `funnel`, `by-outcome`, `over-time`, `best-time-of-day`, `agents`. Defaults to the first three. </ParamField> ### `get_day_activity` Returns calls, callbacks and meetings for one calendar day. <ParamField type="string"> Calendar date as `YYYY-MM-DD`. </ParamField> <ParamField type="string"> Offset such as `-04:00`. Defaults to UTC. </ParamField> <ParamField type="string"> Campaign UUID, or `none` for calls outside campaigns. </ParamField> <ParamField type="string[]"> Only calls with these outcomes. </ParamField> <ParamField type="boolean"> Defaults to `true`. </ParamField> <ParamField type="boolean"> Defaults to `true`. </ParamField> <ParamField type="number"> Maximum calls, default 50 and max 100. </ParamField> Always send the user's offset when “today” or “yesterday” must mean their local day. ### `list_callbacks` Lists callbacks soonest first, including their contact and originating campaign. <ParamField type="string"> `scheduled`, `due`, `in_progress`, `completed`, `missed` or `cancelled`. </ParamField> <ParamField type="number"> 1-based page number. </ParamField> <ParamField type="number"> Page size, max 50. </ParamField> *** ## Do Not Call ### `list_dnc` Lists suppressed numbers with their reason, source and creation time. <ParamField type="string"> Phone-number fragment. </ParamField> <ParamField type="number"> 1-based page number. </ParamField> <ParamField type="number"> Page size, max 50. </ParamField> ### `add_to_dnc` Suppresses one or more numbers. Every future dial to them from this workspace is blocked. Existing entries are reported as duplicates. <ParamField type="string[]"> 1 to 500 E.164 numbers. </ParamField> <ParamField type="string"> Why they were suppressed, max 500 characters. </ParamField> ### `remove_from_dnc` <span>Destructive</span> Releases a number so it becomes callable again. <ParamField type="string"> E.164 number verified with `list_dnc`. </ParamField> <ParamField type="boolean"> Must be literal `true` after the user explicitly requests removal. </ParamField> *** ## AI pipelines These read-only tools expose pipeline state and results. They require an organization admin, except a valid personal context for a freelancer. ### `list_ai_pipelines` Lists `follow_up_recommendations`, `script_optimization` and `objection_intelligence`, including enabled contexts, pending actions and newly eligible calls. Takes no parameters. ### `get_ai_pipeline_results` <ParamField type="string"> Pipeline type returned by `list_ai_pipelines`. </ParamField> <ParamField type="string"> `campaign`, `organization_outside_campaign` or `personal`. </ParamField> <ParamField type="string (uuid)"> Required for a campaign context. </ParamField> <ParamField type="string"> `pending`, `completed`, `dismissed` or `snoozed`. Defaults to `pending`. </ParamField> Returns context activation, last run, confidence, recommended actions and—when applicable—ranked objections with recommended responses and trend data. *** ## AI Voice Agents These tools require an organization workspace. See the focused [AI Voice Agents MCP guide](/voice-agents/mcp) for the complete flow. ### `list_ai_voice_agents` Lists agents, caller numbers and accepted variables by type. <ParamField type="number"> Defaults to 20, max 100. </ParamField> ### `start_ai_voice_agent_call` <span>Sensitive</span> <span>Real billed call</span> Starts a server-originated AI conversation and returns immediately with a call id. <ParamField type="string (uuid)"> Resolve with `list_ai_voice_agents`. </ParamField> <ParamField type="string"> E.164 destination. </ParamField> <ParamField type="string (uuid)"> Eligible caller number when no default can be resolved. </ParamField> <ParamField type="object"> String values accepted by the agent type. </ParamField> <ParamField type="object"> Free-form values echoed in the result. </ParamField> <Warning> This is non-idempotent. Confirm the exact call with the user and do not retry it blindly. </Warning> ### `get_ai_voice_agent_call` Returns status, outcome, summary, sentiment, extracted data and metadata for one AI voice agent call. <ParamField type="string (uuid)"> From `start_ai_voice_agent_call`. </ParamField> *** ## Placing calls Human-operated Ringee calls are placed in the user's browser or mobile app over WebRTC. There is no MCP tool that remotely presses the human dialer. Use `create_call_session` and share the `joinUrl` when a person should dial. Use `start_ai_voice_agent_call` only when the user explicitly wants a configured AI Voice Agent to place and conduct the call. ## Next steps <CardGroup> <Card title="Workspaces" icon="building" href="/mcp/workspaces"> How personal and organization scoping works </Card> <Card title="Safety" icon="shield-check" href="/mcp/safety"> Confirmation rules for sensitive actions </Card> </CardGroup> # Workspaces Source: https://docs.ringee.io/mcp/workspaces How personal and organization scoping works over MCP Every read and write through MCP is scoped to exactly one **workspace**: the user's personal account, or one organization they belong to. ## Why this exists A Ringee user often has both a personal account and membership in one or more organizations. Contacts, calls, sessions and leads live in one of those scopes — never both. Without an explicit active workspace, an assistant asked to "find my contacts" cannot know which set to search. ## The active workspace The MCP connection starts in the workspace encoded in your URL: | URL | Starting workspace | | ---------------------------------------- | ------------------ | | `/api/mcp/<userId>/sse` | Personal | | `/api/mcp/<userId>/<organizationId>/sse` | That organization | From there the assistant can move freely: ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} list_workspaces → see every option and which one is active switch_workspace → change it ``` <Info> Switching takes effect for **subsequent** requests. It is persisted server-side, so it survives across sessions and clients until it is changed again. </Info> ## Typical exchange ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} User: How many contacts do I have at Acme? Assistant: (list_workspaces) You have a personal account and two organizations: Acme Sales and Northwind. You're currently in your personal account. Switching to Acme Sales… (switch_workspace → "Acme Sales") (search_contacts "*") Acme Sales has 1,284 contacts. ``` `switch_workspace` accepts an organization id or an exact, case-insensitive organization name, so the assistant can pass what the user actually said. Pass `personal` to return to the personal account. <Note> The id is resolved against the user's real memberships. An id the user does not belong to is rejected — an assistant cannot switch into a workspace the user has no access to. </Note> ## What scoping affects | Area | Behavior | | ---------------------- | --------------------------------------------------------------------------------- | | Contacts | Searches, creates and updates apply only to the active workspace. | | Calls | `list_calls` returns that workspace's calls only. | | Call sessions | Created under the active workspace, and only its sessions can be read or revoked. | | Leads | Search jobs, reveals and imports belong to the active workspace. | | Caller IDs and credits | Resolved from the active workspace. | ## Organization roles Ringee organizations have roles (admin, member, freelancer) that gate parts of the product. MCP goes through the same service layer as the dashboard, so those rules apply identically — an action the user cannot perform in the UI fails over MCP too. ## Duplicate contacts across workspaces Phone-number uniqueness is enforced **within** a workspace, not across them. The same person can exist as a contact in your personal account and in an organization; they are separate records with separate call histories. <Tip> If an assistant reports "contact already exists" for a number you cannot find, check the active workspace first — you are probably looking at a different one. </Tip> ## Next steps <CardGroup> <Card title="Tool reference" icon="list" href="/mcp/tools"> `list_workspaces` and `switch_workspace` parameters </Card> <Card title="Safety" icon="shield-check" href="/mcp/safety"> Confirmation rules for sensitive actions </Card> </CardGroup> # How to Add a New Provider Source: https://docs.ringee.io/providers/add-provider Guide for integrating a new telephony provider into Ringee Ringee's telephony layer is designed to be **provider-agnostic**. This guide walks you through adding a new telephony provider. ## Requirements Before adding a new provider, ensure the service supports: * **WebRTC** for browser-based calling * **REST API** for call management (create, hangup, transfer, etc.) * **Phone number provisioning** via API * **Webhook callbacks** for call events (ringing, answered, ended) * **Call recording** (optional but recommended) ## Architecture A telephony provider in Ringee consists of the following components: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart TD; classDef module fill:#15803D,color:white,stroke:#fff service[Provider Service]:::module controller[Webhook Controller]:::module config[Config / Env Vars]:::module frontend[Frontend WebRTC Client]:::module config --> service service --> controller service --> frontend ``` ### 1. Provider Service (Backend) Create a new service in the backend that implements the core telephony operations: * **Make a call** — Initiate an outbound call * **Receive a call** — Handle an inbound call via webhooks * **End a call** — Hang up an active call * **Record a call** — Start/stop recording * **Buy a number** — Purchase a phone number * **List available numbers** — Search for available phone numbers by country ### 2. Webhook Controller Create a controller to handle incoming webhook events from the provider: * **Call initiated** — A call has started * **Call answered** — The recipient picked up * **Call ended** — The call has been terminated * **Recording ready** — A call recording is available for download ### 3. Frontend WebRTC Client Integrate the provider's WebRTC SDK in the frontend to handle browser-based calling: * Initialize the WebRTC connection * Handle microphone access * Display call state (ringing, connected, ended) ### 4. Configuration Add the required environment variables for the new provider to the `.env.example` file and document them. ## Steps <Steps> <Step title="Create the provider service"> Add a new service module in the backend with the core telephony operations listed above. </Step> <Step title="Add webhook handlers"> Create webhook endpoint(s) to receive call events from the provider. </Step> <Step title="Integrate the WebRTC SDK"> Add the provider's WebRTC client library to the frontend and wire it into the call UI. </Step> <Step title="Add environment variables"> Define all required API keys and configuration variables in `.env.example`. </Step> <Step title="Write documentation"> Add a new page in `docs/providers/your-provider.mdx` with setup instructions, and update `docs.json` to include it in the navigation. </Step> <Step title="Submit a PR"> Open a pull request with your changes. Make sure to include tests and documentation. </Step> </Steps> <Info> We welcome contributions! If you're adding a new provider, feel free to open an issue first to discuss the integration approach. </Info> # Provider Overview Source: https://docs.ringee.io/providers/overview How telephony providers work in Ringee ## How telephony works Ringee uses **telephony providers** to make and receive phone calls. A provider is a service that connects your browser to the global phone network (PSTN) using WebRTC technology. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef app fill:#15803D,color:white,stroke:#fff classDef provider fill:#22C55E,color:white,stroke:#fff classDef network fill:#86EFAC,color:black,stroke:#fff browser[Your Browser]:::app backend[Ringee Backend]:::app provider[Telephony Provider]:::provider pstn[Phone Network - PSTN]:::network browser -- WebRTC --> provider backend -- REST API --> provider provider -- SIP --> pstn ``` When you make a call in Ringee: 1. Your browser establishes a **WebRTC** session with the telephony provider 2. The Ringee backend uses the provider's **REST API** to manage the call (create, transfer, record, etc.) 3. The provider bridges the WebRTC connection to the **PSTN** (public phone network) ## Current Providers | Provider | Status | Protocol | Notes | | --------------------------- | -------- | ----------------- | -------------------------------- | | [Telnyx](/providers/telnyx) | ✅ Active | WebRTC + REST API | Default and recommended provider | ## Features per provider All providers must support these core features: * **Outbound calls** — Make calls to any phone number worldwide * **Inbound calls** — Receive calls on purchased phone numbers * **Call recording** — Record calls and store them in R2 * **Phone number management** — Purchase and manage phone numbers via API * **WebRTC** — Browser-based calling without any installed apps ## Want to add a new provider? See [How to add a new provider](/providers/add-provider) for a guide on integrating additional telephony providers into Ringee. # Telnyx Source: https://docs.ringee.io/providers/telnyx Set up Telnyx as your telephony provider [Telnyx](https://telnyx.com) is Ringee's default and recommended telephony provider. It provides WebRTC-based calling, phone number management, and call recording through a powerful API. ## Setup <Steps> <Step title="Create a Telnyx account"> Go to [telnyx.com](https://telnyx.com) and sign up for an account. </Step> <Step title="Get your API key"> 1. In the Telnyx portal, go to **API Keys** 2. Copy your **API Key** (starts with `KEY...`) </Step> <Step title="Create a Credential Connection (WebRTC)"> Ringee uses Telnyx's **Credential Connections** for WebRTC calling: 1. Go to **Voice** → **Connections** → **Credential Connections** 2. Click **Add Credential Connection** 3. Configure the connection: * **Connection Name**: `ringee-webrtc` * **Username**: Choose a SIP username * **Password**: Choose a SIP password 4. Copy the **Connection ID**, **Username**, and **Password** </Step> <Step title="Buy a phone number"> 1. Go to **Numbers** → **Search & Buy** 2. Search for a number in your desired country 3. Purchase the number 4. Assign the number to your credential connection </Step> <Step title="Configure webhooks"> Set up webhooks to receive call events: 1. Go to your credential connection settings 2. Set the **Webhook URL** to: `https://your-backend-url/api/telnyx/webhook` 3. Events will be sent for: call initiated, answered, ended, recording completed, etc. </Step> <Step title="Configure environment variables"> Add the following to your `.env` file: ```env theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} TELNYX_API_KEY="KEYyour_api_key_here" TELNYX_CONNECTION_ID="your_connection_id" TELNYX_SIP_USER="your_sip_username" TELNYX_SIP_PASSWORD="your_sip_password" NEXT_PUBLIC_TELNYX_LOGIN="your_sip_username" NEXT_PUBLIC_TELNYX_PASSWORD="your_sip_password" ``` </Step> </Steps> ## Environment Variables | Variable | Required | Description | | ----------------------------- | -------- | -------------------------------------------------------------------- | | `TELNYX_API_KEY` | Yes | Your Telnyx API key | | `TELNYX_CONNECTION_ID` | Yes | Credential connection ID for WebRTC | | `TELNYX_SIP_USER` | Yes | SIP username for the credential connection | | `TELNYX_SIP_PASSWORD` | Yes | SIP password for the credential connection | | `NEXT_PUBLIC_TELNYX_LOGIN` | Yes | SIP login exposed to the frontend (same as `TELNYX_SIP_USER`) | | `NEXT_PUBLIC_TELNYX_PASSWORD` | Yes | SIP password exposed to the frontend (same as `TELNYX_SIP_PASSWORD`) | <Note> The `NEXT_PUBLIC_` variables are needed by the frontend to establish WebRTC connections directly from the browser to Telnyx. These are the same values as the SIP credentials. </Note> ## How it works ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} sequenceDiagram participant Browser participant Ringee Backend participant Telnyx participant Phone Browser->>Telnyx: WebRTC connect (SIP credentials) Browser->>Telnyx: Initiate call Telnyx->>Phone: Ring phone via PSTN Phone-->>Telnyx: Answer Telnyx-->>Browser: Call connected Telnyx->>Ringee Backend: Webhook: call.answered Note over Browser,Phone: Voice call in progress Browser->>Telnyx: Hang up Telnyx->>Ringee Backend: Webhook: call.hangup ``` ## Features | Feature | Supported | | ------------------------ | --------- | | Outbound calls | ✅ | | Inbound calls | ✅ | | Call recording | ✅ | | Phone number purchase | ✅ | | Number search by country | ✅ | | WebRTC (browser calling) | ✅ | | Call transfer | ✅ | ## Troubleshooting <AccordionGroup> <Accordion title="Calls not connecting"> Verify that your SIP credentials match between `TELNYX_SIP_USER` / `TELNYX_SIP_PASSWORD` and `NEXT_PUBLIC_TELNYX_LOGIN` / `NEXT_PUBLIC_TELNYX_PASSWORD`. Also ensure your credential connection is active in the Telnyx portal. </Accordion> <Accordion title="Webhooks not received"> Make sure your webhook URL is publicly accessible. For local development, use a tunneling service like [ngrok](https://ngrok.com) to expose your backend: `ngrok http 3000` </Accordion> <Accordion title="No phone numbers available"> Phone number availability varies by country. Try searching for different area codes or number types (local vs. toll-free). </Accordion> </AccordionGroup> # Quickstart Source: https://docs.ringee.io/quickstart Get started with Ringee installation ## Self-hosted installation options Choose the best option for your use case: <CardGroup> <Card title="Docker Compose" icon="docker" href="/installation/docker-compose"> Recommended for **production** — everything in one command </Card> <Card title="Development" icon="code" href="/installation/development"> Recommended for **developers** — full local setup </Card> </CardGroup> ## Other options <CardGroup> <Card title="Docker (standalone)" icon="docker" href="/installation/docker"> Run individual containers from the command line </Card> </CardGroup> ## Quick start with Docker Compose If you just want to get Ringee running as fast as possible: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} git clone https://github.com/ringee-io/ringee-app.git cd ringee cp .env.example .env # Fill in your API keys docker-compose -f docker-compose.app.yml up --build -d ``` Then open: * **Frontend** → [http://localhost:4200](http://localhost:4200) * **Backend API** → [http://localhost:3000/api](http://localhost:3000/api) <Warning> You **must** configure your API keys in the `.env` file before running. See the [Configuration Reference](/configuration/reference) for details on each variable. </Warning> ## Next steps <CardGroup> <Card title="Configuration Reference" icon="gear" href="/configuration/reference"> Learn about all environment variables </Card> <Card title="Telnyx Setup" icon="phone" href="/providers/telnyx"> Configure your telephony provider </Card> <Card title="Build on Ringee" icon="puzzle-piece" href="/build-on-ringee"> API, SDK, MCP and CLI — which one you need </Card> <Card title="How it works" icon="diagram-project" href="/howitworks"> The architecture behind Ringee </Card> <Card title="AI Voice Agents" icon="robot" href="/voice-agents/quickstart"> Create an agent that can place and analyze calls </Card> </CardGroup> # AI Voice Agents API Source: https://docs.ringee.io/voice-agents/api-reference Public API endpoints for listing AI Voice Agents, starting calls and reading results Use the AI Voice Agents Public API to start autonomous calls from your server and read their structured results. Agent creation, configuration, knowledge sources and browser testing remain in the Ringee dashboard. ## Authentication and base URL Authenticate every request with the `cik_live_…` API key from an active [Custom Integration](/api/authentication): ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl "https://api.ringee.io/api/v1/ai-voice-agents" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" ``` You can instead send the same key as a Bearer credential: ```http theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} Authorization: Bearer cik_live_xxxxxxxx… ``` Send the key in exactly one of those headers. A request containing both is rejected. <Warning> A Custom Integration API key is a server-side secret. Never expose it in browser code, logs or a client application. </Warning> For self-hosting, replace `https://api.ringee.io` with `PUBLIC_BACKEND_URL`. The integration determines the user and organization; the API never accepts a client-supplied workspace id as authorization. AI Voice Agents require an organization-scoped Custom Integration. A personal-workspace key is valid for other Public API capabilities but returns `403` on these routes. ## Endpoints | Method | Path | Purpose | | ------ | ---------------------------------------- | ---------------------------------------- | | `GET` | `/api/v1/ai-voice-agents` | List agents and their accepted variables | | `GET` | `/api/v1/ai-voice-agents/phone-numbers` | List eligible caller numbers | | `POST` | `/api/v1/ai-voice-agents/:agentId/calls` | Start one real, billed call | | `GET` | `/api/v1/ai-voice-agents/:agentId/calls` | List calls for one agent | | `GET` | `/api/v1/ai-voice-agents/calls/:callId` | Read one normalized call result | ## List agents ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl "https://api.ringee.io/api/v1/ai-voice-agents?page=1&limit=20&type=appointment_booking" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" ``` `page` defaults to `1`; `limit` defaults to `20` and cannot exceed `100`. `type` can be `appointment_booking` or `reminders_notifications`. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "data": [ { "id": "7c819553-25be-43b5-9fc6-58effe1569f8", "name": "Maya", "type": "appointment_booking", "status": "active", "voice": "Carolina", "callCount": 14, "callerNumberId": "1c93cab1-e97a-42e0-9ddf-e7a87cc950fd", "callsFrom": "+13055550100", "createdAt": "2026-09-04T14:00:00.000Z", "updatedAt": "2026-09-04T14:30:00.000Z" } ], "total": 1, "page": 1, "limit": 20, "callerNumbers": [ { "id": "1c93cab1-e97a-42e0-9ddf-e7a87cc950fd", "phoneNumber": "+13055550100", "isoCountry": "US", "kind": "purchased" } ], "variablesByType": { "appointment_booking": [ { "key": "first_name", "label": "First name", "required": true, "description": "The first name of the person being called." } ] } } ``` The response excludes provider identifiers, credential references and callback secrets. Resolve `agentId`, `from_number_id` and required variables from this response before starting a call. ## List caller numbers `GET /api/v1/ai-voice-agents/phone-numbers` returns the same `callerNumbers` entries as the list response, without the surrounding pagination object. ## Start a call ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl -X POST "https://api.ringee.io/api/v1/ai-voice-agents/<agentId>/calls" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" \ -H "Content-Type: application/json" \ -d '{ "to": "+13055550123", "from_number_id": "1c93cab1-e97a-42e0-9ddf-e7a87cc950fd", "variables": { "first_name": "Carlos", "reason": "Product demo" }, "metadata": { "external_id": "crm-contact-42" } }' ``` | Field | Required | Description | | ---------------- | -------- | --------------------------------------------------------------------------------------------------- | | `to` | Yes | Destination number. Ringee validates and normalizes it before dialing. | | `from_number_id` | No | Eligible caller number. Required if the agent has no assigned or default number. | | `variables` | No | String values defined by the agent type. Every required variable must be supplied. | | `metadata` | No | Arbitrary correlation data echoed in the result. `external_id` also propagates to related webhooks. | The response returns immediately: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "id": "8ddf384a-9b9d-44dd-a120-23395b1f4c98", "status": "initiating" } ``` `id` is the AI Voice Agent call id. Use it with the result endpoints. Outbound webhooks identify the underlying telephony call as `data.callId`; `call.outcome.updated` also includes the start-response id as `data.agentCallId`. Use `metadata.external_id` when you need one correlation id across every event type. Starting a call is non-idempotent. Each successful request creates a new call and can incur real usage. Do not automatically retry an ambiguous response. ## List and read calls List one agent's calls with `GET /api/v1/ai-voice-agents/:agentId/calls?page=1&limit=20`, or read a known result directly: ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl "https://api.ringee.io/api/v1/ai-voice-agents/calls/<callId>" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" ``` Both routes return the normalized public result shape: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "call_id": "8ddf384a-9b9d-44dd-a120-23395b1f4c98", "status": "completed", "outcome": "meeting_booked", "summary": "Carlos agreed to a product demo on Friday at 10:30 AM.", "sentiment": "positive", "extracted_data": { "budget_range": "$5k-$10k" }, "metadata": { "external_id": "crm-contact-42" } } ``` The list route wraps results in `{ data, total, page, limit }`. Post-call analysis can arrive shortly after the phone status becomes terminal, so poll again if `summary`, `sentiment` or `extracted_data` is still empty. ## Receive results by webhook Polling and outbound webhooks can be used together. Subscribe the same Custom Integration to the events you need: | Event | What it reports | | ---------------------- | --------------------------------------------------------------- | | `call.completed` | The terminal phone detail for a completed or unanswered call | | `call.failed` | A terminal technical failure | | `call.outcome.updated` | The normalized public outcome plus `data.call` telephony detail | | `callback.created` | A callback the live agent successfully scheduled | | `meeting.created` | A meeting the live agent successfully booked | | `recording.ready` | The processed recording | When the start request contains `metadata.external_id` or `metadata.externalId`, each call-linked event exposes the value as `data.externalId`. It also includes `data.user` and the AI Voice Agent reference under `data.agent` when those actors can be resolved. <Note> The result API and webhook contract use the same canonical outcome names, including `meeting_booked` and `callback_scheduled`. Ringee normalizes the historical names `appointment_booked` and `callback_requested` before returning them. </Note> ## Common errors | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------- | | `400` | Invalid pagination, number or variables; no usable caller number; the agent is not ready; or provider failure | | `401` | Missing, malformed, unknown or disabled Custom Integration API key, or both supported auth headers were sent | | `403` | The integration is personal-scoped, calling is disabled, the number is on DNC or credits are unavailable | | `404` | The agent, call or caller number does not belong to the integration's organization | ## Configuration stays in the dashboard The Public API intentionally exposes execution, not agent administration. Create, edit, enable, disable, test and delete agents in the Ringee dashboard, then use their ids from `GET /api/v1/ai-voice-agents` in your server integration. ## Next steps <CardGroup> <Card title="Authentication" icon="key" href="/api/authentication"> Create and protect a Custom Integration API key </Card> <Card title="Calls and results" icon="phone" href="/voice-agents/calls"> Understand status, outcomes, polling and billing </Card> <Card title="Outbound webhooks" icon="webhook" href="/api/outbound-webhooks"> Receive terminal, outcome and artifact events </Card> </CardGroup> # Calls and results Source: https://docs.ringee.io/voice-agents/calls Start AI voice agent calls safely and interpret their asynchronous results Starting an AI voice agent call creates a normal Ringee outbound call plus the voice-agent record that tracks the conversation and analysis. ## Pre-call checks Ringee refuses the call unless all of these are true: * an organization workspace is active; * the agent belongs to that workspace and is ready; * outbound calling is enabled for the user; * the destination is not on the workspace's Do Not Call list; * the workspace has available credits, unless a free-call trial applies; * the chosen caller number is eligible for AI voice agent calls; * every required agent variable is present and no unknown variable was supplied. The destination is normalized to E.164 before dialing. MCP and CLI require E.164 at their own input boundary. ## Start a call ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "to": "+13055550123", "from_number_id": "1c93cab1-e97a-42e0-9ddf-e7a87cc950fd", "variables": { "first_name": "Carlos", "reason": "Product demo" }, "metadata": { "external_id": "crm-contact-42" } } ``` `metadata` is opaque caller data. Ringee stores it on the voice-agent call and echoes it back in the result, which makes it useful for CRM ids and workflow correlation. When it includes `external_id` or `externalId`, Ringee also exposes that value as `data.externalId` on every related Custom Integration webhook. If the destination does not match an existing contact, Ringee creates one when possible. `first_name`, `last_name` and `email` fill the new contact's identity. A failure to create that convenience record does not block the requested call. <Warning> Starting a call is non-idempotent and bills real usage. Do not automatically retry an ambiguous response. First check whether a call id was returned or whether the call appears in Ringee history. </Warning> ## Call status Status describes the phone leg, not what the conversation achieved. | Status | Meaning | | ------------- | ----------------------------------------- | | `created` | Ringee reserved the call record | | `initiating` | The provider accepted the dial request | | `ringing` | The destination is ringing | | `in_progress` | The call connected | | `completed` | The connected conversation ended | | `no_answer` | The destination did not answer | | `busy` | The destination was busy | | `voicemail` | Voicemail was detected or reported | | `failed` | The call could not be placed or completed | ## Outcomes Outcome describes what the conversation achieved. A `completed` call can still have `not_interested` or `unknown` as its outcome. | Outcome | Meaning | | -------------------- | -------------------------------------------------------- | | `meeting_booked` | The booking tool created a meeting | | `confirmed` | The person confirmed or acknowledged the notification | | `cannot_attend` | The person cannot make the appointment | | `callback_scheduled` | The callback tool scheduled another call | | `not_interested` | The person declined | | `no_answer` | The call was not answered, was busy or reached voicemail | | `no_conversation` | The call connected without a meaningful exchange | | `wrong_number` | The person said the number or identity was wrong | | `unknown` | The analysis could not classify the result | Historical `appointment_booked` and `callback_requested` values are normalized to `meeting_booked` and `callback_scheduled` before Ringee returns a result. ## Polling Use the call id returned by the start request: <CodeGroup> ```bash CLI theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents call-result <callId> --json ``` ```json MCP theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "name": "get_ai_voice_agent_call", "arguments": { "callId": "<callId>" } } ``` ```bash API theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl "https://api.ringee.io/api/v1/ai-voice-agents/calls/<callId>" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" ``` </CodeGroup> Stop polling once the status is terminal and the analysis fields your workflow needs are populated. Analysis can arrive shortly after the phone leg ends. ## Webhook lifecycle An AI Voice Agent call uses the same signed outbound webhook pipeline as a human call. Depending on what happened, subscribed integrations can receive a terminal `call.completed` or `call.failed`, a later `call.outcome.updated`, and separate `callback.created`, `meeting.created` or `recording.ready` events. Each call-linked event carries the Ringee `callId`. When available, it also carries `data.user`, `data.agent` and your `data.externalId`. Deliveries are independent and ordering is not guaranteed, so upsert by `callId` and deduplicate by `eventId`. The id returned when you start the call identifies the AI Voice Agent call and is the `call_id` used by result polling. It is not the same as the telephony `data.callId`; `call.outcome.updated` exposes it separately as `data.agentCallId`. ## Billing An AI voice agent call has two provider-cost components: 1. the ordinary telephony leg; 2. the conversation engine and its model usage. Ringee settles both from provider usage records and debits each component exactly once. `AI_VOICE_AGENT_PROFIT_MARGIN` applies to the AI component; normal call margin settings apply to the voice leg. Bring-your-own model providers also bill the model usage to your provider account. The self-hosted `AI_VOICE_AGENT_MAX_CALL_SECONDS` value places a hard ceiling on one call. ## Next steps <CardGroup> <Card title="MCP" icon="robot" href="/voice-agents/mcp"> Let an assistant place and monitor a call </Card> <Card title="Self-hosting" icon="gear" href="/voice-agents/self-hosting"> Configure callbacks, models, limits and storage </Card> <Card title="Webhook events" icon="webhook" href="/api/events-reference"> Map call, outcome, meeting and recording payloads </Card> </CardGroup> # AI Voice Agents from the CLI Source: https://docs.ringee.io/voice-agents/cli Run AI voice agent calls from a terminal or shell-capable agent The `ringee` CLI wraps the three AI Voice Agent MCP tools and adds a mandatory `--yes` gate before it starts a real call. ## Configure an organization workspace ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} npm i -g ringee export RINGEE_MCP_URL="https://api.ringee.io/api/mcp/<userId>/<organizationId>/sse" ringee config check ``` AI Voice Agents fail in a personal workspace. See [CLI configuration](/cli/configuration) if you build the URL from `RINGEE_BACKEND_URL`, `RINGEE_USER_ID` and `RINGEE_ORG_ID` instead. ## List agents ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents list ringee voice-agents list --json ``` The readable output includes: * agent id, type, status, voice and call count; * the default caller number, when assigned; * accepted variables, with required values marked `*`; * eligible caller-number ids for `--from`. ## Start a call ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents call <agentId> \ --to +13055550123 \ --from <numberId> \ --var first_name=Carlos \ --var reason="Product demo" \ --external-id crm-contact-42 \ --yes ``` | Option | Required | Meaning | | -------------------- | --------: | --------------------------------------------------------------- | | `--to <phone>` | Yes | E.164 destination | | `--from <numberId>` | Sometimes | Eligible Ringee number when the agent has no resolvable default | | `--var <name=value>` | By type | Dynamic variable; repeat the flag for several values | | `--external-id <id>` | No | Stored as `metadata.external_id` and echoed in the result | | `-y, --yes` | Yes | Confirms the real, billed call | Values may contain `=`; the CLI splits each `--var` only at the first one. <Warning> Without `--yes`, the command exits non-zero and no call is placed. Do not add the flag in an autonomous loop until a human has approved this exact call. </Warning> ## Read the result ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents call-result <callId> ringee voice-agents call-result <callId> --json ``` The human-readable form prints status, outcome, sentiment, summary and extracted fields. JSON also preserves `metadata` for workflow correlation. ## Script safely ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} result=$(ringee voice-agents call <agentId> \ --to +13055550123 \ --var first_name=Carlos \ --yes \ --json) call_id=$(printf '%s' "$result" | jq -r '.callId') ringee voice-agents call-result "$call_id" --json ``` Do not automatically repeat the start command when its result is ambiguous. A retry can place a second call. ## Next steps <CardGroup> <Card title="Complete CLI reference" icon="terminal" href="/cli/commands"> Every Ringee command and option </Card> <Card title="Calls and results" icon="phone" href="/voice-agents/calls"> Status, outcomes, analysis and billing </Card> </CardGroup> # Configure an agent Source: https://docs.ringee.io/voice-agents/configuration Choose an agent type, model, voice, caller number and conversation behavior An AI Voice Agent is a Ringee-owned configuration projected to the voice provider. Saving an agent rebuilds that provider configuration from the stored row, its type blueprint and the active workspace. ## Agent types and variables Variables describe the person and the reason for one call. Required values must be present and unknown variable names are rejected. ### Appointment Booking | Variable | Required | Meaning | | -------------------- | -------: | ------------------------------------------------------ | | `first_name` | Yes | First name of the person being called | | `last_name` | No | Last name | | `email` | No | Address to confirm and use for the calendar invitation | | `reason` | No | Why you are calling, in one line | | `additional_context` | No | Anything else the agent should know | Possible outcomes: `meeting_booked`, `callback_scheduled`, `not_interested`, `no_answer`, `no_conversation`, `wrong_number`, `unknown`. The agent may offer only slots returned by Ringee's live availability tool and may report a meeting as booked only after the booking tool succeeds. Google and Microsoft calendars are optional sync targets; their absence does not block the agent. ### Reminders & Notifications | Variable | Required | Meaning | | -------------------- | -------: | ----------------------------------------------- | | `first_name` | Yes | First name of the person being called | | `last_name` | No | Last name | | `notification` | Yes | The reminder, confirmation or update to deliver | | `appointment_date` | No | Date being confirmed | | `appointment_time` | No | Time being confirmed | | `additional_context` | No | Anything else the agent should mention | Possible outcomes: `confirmed`, `cannot_attend`, `callback_scheduled`, `not_interested`, `no_answer`, `no_conversation`, `wrong_number`, `unknown`. This type does not reschedule an appointment. When the person needs a change or asks for a human, it creates a human-support notification for the workspace. ## Model choices | Choice | Credential | Billing location | | ----------- | ---------------------- | ------------------------------------------------ | | `ringee` | None | Included in the voice-agent usage Ringee settles | | `openai` | Your OpenAI API key | Your OpenAI account | | `anthropic` | Your Anthropic API key | Your Anthropic account | | `google` | Your Google AI API key | Your Google account | The exact model behind each choice comes from the self-hosted deployment's `AI_VOICE_AGENT_*_MODEL` settings and is returned by `GET /api/ai-voice-agents/models`. <Warning> An API key is write-only. Ringee never returns it and does not store the plaintext key in its database. To replace it, submit a new key. </Warning> ## Voices and language Ringee curates conversational voices in English (`en`), Spanish (`es`), Portuguese (`pt`), French (`fr`), German (`de`) and Italian (`it`). Up to 8 voices are returned per locale. The selected voice's language controls the type's default greeting and instructions. Use the preview endpoint or the dashboard player before the first real call. Voice previews are generated audio and can incur provider usage for the deployment, so Ringee caches them in the API process. ## Company context Each agent can carry its own company name, website and description. When those values are empty, Ringee falls back to the workspace company profile. This lets one organization operate agents for multiple brands without changing the shared fallback. Ringee can generate a draft description from a public website. The URL fetch rejects private and local network targets. ## Caller number You can assign a default Ringee number to the agent or choose one on each call. At call time Ringee resolves the caller number in this order: 1. `fromNumberId` supplied for this call; 2. the number assigned to the agent; 3. the workspace's only eligible number, but only when exactly one exists. If several numbers are available and no choice can be resolved, Ringee refuses the call instead of guessing. ## Conversation settings You can change: * when the assistant speaks: `assistant_speaks_first`, `assistant_generates_greeting` or `assistant_waits_for_user`; * the greeting; * the main instructions; * whether post-conversation instructions run; * post-conversation instructions. Safety instructions supplied by the agent type remain in effect when you replace the editable prompt. For example, an Appointment Booking agent still cannot invent availability. ## Status lifecycle | Status | Meaning | | ---------- | ---------------------------------------------------------------- | | `draft` | More required setup is needed | | `active` | Ready to place calls | | `disabled` | Deliberately prevented from placing calls | | `error` | Provider synchronization failed; `lastError` contains the reason | Saving an eligible draft normally activates it. A disabled agent stays disabled after edits until you explicitly activate it. Call readiness is checked again immediately before every call. ## Next steps <CardGroup> <Card title="Knowledge and analysis" icon="book" href="/voice-agents/knowledge-analysis"> Ground responses and extract structured data </Card> <Card title="Calls and results" icon="phone" href="/voice-agents/calls"> Trigger calls and interpret their lifecycle </Card> </CardGroup> # Knowledge and analysis Source: https://docs.ringee.io/voice-agents/knowledge-analysis Ground an AI Voice Agent and define the structured result it produces Knowledge helps an agent answer company-specific questions during a call. Post-call analysis turns the finished conversation into a summary, outcome, sentiment and custom fields. ## Knowledge sources Each source is indexed separately and attached to the agent only after it reaches `ready`. | Kind | Input | Limits | | ------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- | | URL | A public `http` or `https` page | Private, loopback and local network targets are rejected | | Text | A label and plain text | 100,000 characters | | Document | PDF, TXT, Markdown, DOC or DOCX | 20 MB by default; controlled by `AI_VOICE_AGENT_MAX_DOCUMENT_MB` | | Reused source | A source already uploaded to another agent in the workspace | Duplicate content already on the target agent is flagged | ### Indexing states | Status | Meaning | | ------------ | ----------------------------------------------------------- | | `pending` | The source was accepted but indexing has not started | | `processing` | The provider is building the knowledge index | | `ready` | The source is attached and available during calls | | `failed` | Indexing failed; `lastError` explains the provider response | Reading the knowledge list refreshes pending states. If a source became ready but attaching it failed, Ringee retries the agent synchronization instead of requiring another upload. <Tip> Use one source per coherent topic. Several small, clearly labeled sources are easier to replace and diagnose than one large document containing unrelated material. </Tip> ## Built-in analysis | Result | Default | Notes | | --------- | --------: | ---------------------------------------- | | Outcome | Always on | The closed set depends on the agent type | | Summary | On | Concise account of what happened | | Sentiment | Off | Free-form provider sentiment label | Analysis arrives asynchronously after the call. A terminal call can briefly have `null` for `outcome`, `summary` or `sentiment`. ## Custom extraction fields An agent can extract up to 20 custom fields. | Field | Rule | | ------------- | -------------------------------------------------------------------------------------------------- | | `key` | Starts with a lowercase letter; lowercase letters, numbers and underscores only; max 60 characters | | `label` | Max 80 characters | | `type` | `text`, `number`, `boolean` or `select` | | `description` | Max 300 characters | | `options` | Up to 20 values for `select` | ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "key": "budget_range", "label": "Budget range", "type": "select", "description": "The budget the person said is available.", "options": ["under_5k", "5k_to_10k", "over_10k", "unknown"] } ``` Extracted values appear under `extracted_data` in the API, MCP and CLI result. ## Recordings and transcripts AI voice agent calls are recorded. Ringee also imports the transcript produced by the live conversation engine. It stores agent speech as outbound turns and the other person's speech as inbound turns; internal tool calls are not included as spoken text. Recording and transcript recovery is best-effort and retried after the call. They appear on the corresponding Ringee call record, not inside the compact AI voice agent result. ## Next steps <CardGroup> <Card title="Calls and results" icon="phone" href="/voice-agents/calls"> Status, outcomes, metadata and billing </Card> <Card title="API reference" icon="code" href="/voice-agents/api-reference"> Knowledge and configuration endpoints </Card> </CardGroup> # AI Voice Agents over MCP Source: https://docs.ringee.io/voice-agents/mcp List agents, start a real call and read its result from any MCP client The Ringee MCP server exposes three focused AI Voice Agent tools. Agent creation and configuration remain in the dashboard; the [Public API](/voice-agents/api-reference), MCP and CLI expose the execution flow. ## Requirements * Connect MCP to an organization workspace. * Create and activate the agent first. * Keep the destination and caller-number ids in the active workspace. * Ask for human confirmation immediately before starting a real call. ## `list_ai_voice_agents` <span>Read</span> Lists agents, the caller numbers this workspace may use and the accepted variables for each type. <ParamField type="number"> Number of agents to return. Defaults to 20, max 100. </ParamField> Always resolve `agentId`, `fromNumberId` and required variable names from this tool before calling. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "agents": [ { "id": "<agentId>", "name": "Maya", "type": "appointment_booking", "status": "active", "voice": "Carolina", "callCount": 14, "callsFrom": "+13055550100" } ], "callerNumbers": [ { "id": "<numberId>", "phoneNumber": "+13055550100", "country": "US" } ], "variablesByType": { "appointment_booking": [ { "key": "first_name", "required": true, "description": "The first name of the person being called." } ] } } ``` ## `start_ai_voice_agent_call` <span>Sensitive</span> <span>Real billed call</span> <ParamField type="string (uuid)"> Agent returned by `list_ai_voice_agents`. </ParamField> <ParamField type="string"> Destination in E.164. </ParamField> <ParamField type="string (uuid)"> Eligible Ringee number. Required when no default can be resolved. </ParamField> <ParamField type="object"> String values accepted by the selected agent type. </ParamField> <ParamField type="object"> Free-form values echoed in the result. </ParamField> ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "agentId": "<agentId>", "to": "+13055550123", "fromNumberId": "<numberId>", "variables": { "first_name": "Carlos", "reason": "Product demo" }, "metadata": { "external_id": "crm-contact-42" } } ``` The tool returns immediately: ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "ok": true, "callId": "8ddf384a-9b9d-44dd-a120-23395b1f4c98", "status": "initiating", "note": "The conversation runs asynchronously. Poll get_ai_voice_agent_call for the outcome." } ``` <Warning> This tool is non-idempotent. A repeated successful call starts another phone call. MCP annotations identify it as write, non-idempotent and open-world, but the assistant must still explain the target and wait for an explicit human yes. </Warning> ## `get_ai_voice_agent_call` <span>Read</span> <ParamField type="string (uuid)"> The id returned by `start_ai_voice_agent_call`. </ParamField> Returns `call_id`, `status`, `outcome`, `summary`, `sentiment`, `extracted_data` and `metadata`. Analysis fields can remain empty briefly after the status becomes terminal. ## Recommended flow <Steps> <Step title="List and resolve"> Call `list_ai_voice_agents`. Choose an active agent, confirm its required variables and resolve a caller number when needed. </Step> <Step title="Confirm the real call"> State the agent name and destination. Explain that this places a real, billed call and wait for the user's explicit approval. </Step> <Step title="Start once"> Call `start_ai_voice_agent_call` once. Preserve the returned `callId`. </Step> <Step title="Read the result"> Poll `get_ai_voice_agent_call` until the phone status is terminal and the required analysis fields have arrived. </Step> </Steps> ## Next steps <CardGroup> <Card title="Complete MCP reference" icon="list" href="/mcp/tools"> All 37 Ringee MCP tools </Card> <Card title="MCP safety" icon="shield-check" href="/mcp/safety"> Confirmation, destructive actions and credits </Card> </CardGroup> # AI Voice Agents Source: https://docs.ringee.io/voice-agents/overview Build voice agents that place real calls, hold conversations and return structured results AI Voice Agents place outbound calls, speak with the person who answers and return a structured result. A call can book a meeting, confirm an appointment, deliver an update, request human follow-up and extract fields from the conversation. <CardGroup> <Card title="Create your first agent" icon="rocket" href="/voice-agents/quickstart"> Configure, test and place a call </Card> <Card title="Automate agent calls" icon="code" href="/voice-agents/api-reference"> Use the Public API, MCP or CLI </Card> </CardGroup> ## What an agent does ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} flowchart LR; classDef input fill:#86EFAC,color:black,stroke:#fff classDef svc fill:#15803D,color:white,stroke:#fff classDef output fill:#22C55E,color:white,stroke:#fff trigger[Dashboard / API / MCP / CLI]:::input agent[Ringee AI Voice Agent]:::svc phone[Phone conversation]:::svc result[Status · outcome · summary · extracted data]:::output trigger --> agent --> phone --> result ``` Every trigger surface reaches the same service. Workspace ownership, agent readiness, Do Not Call checks, calling permissions, available credits and caller-number eligibility are enforced in one place. ## Agent types | Type | Best for | Built-in behavior | | ------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | `appointment_booking` | Sales qualification and meeting booking | Reads live calendar availability, confirms a specific slot and creates the meeting only after explicit agreement | | `reminders_notifications` | Appointment confirmations, reminders and updates | Delivers the supplied information, records whether the person confirmed and requests human follow-up when needed | See [Configuration](/voice-agents/configuration) for each type's variables and outcomes. ## Availability AI Voice Agents require an **active organization workspace**. They are not available in a personal workspace. The dashboard, Public API, MCP and CLI all enforce this rule. Operations that depend on another Ringee capability still follow that capability's rules. For example, the selected caller number and calendar must belong to the active workspace. ## What comes back Starting a call returns immediately with an AI voice agent call id. The conversation runs asynchronously. Poll the result until its telephony status is terminal and post-call analysis has arrived. ```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "call_id": "8ddf384a-9b9d-44dd-a120-23395b1f4c98", "status": "completed", "outcome": "meeting_booked", "summary": "Carlos agreed to a product demo on Friday at 10:30 AM.", "sentiment": "positive", "extracted_data": { "budget_range": "$5k-$10k" }, "metadata": { "external_id": "crm-contact-42" } } ``` The corresponding call also appears in Ringee call history. Ringee recovers its recording and the transcript produced during the live conversation when the provider makes them available. ## Ways to use it | Surface | Use it when | | ----------------------------------------- | ------------------------------------------------------------------------------------ | | Dashboard | A person is creating, tuning, testing or manually triggering an agent | | [Public API](/voice-agents/api-reference) | Your server should list agents, start calls and read results with a `cik_live_…` key | | [MCP](/voice-agents/mcp) | Claude, ChatGPT or another MCP client should select an agent and start a call | | [CLI](/voice-agents/cli) | A script or shell-capable agent should list agents, call and read results | <Note> The Public API exposes the execution flow: list agents and caller numbers, start calls, and read results. Create and configure agents in the dashboard. </Note> ## Next steps <CardGroup> <Card title="Quickstart" icon="rocket" href="/voice-agents/quickstart"> Create, test and call with an agent </Card> <Card title="Calls and results" icon="phone" href="/voice-agents/calls"> Understand gates, status and billing </Card> </CardGroup> # AI Voice Agents quickstart Source: https://docs.ringee.io/voice-agents/quickstart Create an AI voice agent, test it and place the first real call This guide creates an agent in the dashboard. Once the agent exists, you can trigger it from the dashboard, API, MCP or CLI. ## Before you start You need: * an active **organization** workspace; * a Ringee number eligible for outbound AI agent calls; * available Ringee credits, unless the user is on a free-call trial; * a connected Google or Microsoft calendar for an Appointment Booking agent; * a public backend URL when self-hosting, so provider callbacks and agent tools can reach Ringee. ## Create the agent <Steps> <Step title="Open AI Voice Agents"> Switch to an organization in Ringee, then open **AI Voice Agents** and choose **Create agent**. </Step> <Step title="Choose what the agent does"> Choose **Appointment Booking** to offer real calendar slots and book a meeting, or **Reminders & Notifications** to deliver information and capture the response. </Step> <Step title="Set its identity and voice"> Give the agent a name, add the company context it speaks for and choose a voice. The voice language controls the default greeting and instructions. Ringee currently curates English, Spanish, Portuguese, French, German and Italian voices. </Step> <Step title="Choose the model"> Use **Ringee AI** for the included model with no extra credential. OpenAI, Anthropic and Google are bring-your-own-key options. Ringee verifies the key and sends it to the voice provider's secret store; Ringee stores only the resulting reference. </Step> <Step title="Finish the required setup"> Assign a caller number. For Appointment Booking, connect a calendar, set the time zone, meeting duration and title. Save the agent and confirm that its status becomes `active`. </Step> </Steps> ## Test in the browser Open the agent and start a test conversation. Ringee temporarily enables browser access for that agent and returns an expiration time. End the test when you finish; an automatic sweep also closes expired sessions. <Warning> A browser test temporarily allows anonymous provider-side access to the assistant. Keep the test window short and close it when you finish. Self-hosters control the maximum duration with `AI_VOICE_AGENT_TEST_SESSION_TTL_SECONDS`. </Warning> ## Place the first call Use E.164 for automation surfaces, for example `+13055550123`. Supply every variable marked required for the selected agent type. <CodeGroup> ```bash CLI theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents list ringee voice-agents call <agentId> \ --to +13055550123 \ --var first_name=Carlos \ --var reason="Product demo" \ --yes ``` ```json MCP theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} { "name": "start_ai_voice_agent_call", "arguments": { "agentId": "<agentId>", "to": "+13055550123", "variables": { "first_name": "Carlos", "reason": "Product demo" } } } ``` ```bash API theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} curl -X POST "https://api.ringee.io/api/v1/ai-voice-agents/<agentId>/calls" \ -H "X-Ringee-Api-Key: cik_live_xxxxxxxx…" \ -H "Content-Type: application/json" \ -d '{ "to": "+13055550123", "variables": { "first_name": "Carlos", "reason": "Product demo" } }' ``` </CodeGroup> Starting a call is non-idempotent. Each successful request creates and places a new, billed call. ## Read the result ```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} ringee voice-agents call-result <callId> ``` The call can finish before its summary and extracted fields arrive. Poll again after the terminal status if those fields are still empty. ## Next steps <CardGroup> <Card title="Configure the agent" icon="sliders" href="/voice-agents/configuration"> Types, voices, models and conversation behavior </Card> <Card title="Automate with MCP" icon="robot" href="/voice-agents/mcp"> Typed tools and confirmation guidance </Card> </CardGroup> # Self-host AI Voice Agents Source: https://docs.ringee.io/voice-agents/self-hosting Configure provider callbacks, models, limits, storage and background reconciliation AI Voice Agents run through the existing Ringee backend, database, Redis and Temporal worker. There is no separate agent service to deploy. ## Required infrastructure | Component | Why it is needed | | ---------------------- | ---------------------------------------------------------------------------- | | PostgreSQL | Stores agents, knowledge-source state, calls, results and settlement markers | | Redis | Tracks temporary browser test sessions | | Temporal worker | Runs expired-test cleanup and voice-agent billing/artifact sweeps | | Public HTTPS backend | Receives call status, conversation analysis and in-call tool callbacks | | Telnyx API access | Creates assistants, voices, calling applications, knowledge stores and calls | | Outbound Voice Profile | Pins server-originated calls to the intended billing route | Set `BACKEND_URL` and `PUBLIC_BACKEND_URL` to the same externally reachable API origin. Ringee writes callback and tool URLs under: ```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}} https://your-api.example.com/api/call/webhook https://your-api.example.com/api/ai-voice-agents/webhooks/… https://your-api.example.com/api/ai-voice-agents/tools/… ``` Do not document or manually call the tokenized webhook URLs. Ringee provisions their authorization material and verifies it on every callback. ## Environment variables | Variable | Default | Purpose | | ----------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- | | `AI_VOICE_AGENT_PROFIT_MARGIN` | `1` | Multiplier applied to the provider's AI conversation cost before Ringee debits credits; must be at least 1 | | `AI_VOICE_AGENT_RINGEE_MODEL` | `moonshotai/Kimi-K2.6` | Model id behind Ringee AI | | `AI_VOICE_AGENT_OPENAI_MODEL` | `openai/gpt-5.2` | Model id behind the OpenAI BYOK choice | | `AI_VOICE_AGENT_ANTHROPIC_MODEL` | `anthropic/claude-haiku-4-5` | Model id behind the Anthropic BYOK choice | | `AI_VOICE_AGENT_GOOGLE_MODEL` | `google/gemini-2.5-flash` | Model id behind the Google BYOK choice | | `AI_VOICE_AGENT_MAX_CALL_SECONDS` | `900` | Hard maximum for one call; must be at least 30 seconds | | `AI_VOICE_AGENT_TEST_SESSION_TTL_SECONDS` | `600` | Browser test-access lifetime; must be at least 60 seconds | | `AI_VOICE_AGENT_STORAGE_REGION` | `us-central-1` | Provider object-storage region for knowledge bases | | `AI_VOICE_AGENT_MAX_DOCUMENT_MB` | `20` | Largest accepted knowledge document | The voice leg also uses `TELNYX_API_KEY`, normal call margin settings and Ringee's storage configuration for recovered recordings. Set `TELNYX_OUTBOUND_VOICE_PROFILE_ID` to pin calls to an explicit Telnyx billing route; if it is omitted, Ringee leaves the provider's default route in place. ## Models and BYOK Self-hosters control the concrete model behind each stable UI choice. Changing a model setting changes what is used the next time an agent is synchronized; the stored agent keeps the provider family, not a user-selected arbitrary model id. OpenAI, Anthropic and Google keys are supplied per agent. Ringee verifies the key, hands it to the voice provider's secret store and persists only the reference. ## Cost and artifact reconciliation The provider can publish call cost, recording and transcript data after the call has ended. Ringee's scheduled sweep therefore: 1. binds any missing provider conversation id; 2. repairs a stalled call timeline from usage records; 3. settles the AI and telephony costs exactly once; 4. retries recording and transcript recovery for up to 6 hours. Keep the Temporal worker running in production. An API-only deployment can place calls but cannot reliably finish cleanup and reconciliation. ## Production checklist * `BACKEND_URL` and `PUBLIC_BACKEND_URL` contain the same public HTTPS origin. * Telnyx can reach `/api/call/webhook` and the generated voice-agent callback URLs. * When set, `TELNYX_OUTBOUND_VOICE_PROFILE_ID` belongs to the same provider account. * PostgreSQL, Redis and Temporal are reachable by both backend and worker. * At least one Ringee number is eligible for AI voice agent outbound use. * The maximum call and test-session durations match your risk policy. * Credit and call margins are set deliberately. * Recording storage and retention follow your consent and compliance policy. ## Next steps <CardGroup> <Card title="Configuration reference" icon="gear" href="/configuration/reference"> All Ringee environment categories </Card> <Card title="API reference" icon="code" href="/voice-agents/api-reference"> Build an authenticated integration </Card> </CardGroup>