# DialStack Documentation > Business Voice for Vertical SaaS This file contains all documentation content in a single document following the llmstxt.org standard. ## Account Settings The Account Settings page shows your account's configuration and activity. Navigate to it by clicking **Settings** in the sidebar. Settings are organized into tabs across the top of the page. Switching tabs updates the page address, so you can bookmark or share a link to a specific tab (for example, a link ending in `?tab=telephony` opens the Telephony tab directly). - **General** — account identity and billing - **Telephony** — call-handling settings - **Locations** — physical sites and emergency-call notifications - **Devices** — phone provisioning and device options - **Activity** — the account audit log Text fields like name and email can be edited directly — Save and Cancel buttons appear when you make changes. Toggles and dropdowns save automatically. ## General Your core account details: name, email, and timezone. Your account's address is its **main location** — manage it on the [Locations](locations.md) tab, not here. To move your business to a new address, edit the main location's address in place (see [Locations](locations.md#relocating)); every device stays attached to it. ### Monthly rates **Monthly rates** shows what your account is billed per user, per phone number, and per VoiceAI location. These are the rates in force today, not a future or historical price. Taxes and fees are additional and are not included in the figures shown. A line reading **Not set** means no agreed rate is on file for that item. It does not mean the item is free: it is still billed, at a standard rate rather than a rate agreed for your account. Ask your platform administrator to confirm the price if you see this. If a rate change has been agreed but has not started yet, the new amount appears beneath the current one with the date it begins. Rate changes always take effect at the start of the next billing period, never partway through a month, so the rate shown as current is the one you are being charged until that date. Only the rates that are actually changing show a second amount. The card is read-only. Rates are set by your platform administrator. ## Telephony Call-handling settings for the account: - **Extension Length** — the number of digits used for extensions. Changing this renumbers your existing extensions so they keep working at the new length (see below). - **Default Outbound Number** — the caller ID used for outbound calls when a phone doesn't specify one. - **Hold Music** — the audio clip played to held callers (regular hold, parking, and queue wait). An individual queue can override it with a clip of its own (see [Call Queues](queues.md)). Upload clips first under [Audio Clips](audio-clips.md). - **Transcription** and **Recording** — toggle automatic call transcription and call recording for the account. - **Sensitive Data Redaction** — redact sensitive information (such as credit card numbers and other personally identifiable information) from call transcripts and audio recordings. Redacted words appear as `[PII]` in transcripts, and the matching audio is silenced. While enabled, calls are transcribed in English only, and a call's recording becomes available only after redaction completes. Off by default. - **Parking Timeout** — how long a parked call waits before ringing back the person who parked it. Enabling fax on individual numbers and setting notification recipients is done on each number's detail page (see [Phone Numbers](phone-numbers.md)). The account-wide phone-number and fax limits are managed by your platform administrator. ### Changing the extension length Extensions are dialed by their exact number of digits, so changing the extension length would normally break every existing extension. To prevent that, changing **Extension Length** opens a confirmation dialog that previews and applies a renumbering: - **Making extensions longer** — a prefix is added to the front of every existing extension (for example, `1001` becomes `11001`). You choose the prefix in the dialog, with a preview of the resulting numbers. - **Making extensions shorter** — each extension keeps its last digits (for example, `11001` becomes `1001`). Where that would create a duplicate, the affected extensions are automatically reassigned to a free number; those are highlighted in the preview so you can see exactly what changed. The dialog also updates any speed-dial buttons that point at a renumbered extension, and reprovisions the account's phones automatically so they pick up the new numbers. Depending on the phone model, a phone may briefly reboot to apply the change. After applying, you'll see a summary of how many extensions were renumbered and how many phones were updated. Any phone that was offline at the time will pick up the change the next time it checks in. ## Locations The Locations tab shows the physical office locations associated with your account. Each location can have an E911 address for emergency calling. See the [Locations guide](locations.md) for details. ### Emergency Notifications Notify on-call staff whenever someone dials 911 from a phone on this account. Add recipients under **Emergency call notification recipients** on the Locations tab. - Add one or more email addresses. Whenever 911 is dialed from any extension on the account, every recipient here receives an email with the dialed number, caller, and location. - A location can name additional recipients to alert specifically for calls placed from that location — see [Locations](locations.md). Account- and location-level recipients are notified together; they don't replace each other. - Test calls (`933` on the carrier's emergency test line) trigger the same notification path as real 911 calls, so you can verify the wiring end-to-end. ## Devices ### Button Templates Reusable programmable-key layouts you can bind to devices. See the [Button Templates guide](button-templates.md). ### Provisioning Shows the provisioning token, the provisioning URL phones boot against, and the phone admin password used for local device administration. Use the copy buttons to grab any value. Click **Generate** to create the first token, or **Rotate** to replace an existing one. Rotating invalidates the previous token — phones that were provisioned with it must be re-provisioned with the new URL. ## Activity The Activity tab provides a chronological record of all API operations performed on your account — who did what, when, and from where. Each row shows the **timestamp**, **event** (e.g., `user.create`), **resource** affected, **actor** who performed the action, **outcome** (success, failure, or denied), and **IP address**. ### Filtering Each column has a filter directly below its header. Type or select a value to narrow results: - **Time** — pick a start and end date to filter by time range. - **Event** — open the picker to check specific event types. Use the **Reads** and **Writes** buttons to quickly toggle all read or write operations. - **Resource** and **Actor** — type a prefix to match (e.g., `user` to find all user resources). - **Outcome** — select success, failure, or denied from the dropdown. - **IP Address** — type a full or partial IP address. Click **Clear Filters** to reset all active filters. ### Copying entries Hover over any row and click the copy icon to copy the full audit log entry as JSON to your clipboard. --- ## Audio Clips Audio clips are reusable audio files that can be attached to IVR menus and other telephony features. Upload a greeting, an on-hold message, or a menu prompt once, then reference it wherever you need it. ## Viewing Audio Clips Navigate to **Audio Clips** from the sidebar. The table shows all audio clips for the selected account, including an inline audio player, duration, and creation date. From the list, you can: - Use the inline player to preview a clip. - Use the **pencil** icon to rename a clip. - Use the **trash** icon to delete a clip. ## Uploading a Clip 1. Click **Upload Audio Clip** in the top-right corner. 2. Enter a **Name** for the clip (e.g., "Main Greeting" or "After-Hours Message"). 3. Provide the audio using one of two methods: - **Upload** — Drag and drop a file or click to browse. Accepted formats: WAV, MP3, OGG, WebM. - **Record** — Click **Start recording** to capture audio directly from your microphone. Click **Stop** when finished, preview the recording, then click **Use recording** to confirm or **Discard** to try again. 4. Click **Upload**. **Limits:** Files must be 5 MB or smaller. The system automatically transcodes uploads to a format optimized for telephony playback. Clip names must be unique within an account. ## Renaming a Clip To rename a clip, click the **pencil** icon in the list table, enter the new name, and click **Save**. ## Deleting a Clip To delete a clip, click the **trash** icon in the list table and confirm. If the clip is currently referenced by a dial plan menu node, you must remove it from all menus before it can be deleted. This action cannot be undone. --- ## Button Templates Button templates are reusable layouts for a phone's programmable keys. Build a layout once — speed dials, call parking, shared voicemail, do-not-disturb — then bind it to any compatible deskphone or DECT handset instead of configuring each phone by hand. ## Viewing templates Open an account's **Settings** view. The **Button templates** card lists every template on the account by name; click a row to edit its buttons. ## Creating a template 1. From the **Button templates** card, click **Add template**. 2. Give it a name (for example, "Reception" or "Executive") and an optional description. 3. Save. You're taken to the template's detail page to add buttons. ## Setting the account default Use **Default template** on the account's **Button templates** card when every new phone should start with the same layout. Newly added deskphones and DECT handsets inherit the selected template automatically. DECT bases do not inherit it because programmable keys live on their paired handsets. Changing the default does not silently change phones that already exist. After you select a new default, the portal offers to apply it to the account's existing deskphones and handsets. Choose **Apply to existing devices** to update them now, or **Just set default** to leave them unchanged. If any device update fails, the dialog keeps those devices available for retry. Selecting **No default** stops inheritance for devices added later; it does not unbind templates already assigned to phones. ## Adding buttons On the template detail page, click **Add button** and pick a purpose: - **Speed dial** — one-touch dial to a number, extension, or feature code. When the destination is an extension, phones that support it also light up to show whether that person is available, ringing, or busy. - **Call parking** — park a call to a numbered spot and watch that spot's occupancy from this key. - **Shared voicemail** — light up when a shared mailbox has new messages, and press to listen or transfer the active call into it. Pick which shared voicemail box the key watches. On-device behavior depends on the phone model's support. - **Do not disturb** — toggle do-not-disturb for the assigned user. Each purpose has its own fields: - **Label** — the text shown on the phone's screen next to the key. - **Target** — varies by purpose: **Number or extension** for Speed dial, **Parking spot** for Call parking, **shared voicemail box** for Shared voicemail. Do not disturb needs no target. Click a button row to edit it; use the row's delete action to remove it. ## Reordering buttons Drag the handle at the left of a button row to move it. Dropping a button on another row gives it that row's position, and the buttons in between shift one slot — the set of occupied positions stays the same, so any gaps you've left in the layout are preserved. ## Binding a template to a device Open a deskphone or DECT handset from the **Devices** page and use the **Buttons** card. (DECT bases don't expose a Buttons card — bind templates on each paired handset instead.) 1. Choose a template from the **Button template** dropdown. Templates that wouldn't render any supported button on this device's model are hidden; empty templates (no buttons configured yet) are listed so you can bind and then fill them in. 2. The **Effective buttons** table shows exactly what the phone will provision — template buttons merged with any per-device overrides. To remove the layout, pick **No template bound**. To update several phones together, use bulk assignment from the [Devices page](devices.md#bulk-assigning-a-button-template). ## Per-device overrides A bound template is shared, but a single phone sometimes needs a tweak. From the device's **Buttons** card: - **Add override** — pick a free slot, then add a button for this phone only (or replace the template's button at that slot). - **Suppress** — hide a template button on this phone. Suppressed positions are listed separately and can be **restored** at any time. - Click a row to edit it. If the row is backed by an override, deleting it falls back to the underlying template button. Overrides apply only to the one device and never change the underlying template. ## Phone defaults A row badged **Phone default** was not configured by anyone — it comes from the phone model. A few models have no other way to reach a function: the Snom D800 desk phones (D810WB, D812, D815W) have no do-not-disturb hard key and no do-not-disturb softkey, so a programmable key is the only place DND can live. On those models, when neither the template nor an override provides that button, one is added automatically just after the template's last button. It behaves like any other row. Provide your own DND button — in the template or as an override, at any position — and the automatic one goes away. Place an override at its position to replace it, or suppress it to remove it. One thing to watch: it always sits one slot past the template's last button, so adding buttons to the template moves it, and a suppression recorded at the old position stays with the position rather than following the button. ## Compatibility Not every phone model supports every button type or position. When a bound template includes a button the device can't render, it appears under **Won't render on this device** with the reason (for example, the model has fewer keys, or the vendor doesn't support that type). The phone still provisions every button it _can_ render; unsupported ones are simply skipped. --- ## Call Logs The Call Logs page displays a chronological list of all calls for the selected account, including inbound, outbound, and internal calls. ## Call List The table shows: - **Date/Time** — When the call started - **Direction** — Inbound, outbound, or internal - **From / To** — Phone numbers with labels when available. For an external caller the label is the caller ID name (CNAM) the carrier sent, so it may be a locality ("LA MESA CA") or a placeholder ("WIRELESS CALLER") rather than a person's name. When the carrier sends the number itself as the name, the label is left off rather than shown twice. - **Duration** — Talk time (does not include ring time) - **Status** — Completed, no answer, busy, failed, or voicemail. Voicemail means the caller actually left a message; a caller who reached voicemail and hung up during the greeting or without recording anything is shown as no answer, and no voicemail appears in the mailbox. - **Quality** — MOS score (worst across call legs) Click any row to open the call detail page. ## Call Detail The detail page shows comprehensive information about a single call. The fields shown depend on the call direction. ### Call Information **Inbound calls** show: - **Number Dialed** — Which of your phone numbers the caller dialed (useful for tracking marketing lines or brands) - **Caller** — The external phone number that called, with the caller ID name when the carrier sent one - **Routed To** — The extension or person the call was routed to **Outbound calls** show: - **Called By** — The user or extension that placed the call - **Destination** — The external phone number that was dialed - **Caller ID** — Which of your phone numbers was used as the outgoing caller ID **Internal calls** show the originating and receiving extensions. All calls include start, answer, and end timestamps, plus the talk duration. ### Calls in progress A call detail can be opened while the call is still connected. Its status shows **In progress**, and fields that are only known once the call ends — the destination for inbound calls, the talk duration, the recording, the AI summary, and quality metrics — are not shown yet. The page updates on its own when the call completes, filling in the final status and those fields without a manual refresh. ### Recording When a recording is available, an audio player lets you listen to the call directly in the browser. Use the **Download** button to save the audio file. #### Pausing recording during a call On a recorded call, a user can pause recording — for example, while reading back or collecting a customer's payment card number — so the sensitive audio is never captured. - **Press `*7`** on the phone keypad during the call to pause recording. Both parties hear a single low beep. - **Press `*7` again** to resume. Both parties hear two higher beeps. Recording stays paused until it is resumed or the call ends; the recording is a single file with a silent gap over the paused portion. Only the users on the account can pause or resume — the outside caller pressing `*7` has no effect. The same pause and resume actions are also available through the API for integrations that pause recording automatically (for example, when a payment-capture form opens). ### AI Summary For calls with transcripts, an AI-generated summary provides a quick overview of what was discussed. ### Transcript The full speech-to-text transcript of the call recording. While the recording is still being processed, the card shows a "still being generated" message; reload the page after transcription completes to see the text. Each speaker's turn is tagged **`[Local]`** (your team) or **`[Remote]`** (the other party). The Transcript heading shows a sentiment badge for each side, labelled to match those tags: - **Positive** — real satisfaction or gratitude. - **Negative** — real frustration, complaint, or distress, including someone dealing with an urgent problem of their own even when they stay polite. - **Mixed** — feeling ran high but pulled in both directions over the course of the call. - **Neutral** — routine and businesslike. This is the most common result, and it stays neutral even when everyone is perfectly friendly, so anything else is worth a look. Sentiment is judged per speaker so you can see which side of the call a reading came from — a frustrated caller and a calm agent is a different situation from both sides being frustrated. It reflects how the people on the call felt about the interaction, not how pleasant the subject matter was: a calm conversation about bad news is neutral. Sentiment needs both sides of the audio recorded separately, which is the norm; the badges do not appear for the occasional call captured as a single channel. ### Quality Metrics Per-leg quality metrics (PSTN and endpoint) are displayed when available: - **MOS** — Mean Opinion Score (1.0–4.5). Green indicates good quality (3.5+), yellow is fair (2.5–3.5), and red indicates poor quality. - **Jitter** — Variation in packet arrival time, in milliseconds. - **Packet Loss** — Percentage of packets lost during the call. - **Round-Trip Time** — Network latency in milliseconds. --- ## Getting Started After signing in to the admin portal, you land on the **Users** list for your selected account, where you can create, edit, and manage users and their SIP endpoints. Use the navigation sidebar to move between Users, Devices, phone numbers, dial plans, and the other management areas. ## Account Picker The account picker appears in the top bar. If you have access to multiple accounts, use the search field to quickly find the one you need. Your selected account persists across page navigations until you switch to a different one. If no account is selected yet, choose one from the picker to load its management pages. --- ## Managing Devices The Devices page lets you manage every phone on the account — deskphones, DECT base stations, and the wireless handsets paired with those bases. Deskphones are PoE-wired or Wi-Fi desk phones; DECT systems are a base station plus a fleet of cordless handsets. ## Viewing Devices Navigate to **Devices** from the sidebar. The table shows every device on the selected account with its name, type, hardware identifier (MAC address for deskphones and bases; IPEI for handsets), vendor, model, status, location, and the user it is assigned to. DECT handsets are listed underneath their paired base in the table — indented and grouped — so a base and its handsets stay visually together. A handset that has been stocked but not yet paired with a base appears as a top-level row. ### Ordered hardware Hardware ordered with the account that hasn't been delivered yet appears at the bottom of the table — one row per unit, each with an **ordered** badge showing the vendor and model. There is no device to configure yet; once a unit ships and registers, that same row becomes a regular device row. You can decide ahead of time who each ordered phone or handset is for and which site it goes to. Click an ordered row to open its detail page and pick a user and a location — the same way you configure a delivered device. The **Assigned to** and **Location** columns show the plan, and both are applied automatically when the device arrives and registers. Base stations can be staged for a location but not assigned to a user (they serve all users at a site); handsets are assigned to a user but inherit their location from the base they pair with. ### Searching and filtering A filter strip above the table lets you narrow the list: - **Search** — type any part of a name, MAC address, IPEI, model, vendor, or an assigned user's name or email. - **Type** — show only deskphones, bases, or handsets. - **Location** — show devices at one or more physical sites (or "Unassigned"). Handsets are matched by the location of their paired base. - **Status** — show devices in a specific provisioning state. Filters and search compose, and the result is preserved as you move through the table. ### Bulk-assigning a button template Use the checkbox column to apply one button template to several visible phones: 1. Select individual deskphones or DECT handsets, or use the header checkbox to select all eligible phones in the current filtered view. 2. Click **Assign template** in the selection bar. 3. Choose a template and click **Apply**. DECT bases and hardware that has not arrived cannot be selected because neither has programmable keys to configure. Changing search or filters clears selections that are no longer visible, so the action only affects the phones shown as selected. Updates run in small batches. If some phones cannot be updated, the dialog lists each failed phone and its error, keeps those phones selected, and offers **Retry failed devices**. Successful phones are removed from the selection. For automatic assignment on phones added later, set the [account default button template](button-templates.md#setting-the-account-default). ## Provisioning Phones added here are configured automatically — no per-device data entry. See [Phone Provisioning](provisioning.md) for the overview, including what to do if a phone won't register. The provisioning token and URL live on the **Account Settings** page under the collapsible **Provisioning** section. See [Account Settings](accounts.md) for details. ## Adding a Device 1. Click **Add Device** in the top-right corner. 2. Choose the type of device you want to add: - **Deskphone** — PoE-wired or Wi-Fi desk phones. - **DECT Base** — Wireless system with a base station and cordless handsets. - **DECT Handset** — A cordless handset that pairs with a DECT base station. 3. For deskphones and DECT bases, enter the **MAC Address** (format: `00:04:13:XX:XX:XX`). For DECT handsets, enter the **IPEI** and pick the **DECT Base** to pair it with. You can leave the base picker blank to stock an unpaired handset and pair it later from the handset's detail page. 4. Optionally enter a **Name** to help you recognize the device later (e.g., "Reception", "Conference room", "John's phone"). 5. Optionally enter the **Model** (e.g., "D815" for deskphones, "M58" for a DECT handset). 6. For deskphones and DECT bases, choose the **Location** — the physical site the device lives at. This determines the dispatch address used when the device places an emergency call. It defaults to the account's [main location](locations.md#main-location) (shown with its full address) so a single-site account is correct without any extra step; change it if this device lives at a different site. For DECT systems, the base's location is shared by every handset paired with it. See [E911 Dispatch Addresses](./e911.md) for the full picture. 7. Click the submit button to register the device. Devices can be renamed, moved between locations, or repaired with a different base later from their detail page. ## DECT Limitations DECT handsets are cordless phones built around a single radio link to their base station, and that design imposes a few limits a wired deskphone doesn't have. Plan for these when you decide whether DECT is the right fit: - **User presence is base-local, not system-wide.** A DECT handset shows the busy/idle status of another user only when that user's handset pairs to the **same** base station. Within one base, busy-lamp keys work as expected; they can't watch a user on a different base or on a deskphone. - **Call parking has no occupied-slot indicator.** A parked call can't show up as a quiet "occupied" lamp on a handset. The call can still be parked and retrieved from any handset, but there's no glanceable way to see which park slots are in use. DECT is still the right choice where running Ethernet isn't practical and Wi-Fi coverage is unreliable — it trades system-wide presence for mobility and easy deployment. Where you need system-level busy-lamp keys (watching users on other bases or deskphones) or parking indicators, choose a wired or Wi-Fi deskphone instead. ## Is My Phone Working? Every device tells you where it is on the path from "in the box" to "in service" — no phone-system expertise needed. ### The setup checklist While a phone is being set up, its detail page opens with a **Device setup** checklist: 1. **Phone retrieved its configuration** — the phone has fetched its configuration from . 2. **Online** — the phone is reachable right now: it's powered on, on the network, and holding a live connection to . 3. **Ready to call** — the phone belongs to a user and that user's line is live, so it can make and receive calls. 4. **First call made** — the phone has placed or received a call, proving its connection to the phone network works. Step titles reflect their state — a step that hasn't happened yet reads as pending ("Not online yet"), never as a fact. The current step shows a one-line hint for what to do next — plug the phone in, check the network, assign a user, or make a test call. **Online is not the same as ready to call.** A phone can be reachable before anyone is assigned to it — it's on the network and healthy, it just can't place calls yet. Assigning a user is what unlocks calling, and you can do it at any point: before the phone is plugged in, or long after. The checklist points it out only when it's the thing standing between you and a working phone. The checklist updates live, so you can watch a phone come up while you're standing next to it. ### After the first call Once a phone has carried a call, the checklist is replaced by a compact status: **"Phone is working"** (or **"Phone is offline"**) with the evidence underneath — online since when, the last call, and the last time it fetched its settings. A working phone stays quiet; if it ever drops offline, the problem — and what to check — shows up right there. ### In the devices table The table's status badge gives you the same signal at a glance: - **Setting up** — the device is still onboarding. - **Online** / **Offline** — the device has proven itself with a call; the badge now tracks live reachability. ## Reloading Device Configuration Each device row exposes a reload icon (the circular arrow next to the row's actions). Clicking it asks the device to fetch and apply its latest configuration without rebooting — active calls keep going and the device stays registered. Use this after changing settings that affect a specific device (name, line keys). Routine clicks on the admin UI do not need it. Assignment changes don't need it either: when you assign a user to a device, remove one, or move a user between devices, the affected device reloads automatically if it is registered. - **Deskphones and DECT base stations** — Clicking the icon triggers the reload directly on the device. - **DECT handsets** — Handsets do not reload on their own. Clicking the icon opens a dialog explaining that the reload is performed on the paired base, which then fans the update out to every handset registered with it. Confirming the dialog reloads the base. A handset that is not paired with a base cannot be reloaded. ## Device Detail Page Click any device row to open its detail page. The header carries the device's name with an inline edit affordance — click the pencil to rename in place. Below the header are two cards: - **Device Info** — type, hardware identifier (MAC or IPEI), vendor, model, status, firmware version, last-provisioned timestamp, current IP address, and (for bases) max handsets and multicell role. Most fields are read-only and reflect what the device reports during provisioning. The exception is a **DECT handset's model**: handsets never provision (the base fetches config and pushes it over DECT), so the handset can't report its model — set it here from the dropdown. This is required before you can bind a button template (see below). A **Resync** button in the card header pushes a configuration reload to the device — the same operation as the row-level icon in the devices table, surfaced here for when you're working on this specific device. The device reloads to apply the latest configuration, which briefly interrupts any active call; for a DECT handset it confirms the reload on the paired base. - **Configuration** — inline pickers for the fields you control: - **Location** — the device's E911 dispatch address. For deskphones and bases this is editable directly. For DECT handsets it is read-only and shows the value inherited from the paired base. - **Paired base** _(handsets only)_ — move the handset to a different DECT base on the account, or unpair it entirely. The handset record stays so you can re-pair it later. - **User** — the user this phone is assigned to. The picker shows everyone on the account; selecting a user assigns the device to them. - **Advanced** _(deskphones and bases)_ — a collapsed section for settings most accounts never touch. It holds **SIP Transport**, which controls how the device's call signaling reaches the platform. **Leave it on Inherit (default) unless you've been advised otherwise** — the platform default applies automatically and is right for almost every network. Choosing **UDP** or **TCP** overrides the transport for this one device. Handsets follow their base, so this section isn't shown on a handset. ### One device per user A user can only have one phone assigned at a time. If you pick a user who is already assigned to another device, the admin shows a confirmation dialog noting which device they're on. Confirming reassigns the user to this device and releases the old one. Cancelling leaves both assignments untouched. ### Programmable buttons Deskphones and DECT handsets carry a **Buttons** card where you bind a reusable **button template** — speed dials, call parking, voicemail, and do-not-disturb. The **Effective buttons** table shows exactly what the phone will provision after the template and any per-device overrides are merged, along with whether each button is supported by the model. You can add per-device overrides, suppress individual template buttons for this phone, and restore them later — all without changing the shared template. Templates are managed from the account's **Settings** view; per-device tweaks live here on the device. Some rows are badged **Phone default**. A few phone models have no other way to reach a function at all — the Snom D800 desk phones (D810WB, D812, D815W), for example, have no do-not-disturb hard key and no do-not-disturb softkey, so the only place DND can live is a programmable key. On those models, if neither the template nor an override provides that button, the Effective buttons table adds one automatically, just after the template's last button. It is not part of the template, so it appears on that model only and disappears the moment you provide your own. Treat it like any other row: place an override at its position to replace it, or suppress it to remove it. Because it sits one slot past the template's last button, adding buttons to the template moves it along too — a suppression stays with the position, not with the button, so it will not follow. DECT **bases** do not carry their own Buttons card — a base has no assigned user and renders no buttons of its own. Buttons live on each paired handset, so bind templates on the handset's detail page instead. If the device's model isn't in our capability matrix yet, the card shows a "Button management not available" message instead of an empty table — the phone still provisions, just without programmable-key configuration from the admin. For a **DECT handset**, this usually just means the model hasn't been set: set it in the **Device Info** card and the Buttons card will appear. ## Removing a Device From the devices table, hover a row and click the trash icon to delete the device. Deleting a DECT base keeps its handsets: they become unpaired top-level rows in the table, with their user assignments intact, and can be paired with another base later from each handset's detail page. A handset stays out of service until it is re-paired. Deleting a deskphone does not affect the users that were assigned to it — the user simply has no phone until you assign them a new one. --- ## Dial Plans Dial plans let you build visual call routing flows using a drag-and-drop editor. Each dial plan defines how incoming calls are routed through a sequence of steps — such as ringing users, checking schedules, or forwarding to voicemail. ## Viewing Dial Plans Navigate to **Dial Plans** from the sidebar. Dial plans are displayed in a **card view** by default, showing a visual preview of each routing flow along with its name and extension number. Use the toggle in the top-right corner to switch between **card view** and **table view**. Both views support pagination and allow you to delete a dial plan or click through to its detail page. ## Creating a Dial Plan 1. Click **New Dial Plan** in the top-right corner. 2. Enter a **Name** for the dial plan. 3. Click **Create**. You'll be redirected to the dial plan editor where you can build your routing flow. ## Editing a Dial Plan Click on any dial plan card or table row to open its detail page with the visual editor. ### The Editor The editor has three areas: - **Node Library** (left) — Drag nodes onto the canvas to add routing steps. - **Canvas** (center) — The visual flow diagram. Connect nodes by dragging from one node's handle to another. - **Config Panel** (right) — Appears when you select a node or connection. Configure the selected element here. ### Node Types The editor palette has eight node types: - **Schedule** — Routes calls based on a [schedule](./schedules.md). Has **Open** and **Closed** exits; the call follows whichever path matches the current time (holidays follow the Closed path). - **Internal Extension** — Rings an internal target by extension: a user, [ring group](./ring-groups.md), [call queue](./queues.md), or another dial plan. Exposes a **No Answer** exit after the configured timeout. (Targeting another dial plan lets you reuse common flows across entry points. Targeting a call queue holds the caller in the queue until its max wait elapses; the **No Answer** exit is then taken, and it takes precedence over the queue's own [timeout behavior](./queues.md#timeout-behavior). The exit is also taken when the queue refuses the caller outright — it is full, or nobody is signed in and its **Join when empty** policy denies. Leave the exit unwired to let the queue's timeout behavior run instead.) - **External Number** — Rings an external phone number with a configurable timeout. Has a **No Answer** exit. A call that came in on one of your numbers shows **the original caller's number** on the external phone, so it can be returned directly; a call that started inside the account shows one of yours. - **Ring All Users** — Rings every user in the account simultaneously. Has a **No Answer** exit. - **Voice App** — Routes the call to a voice app (see [Voice Apps](./voice-apps.md)) (e.g. an AI agent or BYO voice app). - **IVR Menu** — Plays an audio prompt and routes the caller based on the digit they press. See [IVR Menu](#ivr-menu) below. - **Audio Clip** — Plays an [audio clip](./audio-clips.md), then continues to the next step. See [Audio Clip](#audio-clip) below. - **Voicemail** — Sends the caller straight to a [shared voicemail box](./shared-voicemails.md). Terminal — no exits. ### Building a Flow 1. Drag a node from the library onto the canvas. 2. Connect the **Start** node to your first step by dragging from the Start node's handle. 3. Select a node to configure it in the side panel (e.g., pick a schedule or dial target). 4. Connect node exits to subsequent steps. 5. Click **Save** when done. You can save partial flows and come back to finish them later. ## IVR Menu An **IVR Menu** node plays an audio prompt and routes the caller based on the digit they press on their keypad. Use it to build "Press 1 for sales, 2 for support" style flows. ### Configuring the Menu Select a Menu node to edit it in the side panel: - **Prompt** — The [audio clip](./audio-clips.md) played to the caller. Pick from clips uploaded to the account. A menu without a prompt will hang up the call when reached, so set one before publishing. - **Timeout (seconds)** — How long to wait for a digit after the prompt finishes (1–30 seconds). - **Options** — Up to 12 digit-to-step mappings. Each option assigns a digit (`0`–`9`, `*`, or `#`) to a node on the canvas, with an optional short label (up to 20 characters, e.g. `Sales`) displayed next to the digit on the node so the menu is readable at a glance. A menu with no options is allowed (e.g. an announcement that only branches on timeout). ### Menu Exits A Menu node has one exit per configured option, plus two fallback exits: - **Timeout** — Followed if the caller doesn't press anything before the timeout elapses. - **Invalid** — Followed if the caller presses a digit that isn't in the options list. Each fallback exit can be routed to another step (for example, an operator extension or voicemail), or routed back to the same Menu node — which plays the prompt again so the caller can try again. ## Audio Clip An **Audio Clip** node plays an [audio clip](./audio-clips.md) to the caller, then continues to the next step in the flow. Use it for announcements, disclaimers, or hold messages. - **Audio Clip** — The clip to play. An Audio Clip without an audio clip will hang up the call when reached, so set one before publishing. - **Next** — The step to follow once the clip finishes. If the **Next** exit is left unconnected, the call ends after the clip plays. ## Renaming a Dial Plan Click the dial plan name at the top of the detail page to edit it inline. Press **Enter** or click away to save. ## Extension Number Each dial plan can have an extension number assigned. Edit it from the field next to the name on the detail page. --- ## E911 Dispatch Addresses When someone dials 911 from a phone on your account, the upstream carrier needs to know **where the caller is** so emergency services can show up at the correct address. This page walks through how locations and devices fit together to make that work. US federal law (Kari's Law and RAY BAUM's Act) makes the dispatch obligation strict: the address must be specific enough for first responders to find the caller, and dialing 911 must work without any prefix. handles the carrier side of that obligation; **the account is responsible for keeping each device's location current** as people move desks, branches, or sites. ## The pieces Two things combine to dispatch an emergency call: - **Locations** — the physical sites your account operates from. Each location is a structured address registered with the upstream carrier. Manage them on the [Locations](./locations.md) page. - **Devices** — the phones at those sites. The device placing the call is what determines the dispatch address: - **Deskphone** — its dispatch location is set on the device itself. - **DECT base** — its dispatch location is set on the base; every handset paired with the base inherits it. - **DECT handset** — has no location of its own; uses whatever the paired base has. Manage devices and their location assignment on the [Devices](./devices.md) page. Softphone and in-app SDK calls are handled differently. There is no fixed physical site for these users, so the dispatch address comes from the user themselves rather than from a device's location. The app prompts each user to register an emergency address before they can place any call, validates it against the official street-address database, and re-prompts when the user's network changes (a typical signal they've moved offices, hotels, or home setups). When that user dials 911, the call goes out with their currently-confirmed address attached — same dispatch quality as a fixed deskphone, as long as the user keeps up with the prompts. Admins don't manage these addresses from the portal; each user owns their own. If the same user also has a deskphone or DECT handset assigned to them on the account, the two paths are independent — calls from the desk phone use the device's location, calls from the softphone use the user's registered address. ## Provisioning a location A new location is registered with the carrier from the [Locations](./locations.md) page. Each location moves through a status machine: | E911 status | Meaning | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **None** | Not registered yet. | | **Pending** | Submitted to the carrier; waiting for a response. | | **Binding** | Carrier accepted the address and is finalizing the dispatch record. | | **Provisioned** | Ready. Emergency calls from devices at this location will dispatch correctly. | | **Failed** | The carrier rejected the address — almost always because it's not a deliverable street address. Use the **Retry E911** action on the row to try again after fixing the address. | **Only "Provisioned" locations actually dispatch.** Assigning a device to a location that is still pending or has failed is allowed (handy during onboarding), but at call time the platform treats that device as if it had no location at all. ## Assigning a device to a location When you add a deskphone or a DECT base, the form includes a **Location** picker. Pick the site the device lives at. You can change it later from the device's detail page. DECT handsets do **not** have a location field of their own — they inherit from the paired base. To change where a handset dispatches, either change the base's location (all handsets on that base follow) or repair the handset with a base at a different site, both from the device detail page. The handset's detail page shows the inherited location as read-only. ## What happens when someone dials 911 1. The phone places the call. 2. The platform reads the location bound to that device (or the location of the paired base, for handsets). 3. If the location is **Provisioned**, the carrier registration ID is attached to the call and the carrier routes it to the correct emergency dispatcher (PSAP) for that address. 4. If the location is missing, still pending, or failed, the call still completes but **no address is attached** — the carrier routes it to a default dispatch center (typically tied to the calling number's area code), and the dispatcher who answers has no specific street address for the caller. They'll have to ask the caller verbally, which costs precious seconds in an emergency and only works if the caller is conscious and can speak. Every emergency call also writes an audit log entry, so you have a paper trail regardless of whether the dispatch succeeded. ## Keeping things accurate - **When a user moves desks within a site** — no action needed, unless they also change devices. The device's location is what counts. - **When a user moves to a different office** — reassign the device they take with them to the new location. If they switch to a phone that lives at the new office, no action needed: that phone already has its own location. - **When you swap out a phone** — the replacement starts with no location. Set it on the device's detail page right after registering it. - **When you decommission a location** — devices that were assigned to it have their location cleared automatically. Reassign them before the change so emergency calls don't dispatch with no address. ## Failure modes to watch for | Situation | What happens at call time | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Device has no location assigned | Call completes; no address sent to the carrier. Routes to a default dispatch center with no street address for the dispatcher to act on. | | Location is still **Pending** or **Failed** | Same as no location — the call completes but with no specific address. | | Location was deleted after assignment | The device's location is cleared. Same outcome until you reassign the device. | ## Testing without dialing real 911 Do not place real 911 calls to test. Dial **933** instead — it's a non-routable test number that exercises the full dispatch flow (carrier-side address lookup, audit) and reads your registered address back to you, without contacting a real emergency dispatcher. --- ## Faxes The Faxes page lists the faxes sent and received on the selected account, so you can review fax activity, send new faxes, and clean up old records. ## Viewing Faxes Navigate to **Faxes** from the sidebar. Each row shows: - **Direction** — whether the fax was `Inbound` (received) or `Outbound` (sent). - **Status** — `Received` for inbound faxes; `Pending`, `Delivered`, or `Failed` for outbound faxes. - **From** — the sender's number. - **To** — the recipient's number. - **Pages** — the number of pages transmitted, once known. - **Received** — when the fax record was created. ## Filtering and Searching Three controls above the table narrow the list: - **Direction** — show all faxes, or only inbound or only outbound. - **Status** — filter by lifecycle state. The available statuses depend on the selected direction. - **Search by number** — type any phone number (in any format) to show only faxes whose sender or recipient matches. ## Sending a Fax Click **Send fax** above the table to open the send dialog, then: 1. **Send from** — choose one of the account's fax-enabled numbers. Only active, fax-enabled numbers appear. If exactly one qualifies it's selected for you; if none do, the account can't send or receive faxes until you enable fax on a number from the **Phone Numbers** page. 2. **Document** — drop a PDF onto the upload area or click to browse. Faxes accept PDF files only, up to 25 MB and 50 pages. A progress bar shows the upload. 3. **Send to** — enter the destination fax number. Click **Send fax** to submit. The new fax appears in the table as `Pending` and moves to `Delivered` or `Failed` as it is processed. Each account has an hourly limit on outbound faxes. If the limit is reached, the send is rejected and you can try again later. ## Opening a Fax Click any fax row to open its detail page; use the back arrow to return to the list. When the document is available, the page renders it and — depending on your browser — lets you zoom, page through, print, and download it. Some faxes show a message instead of the document: a fax still in progress shows that it's pending until it completes, and a fax whose stored copy was removed (numbers set to delete fax documents no longer keep a copy) shows that no document is available. Unread received faxes are highlighted in the list. Opening one marks it as read for everyone on the account. ## Deleting a Fax To remove a fax record, hover over its row, click the **trash** icon, and confirm. This deletes the fax record only; it cannot be undone. --- ## Account Administration As an account administrator, you can manage the day-to-day operations of your account. This section covers: - [Getting Started](./dashboard.md) — Signing in and selecting an account ### Directory - [Users](./users.md) — Create and manage users and their SIP endpoints - [Devices](./devices.md) — Manage DECT base stations and handsets ### Telephony - [Phone Numbers](./phone-numbers.md) — View numbers, order new ones, and port existing numbers - [Port Orders](./port-orders.md) — Transfer phone numbers from other carriers - [Dial Plans](./dial-plans.md) — Design call-routing trees that decide where inbound calls go (menus, hours, forwarding) - [Queues](./queues.md) — Hold callers until an available user agent answers - [Ring Groups](./ring-groups.md) — Ring several users at once or in sequence under a single destination - [Shared Voicemails](./shared-voicemails.md) — Team mailboxes that multiple users can monitor and manage - [Voicemail Greetings](./voicemail-greetings.md) — Record or upload custom greetings for user and shared voicemail boxes - [Voicemail Device Access](./voicemail-device-access.md) — Star codes and keypad reference for checking voicemail from any device - [Schedules](./schedules.md) — Define business hours and holidays that dial plans can branch on - [Voice Apps](./voice-apps.md) — Higher-level call experiences (queues, IVRs, announcements) that dial plans can hand off to ### Analytics - [Call Logs](./call-logs.md) — Searchable history of every inbound and outbound call on the account - [Quality](./quality.md) — Monitor call quality metrics (MOS, jitter, packet loss) across the account ### Settings - [Locations](./locations.md) — Manage physical office locations --- ## Locations Locations represent the physical office sites for your account. They are used for E911 emergency service addressing and general organizational purposes. :::tip[E911 dispatch] For the full picture of how locations, devices, and call-time dispatch fit together, see [E911 Dispatch Addresses](./e911.md). ::: ## Viewing Locations Navigate to **Locations** from the sidebar. The locations list shows all registered locations with their name, address, and E911 status. ### E911 Status Each location displays an E911 status badge: - **Provisioned** — E911 is active and the location's address is registered for emergency services - **Pending** / **Binding** — E911 provisioning is in progress - **Failed** — E911 provisioning encountered an error. Use the **Retry E911** action to try again. - **None** — E911 has not yet been provisioned for this location E911 is provisioned automatically after the location address is validated. If provisioning fails, an admin can retry it from the row actions menu. When any location's E911 provisioning is **Failed**, an attention badge appears next to **Settings** in the sidebar and on the **Locations** tab — so a location that can't yet register for emergency service isn't missed. The badge clears once every location is provisioned (retry it, or correct and re-validate the address). ## Adding a Location 1. Click the **Add Location** button. 2. Enter a **Location Name** (e.g., "Main Office", "Branch - Downtown"). 3. Start typing the address in the **Address** field. The autocomplete will suggest matching addresses. 4. Select an address from the suggestions, or click **Enter it manually** to fill in the fields yourself. 5. Click **Add Location**. validates the address and starts E911 provisioning automatically. ## Editing a Location 1. Click on a location to open the edit form. 2. Update the location name or address as needed. 3. Click **Save Changes**. ## Main Location Every account has one **main location** — the fallback used to determine tax jurisdiction and regulatory fees for anyone or anything that doesn't have a specific location of its own: - Users without an assigned device (e.g., WebRTC-only users) are treated as roaming and resolve to the main location. - Phone numbers without a more specific device or user location use the main location as their fallback. The main location does **not** determine E911 dispatch — emergency calls always use the calling device's own location. ### How it's set Your account's main location is created automatically when the account is set up, from the address entered at that time. After that, the assignment never changes on its own; you can swap it at any time from the **Locations** table. ### Relocating When your business moves, **edit the main location's address in place** — open its row's edit dialog on the **Locations** table and update the address. The address is re-validated and emergency (911) service is re-registered automatically, and every device stays attached to the location, so nothing needs to be reassigned. Editing the address is how you relocate; there is no separate "billing address" to keep in sync. ### Switching the main Hover over any other active location's row in the **Locations** table and click the **Set as main location** (house icon) action. The change saves immediately, and the **Main** badge moves to the new row. ### Deleting the main The main location cannot be deleted directly. The delete row action is disabled on the main location's row with a tooltip explaining why. To delete it: 1. Pick another active location and click **Set as main location** on its row. 2. Delete the now-non-main location from the **Locations** table. ## Per-Location Emergency Call Notification Add **Emergency call notification recipients** on a location to alert local staff when a 911 call is placed from that location — for example, the branch office front desk for calls from the branch office. - Add one or more email addresses on the location edit dialog. When 911 is dialed from a phone bound to this location, every recipient here receives an email. - Recipients here are notified **in addition to** the account-level recipients, not in place of them. Leave the list empty if no location-specific staff need to be alerted; account-level recipients still receive the email. ## Deleting a Location 1. Open the location's edit view. 2. Click **Delete**. 3. Confirm the deletion. A location cannot be deleted while anything is still assigned to it — a phone, an open hardware order, or a phone number's directory listing. If any are, the delete is refused and you clear the reference first: reassign the phones to another location, cancel or reassign the open orders, and point the directory listing at another location. This prevents phones from being stranded without a valid emergency (E911) location, which would block their outbound calling. :::warning Deleting a location is permanent. Make sure no active services depend on this location before removing it. ::: ## Address Format Locations require a complete US street address: - **House/Building Number** — The street number - **Street Name** — The street name - **Suite/Unit** (optional) — Apartment, suite, or unit number - **City** - **State** — Two-letter state code - **ZIP Code** — Five-digit postal code --- ## Ordering New Numbers Use this wizard to search for available phone numbers and add them to your account. ## How It Works 1. Navigate to **Phone Numbers** → **Add Numbers** → **Order New Numbers**. 2. Search by area code or ZIP code to browse available numbers. 3. Select the numbers you want from the results. 4. Click **Order** to add them to your account. Ordered numbers are available immediately and appear on the Phone Numbers page. ## What it costs Before the order is placed, the confirm step states what the numbers will add to the account's bill: the monthly total, and the per-number rate agreed for this account. Taxes and fees are additional. Numbers are charged from the moment they activate, and only for the days they are in service, so the first month is normally less than the figure shown. If no per-number rate has been agreed for the account, the confirm step still names the numbers as billable but shows no price. Sandbox and demo accounts are never billed, and say so instead of quoting a price. ## Limits Each account has a cap on the total number of active phone numbers it can hold (25 by default, configurable per account). An order that would push the account over its cap is rejected. If you need to hold more numbers than the default, ask your platform administrator to raise the cap before placing a large order. Ordered numbers are activated as soon as the order completes — there is no multi-day wait for a new purchase. Porting numbers from another carrier is a separate process with its own timeline; see [Port Existing Numbers](./port-orders.md). ## Tips - You can select multiple numbers from a single search before ordering. - If you need numbers in a specific area code, search by area code for the fastest results. - To transfer numbers you already own from another carrier, use [Port Existing Numbers](./port-orders.md) instead. --- ## Call Parking Call parking lets you put a live call on hold in a shared "slot" that anyone on the account can pick up from any registered phone. It's the standard way to hand a call off when you don't know which person or which desk should take it: park the call, announce "Bob, you have a call on slot 3", and Bob dials a short code from wherever he is to take over. A parked call hears hold music until someone retrieves it. If nobody picks it up within the configured timeout (five minutes by default), the original parker's phones ring back so the call isn't lost. ## Star Codes | Code | What it does | | -------- | -------------------------------------------------------------------------------------------------- | | `*68` | Park the current call into **slot N** (works for blind transfers), or pick up the call from slot N | | `*68` | **Attended-transfer only** — system picks the next free slot and announces it | `` is a slot number from `1` to `999`. Pick whichever numbers make sense for your team — many offices use `*681`–`*689` as their day-to-day "park slots". ## Parking a Call Parking happens **mid-call** via your phone's transfer button — it's a kind of transfer where the destination is a slot instead of another person. ### Park to a specific slot (blind transfer) This is the simplest path. Because you already know the slot number, there is nothing to listen for — you commit the transfer right away without waiting for the slot to "answer". 1. While on the call, start a **blind transfer** on your phone. 2. Dial `*68` (e.g. `*683`). 3. Commit the transfer. The other party is now parked at slot N and hears hold music. Tell whoever should take the call which slot it's on. ### Let the system pick the slot (attended transfer) If you don't want to choose a slot number yourself, you can let the system pick the next free one. This **must be an attended (consultation) transfer**, because the system has to play the chosen slot number out loud to you before you commit — and a blind transfer commits before it can talk. 1. While on the call, start an **attended transfer** on your phone (consultation). 2. Dial `*68` (no slot number). 3. **Wait** for the announcement: **"Parked at slot N"**. 4. Commit the transfer. ## Retrieving a Parked Call From any registered phone on the account, dial `*68` where N is the slot the call is on. You're connected directly to the parked caller. ## Busy Lamp Field (BLF) Keys If your phone has programmable line keys that support BLF, you can set them up to monitor specific parking slots. A lit key means the slot has a call waiting; pressing it picks the call up. BLF is the fastest way to use parking in a busy office: parker says "call on slot 3", everyone with a BLF key for slot 3 sees it light up, and the first person to press the key takes the call. ## Timeout and Ring-Back A parked call doesn't sit on hold forever. If nobody retrieves the call within the timeout (**five minutes by default**), the system rings the original parker's phones back to make sure the call isn't dropped. While the ring-back is in progress, the parked caller continues to hear hold music — they don't notice the timeout. ## What If the Slot Is Already Taken? If you try to park into a slot that already has a call on it (you and a colleague both grab slot 3 at the same time, for example), the system can't park the second call there. Instead, it immediately rings the second parker's phones with a "hot" ring-back — same shape as the timeout ring-back, but right away. The practical effect is that **the call isn't lost**. When you pick up the ring-back, you're reconnected to your caller and can try a different slot. The same fallback fires if you blind-transfer to bare `*68` (no slot number): there's no way for the system to tell you which slot it picked, so it rings your phones instead. Use `*68` for blind transfers, and reserve bare `*68` for attended (consultation) transfers where you can hear the slot announcement before committing. ## What Callers Hear From the caller's side, parking is just "you're on hold": 1. The transfer completes silently — no announcement on their end. 2. They hear the account's hold music (or the default music if your account doesn't have hold music set). 3. When someone retrieves the call, the music stops and the conversation resumes. 4. If the call times out, hold music continues during the ring-back; if the ring-back succeeds, the music stops when the parker answers; if not, the call is released. ## For Administrators - **Timeout duration** is set per-account from the **Parking Timeout** field on the account's info card in the admin portal. Entered in minutes (1–60); defaults to 5 minutes. - **Hold music** uses the account's configured hold music clip (see **Audio Clips**); accounts without a custom clip fall back to the platform default. - **No per-slot configuration.** Slots are not pre-allocated or named — any positive slot number is valid as soon as someone dials it. There's no "create parking slot" step in the admin portal. --- ## Phone Numbers The Phone Numbers page shows all phone numbers assigned to your account. From here you can order new numbers or initiate a port to transfer existing numbers from another carrier. ## Viewing Phone Numbers Navigate to **Phone Numbers** from the sidebar. The table displays all numbers with their assignment status and associated port order (if applicable). Use the **search box** above the table to filter the list as you type. It matches on the phone number, outbound caller ID name, inbound caller ID prefix, carrier, and call-routing target, and applies within the tab you're viewing (**Active**, **In Progress**, or **Cancelled**) — so switch tabs if you don't find what you're looking for. Two columns on the **Active** tab cover opposite directions of a call, and neither is a modifier of the other: - **Outbound Caller ID** — the name shown to the person you're calling on outbound calls from that number. - **Inbound Caller ID Prefix** — the label added in front of an incoming caller's identity on the device that answers, including when the call is forwarded out to an external number. Numbers without one read **Not set**. Set or change a prefix on the number's detail page, under [Inbound](#inbound). Fax-enabled numbers show **Not applicable** under **Call Routing**: a fax number receives inbound calls as faxes, so it has no call routing to configure — neither inbound nor outbound faxing depends on it. ## Adding Phone Numbers Click the **Add Numbers** button to see two options: ### Order New Numbers Search available phone numbers from inventory and order them for your account. 1. Click **Add Numbers** → **Order New Numbers**. 2. Browse or search for available numbers. 3. Select the numbers you want. 4. Choose where the numbers should route calls — a user, dial plan, voice app, queue, ring group, or shared voicemail — so they're ready to take calls as soon as they're added. This step is optional; choose **Set up routing later** to configure each number from its detail page afterward. 5. Complete the order. ### Port Existing Numbers Transfer phone numbers you already own from another carrier to . 1. Click **Add Numbers** → **Port Existing Numbers**. 2. This launches the port order wizard. See [Port Orders](./port-orders.md) for the full process. ## Directory Listing The **Directory Listing** card on the Settings page lets you register one of your phone numbers in national directory databases, 411 directory assistance, and online business directories. Only one number per account can have an active listing. Temporary numbers are not eligible. ### Why directory listing matters A registered directory listing feeds your business information into the national directory database, which in turn distributes it to online business directories, mapping services, and data aggregators that people use every day to find local businesses. Having consistent, accurate information in this network helps customers find you and builds confidence that your business is established and legitimate. Directory registration also affects how your outbound calls are perceived. Carriers and call-screening apps cross-reference calls against directory databases. A number with a verified business listing is far less likely to be flagged as spam, which means your calls are more likely to be answered. For a small business that relies on calling customers back — appointment confirmations, follow-ups, estimates — this directly impacts your ability to reach people. ### Setting up a listing 1. Navigate to **Settings**. 2. In the **Directory Listing** card, select the number you want to list from the dropdown. 3. Choose a listing type: - **Listed** — your business name, address, and phone number are published in directory databases, available via 411, and distributed to online directories. - **Non-Listed** — stored in the directory database for caller verification but not published in directories. Still available via 411. - **Non-Published** — stored for caller verification only. Not available via 411, printed, or online directories. 4. Enter your **business name** (max 200 characters). 5. Optionally select a **location** to include your address in the listing. 6. Click **Save**. The update is submitted to the carrier and may take a short time to process. To change which number is listed, select a different number from the dropdown. The old listing will be removed automatically. To remove the listing entirely, select **None**. ## Phone Number Details Click any active phone number in the list to open its detail page. From here you can view and manage the number's settings. ### Temporary Numbers If the number is temporary, an informational banner appears at the top of the detail page explaining that the number is not permanently assigned and showing when it will expire. Click **Keep this number** in the banner to convert it into a permanent number — the expiry is removed and the number behaves like any other on the account. When a temporary number reaches its expiry, it is automatically released back to the carrier. Released numbers appear under the **Cancelled** tab on the phone numbers list. From there you can click **Recover** to attempt to re-acquire the number; recovery may fail if the carrier has already reassigned it elsewhere. ### E911 If the phone number is associated with a location that has E911 configured, an **E911** card appears showing the current provisioning status and the linked location name. This is display-only — E911 configuration is managed through the Locations page. ### Inbound The **Inbound** card controls where incoming calls to this number are directed. Use the routing target picker to route calls to a user, ring group, dial plan, or voice app. The change takes effect immediately. To deliberately turn a number into a dead end, choose **Route to nothing (drop calls)** at the top of the picker. You'll be asked to confirm, because incoming calls will then be dropped immediately — the caller hears no ring, no voicemail, and no message. This is distinct from a number with no routing target set, which rings everyone on the account so a new number never silently loses calls. Restore normal handling at any time by picking a routing target again. - **Inbound Caller ID Prefix** — an optional label added in front of the incoming caller's identity shown on the device that answers. For example, with a prefix of `[Acme]` a call from John Smith rings as `[Acme] John Smith`. When the caller's name isn't known, the prefix is shown with their number instead, as `[Acme] +14084806024`. This is useful when calls to a number are answered on behalf of a specific brand or business, so whoever answers knows which company the caller is trying to reach. It affects only what the answering party sees; it does not change call history or reporting. Leave the field empty for no prefix. The prefix also applies when the call is **forwarded out to an external number**, such as a staff member's personal cell phone — the forwarded call still shows the original caller, now labelled with the prefix. This makes it possible to tell a business call apart from a personal one on a phone that receives both, and applies however the number is routed — to a user, a ring group, a call queue, or a dial plan. Whether the label reaches an external handset ultimately depends on the receiving mobile carrier, which is outside our control. ### Outbound The **Outbound** card controls outbound calling settings for the number. - **Outbound Calling** — toggle whether users can place outbound calls using this number as their caller ID. - **Outbound Caller ID** — the name displayed to the called party on outbound calls (also known as CNAM). Maximum 15 characters; letters, digits, spaces, and `& , . ' - ( )` are allowed. Edit the field and confirm the change in the dialog that appears. Only one caller ID change can be pending per phone number at a time — once submitted, no further updates will be accepted for that number until the carrier finishes processing (up to 72 hours). :::note Caller ID can only be updated for active phone numbers. ::: ### Fax The **Fax** card controls fax handling for the number. :::tip Use a **dedicated number for fax**. Because a fax-enabled number stops handling inbound voice and bypasses its routing, turning fax on for a number that's also used for calls — a user's line, a ring group, a dial plan — will take it out of those voice flows. Set aside a separate number for faxing to avoid clashing with your voice setup. ::: - **Fax Enabled** — toggle whether this number is set up for faxing. Toggling it asks you to confirm, because while fax is enabled **inbound calls to the number are received as faxes instead of voice** — it no longer takes inbound voice calls, and its inbound routing target is bypassed (kept, and it resumes when you disable fax). - **Delete fax documents** — controls whether a copy of each fax is kept. - **Off** (the default): faxes are stored. A received fax is kept here to view and download, and its notification email links to it. - **On**: faxes aren't stored. A received fax is sent only as an attachment on the notification email and no copy is kept, and a fax you send has its source file deleted once it finishes (delivered or failed). - With this on, the email is the only copy of a received fax, so at least one notification recipient is required — the toggle stays off until you add a recipient, and you can't remove the last recipient while it's on. - **Notification Recipients** — when fax is enabled, add the email addresses notified when a fax is received on this number. Enter an address and click **Add**; remove one with the trash icon. Each recipient must be a valid, unique email address, and you can add up to 10. Leave the list empty to receive faxes without email notifications — unless **Delete fax documents** is on, which needs at least one recipient. Recipients are configured per phone number, so different fax numbers can notify different people. Account-wide fax volume limits are configured separately on the account Settings page. ### Cancelling a Number To permanently cancel a phone number, open the detail page and click **Cancel Number** at the bottom. A confirmation dialog will appear before the number is cancelled. :::warning Cancelling a number releases it back to the carrier. The number will stop receiving and making calls. You can attempt to recover it from the **Cancelled** tab, but this may fail if the carrier has already reassigned it. ::: ## Port Order Status If a phone number has an associated port order, clicking the row navigates you to the port order detail page where you can track the transfer progress. --- ## Port Orders Number porting lets you transfer existing phone numbers from your current carrier to . The process involves creating a port order, providing subscriber details, and scheduling the transfer. ## Port Order Workflow A port order moves through these stages: 1. **Draft** — Enter phone numbers, subscriber info, service address, and schedule. 2. **Approved** — Review and electronically sign the authorization. 3. **Submitted** — The order is sent to the carrier for processing. 4. **Scheduled** — The carrier confirms a scheduled transfer date. 5. **Complete** — Numbers are successfully transferred. If the carrier rejects the order, it enters an **Exception** state where you can edit and resubmit. ## Creating a Port Order 1. Navigate to **Phone Numbers** → **Add Numbers** → **Port Existing Numbers**. 2. Enter the numbers you want to port, one per row. Press **Enter** to add another row, or paste a whole list into any row — one number per line, or separated by commas, semicolons or tabs, so a column copied straight out of a spreadsheet works as-is. Each number in the paste gets its own row. 3. Formatting doesn't matter: `(770) 212-6011`, `770-212-6011`, `+1 770 212 6011` and `7702126011` are all accepted, and numbers are tidied into a consistent format as you type. 4. Check each row. Anything that can't be used is flagged where it sits, with the reason — a number that isn't valid, a toll-free or non-US number, an extension that needs removing, or a heading that came along with the paste. A repeat of a row above is flagged as a duplicate: the number is only ever ordered once, so leaving the extra row would mean the list you see is longer than the order you get. Delete it. 5. Fix or delete every flagged row before continuing. The order can't move on while one is outstanding — that's deliberate, so a number you meant to transfer can't be quietly left behind. A count of how many rows still need attention sits above the buttons, so a long list can't hide one further down. 6. The system then checks each number's eligibility and identifies the current carrier. If numbers belong to different carriers, they're automatically split into separate port orders. 7. Click **Create Port Order** to save a draft. :::note Numbers already in service A number that is already live can't be ported, and the order will name it rather than just refusing. If it's already on your own account, it doesn't need porting — take it off the order. If it's in service on another account, it's with the same carrier already, so there's no port to run: moving it is an intra-carrier transfer, which support arranges for you. ::: :::note Number limits Each order holds up to 100 numbers. Separately, every account has a limit on how many phone numbers it can hold in total, and a port order counts against it as soon as the draft is created. If an order would take you past that limit, you'll be told — and the answer is to raise the account's limit rather than to shorten the order. An account admin should contact their platform administrator to do that. ::: ## Completing the Draft The port order form has five sections: ### Numbers The phone numbers to transfer, in the same row list used when starting the order. You can add or remove numbers while the order is in draft; as before, every row has to be usable before you can move on. ### Call Routing Choose where the numbers should send incoming calls — a user, dial plan, voice app, queue, ring group, or shared voicemail. The routing you pick here is applied to the numbers ahead of time, so calls are already going to the right place the moment the transfer completes — there's no gap where a newly transferred number rings nowhere. This step is optional. If you'd rather set routing up later, choose **Set up routing later** and configure each number from its detail page after the port completes. If you pick a destination now and later delete it before the transfer completes, the number simply reverts to needing routing — nothing is guessed on your behalf. ### Port Details - **Business Telephone Number (BTN)** — The main number on your current carrier account. - **Business Name** — Must match your current carrier's records exactly. - **Authorized Contact** — The person authorized to request the transfer. - **Account Number** and **PIN** (optional) — Your current carrier account credentials. ### Service Address Enter the service address exactly as it appears on your current carrier's records. Address mismatches are the most common reason for port rejections. ### Timing & Documents - **Port Date** — The earliest date you can request is 5 business days out, and the latest is 30 calendar days out. In practice, the carrier typically confirms a date further out than the earliest allowed (see [Timeline](#timeline) below). - **Port Time** — Between 8:00 AM and 8:00 PM Eastern. - **CSR Upload** (recommended) — Upload a Customer Service Record from your current carrier. The CSR lists the account's authoritative subscriber details and dramatically reduces the risk of rejection. - **Bill Copy Upload** (recommended) — Upload a recent bill from your current carrier. A bill is a useful fallback when a CSR isn't available and can help resolve carrier disputes about account details. ## Approving a Port Order After completing all sections: 1. Review the summary on the Review page. 2. Type your full name as an electronic signature. 3. Check the authorization box. 4. Click **Approve & Sign**. The Review page also states what these numbers will add to the account's bill: the monthly total, and the per-number rate agreed for this account. Taxes and fees are additional. A ported number is charged from the day it activates on the new carrier, not from the day the order is created, so the first month is normally less than the monthly figure. ## Timeline Number porting is a carrier-to-carrier process, and the overall timeline is set by the industry — not by . What to expect: - **Typical duration:** 10–15 business days from submission to completion for most standard ports. Larger orders or ports with missing details can take longer. - **Requested vs. confirmed date:** The date you pick on the port order is a _request_, not a guarantee. Your current carrier (the "losing carrier") may come back with a different date. We'll communicate the confirmed date as soon as we receive it, with enough lead time for you to prepare. - **Rejections extend the timeline.** If any detail on the order doesn't match the losing carrier's records — business name, service address, account number, authorized contact — they will reject the port. Each rejection resets the clock: you correct the information, we resubmit, and the carrier begins review again. Uploading a recent CSR or bill up front is the single best way to avoid this. ## Tracking Progress Once submitted, the port order detail page shows the current status timeline. You'll be notified when the carrier provides updates. No action is needed during the processing stage unless the carrier rejects the order. ## Changing the Port Date For an order that is **Processing** or **Scheduled**, use **Change Port Date** on the order detail page to request a new date and time. The order keeps its current status, and the carrier may reject the requested date. ## Handling Rejections If a carrier rejects the port order: 1. The order enters **Exception** status with a rejection reason (e.g., address mismatch, invalid account number). 2. Click **Edit & Resubmit** to correct the information. 3. Resubmit the order to the carrier. --- ## Phone Provisioning Provisioning is how a phone learns its SIP credentials, line assignments, and other settings without anyone typing them in by hand. Once a phone is added on the [Devices](devices.md) page, it pulls its configuration on boot and is ready to make and receive calls. **You do not need to think about any of this.** If a phone is not behaving as expected, contact support. ## What lives on the account The provisioning token and URL used to bootstrap phones live on the [Account Settings](accounts.md) page under the collapsible **Provisioning** section. You normally do not need to handle these values yourself, but they are there if a support engineer asks. Rotating the token invalidates the previous one. Phones already deployed under the old token will need to be re-provisioned. --- ## Call Quality The Quality dashboard shows aggregated call quality metrics for your account, helping you identify and troubleshoot degraded calls. ## What is MOS? Call quality on this page is reported primarily through **MOS — Mean Opinion Score**. MOS is the industry-standard rating for voice quality, expressed on a 1 to 5 scale where higher is better: | MOS | Perceived quality | | --------- | --------------------------------------------- | | 4.3 – 5.0 | Excellent — indistinguishable from in-person | | 4.0 – 4.3 | Good — minor issues, callers unlikely to care | | 3.5 – 4.0 | Fair — occasional glitches noticeable | | 3.0 – 3.5 | Poor — callers likely to complain | | Below 3.0 | Unacceptable — conversation is difficult | MOS is computed from measurable factors like jitter, packet loss, and latency, so you can use it as a single headline number without interpreting each underlying metric. ## KPI Cards At the top of the page, four cards summarize key metrics for the selected time period: - **Avg. MOS** — Mean Opinion Score averaged across all answered calls. Scores above 4.0 are good, 3.5–4.0 are fair, and below 3.5 indicate quality issues. - **Avg. Jitter** — Average jitter in milliseconds. Lower values indicate more consistent audio delivery. - **Avg. Packet Loss** — Average percentage of packets lost during calls. - **Total Calls** — Number of answered calls with quality data in the selected period. ## MOS Over Time The area chart displays how MOS trends over the selected time window. A dashed red line marks the 3.5 quality threshold — calls below this line may have noticeable audio issues. ## Lowest Quality Calls A table lists the worst-quality calls sorted by MOS (lowest first). Each row shows the call time, direction, caller, recipient, duration, MOS score, jitter, and packet loss. Use this to identify specific calls that experienced quality degradation. ## Time Range Toggle between two views: - **Last 24 Hours** — Hourly buckets for the past day. - **Last 7 Days** — Daily buckets for the past week. The dashboard opens on **Last 7 Days** by default. --- ## Queues Queues hold callers in a waiting line until an available agent answers. Unlike a [ring group](./ring-groups.md) — which rings everyone at once for a single call — a queue keeps callers on hold (with music or announcements) and pulls them off the front of the line as agents become available. Use queues when you expect more concurrent calls than agents. ## Viewing Queues Navigate to **Queues** from the sidebar. The table shows each queue's name, extension, strategy, timeout, and current member count for the selected account. ## Creating a Queue 1. Click **Add Queue**. 2. Enter a **Name** (e.g., "Sales", "Tier 1 Support"). 3. Optionally assign an extension number so the queue can be dialed directly. 4. Click **Add Queue**. ## Queue Settings Open a queue to edit its settings. Settings are grouped into a short top-level set everyone tends to touch, plus an **Advanced** section (collapsed by default) for the rest. Top-level: - **Extension** — Internal number for directly dialing the queue. - **Strategy** — How waiting callers are distributed to agents. See [Strategies](#strategies) below. - **Max wait (seconds)** — How long a caller waits for an agent to answer, from `1` to `3600` (one hour). New queues default to `300` (5 minutes). If an agent's phone is already ringing when the time is up, that ring is allowed to finish before the timeout behavior runs. - **Timeout Behavior** + **Timeout Target** — What happens to a caller whose Max wait elapses, and where they go. See [Timeout behavior](#timeout-behavior) below. - **Hold music** — The audio callers hear while they wait in this queue. Defaults to the account's hold music. See [Hold music](#hold-music) below. - **Entry prompt** and **Periodic prompt** — Your own recorded messages for waiting callers: one played once as they join, one replayed while they wait. See [Entry and periodic prompts](#entry-and-periodic-prompts) below. Inside **Advanced**: - **Max queue length** — Maximum number of callers waiting at once. `0` means unlimited. When the queue is full, new callers are rejected and never enter. - **Wrap-up time (seconds)** — Cooldown applied to the agent who just took a call from this queue before they're eligible for another one. `0` disables wrap-up. - **Position announcements** — Periodically tells waiting callers their place in line ("You are caller number three"). See [Position announcements](#position-announcements) below. - **Press-1 Callback** — Lets a waiting caller hang up and have an agent call them back when one becomes available. See [Press-1 callbacks](#press-1-callbacks) below. - **Join Empty** — Whether new callers may enter when no eligible agents are signed in. See [Join / Leave Empty](#join--leave-empty) below. - **Leave When Empty** — Whether callers already waiting are dropped when the last eligible agent disappears. ### Strategies Strategies decide which member to ring next when a caller reaches the front of the queue. - **Ring all** — Rings every signed-in member at the same time; the first to answer takes the call. Best when each call needs the fastest possible pickup and team size is small. - **Linear** — Rings members in a fixed order based on their **position** value (lowest position first). Use when there is a clear primary, secondary, tertiary, etc. - **Round robin (memory)** — Rings members one at a time in **position** order, remembering where the rotation left off. The next call starts with the member after the previous one rung. Distributes work evenly over time. Weight is ignored. - **Least recent** — Rings the member who has been idle the longest (longest time since their last call ended). - **Fewest calls** — Rings the member who has handled the fewest calls so far today. The counter resets at midnight in your account timezone. - **Random** — Rings a member chosen uniformly at random. - **Weighted random** — Random with bias: members with lower **weight** are more likely to be chosen on any given call. A higher-weight member can still be picked first — just with proportionally lower probability (`1 / (weight + 1)`). ### Join / Leave Empty These two settings control what happens when no agents are available. "Eligible" here means agents who are members of this queue and could in principle take a call. **Join Empty** — applies to a _new_ caller arriving at the queue: - **Strict** — The caller is rejected if no agent is signed in. - **No agents online** — The caller is rejected only when zero agents are signed in. Anyone signed-in but on a call or paused still counts as online, so the caller may enter and wait. - **No agents available** — The caller is rejected only when zero agents are _available_ right now (signed in, not on a call, not paused). This is the most lenient option for the caller. **Leave When Empty** — applies to callers _already waiting_: - **Strict** — Existing callers are dropped from the queue the moment no agent is signed in. - **No agents online** — Same threshold as above, applied to people currently waiting. - **No agents available** — Same as above. Most lenient. A common pairing: **Strict / No agents online** rejects new callers when no one is signed in but lets existing ones keep waiting through brief gaps in coverage. ### Timeout Behavior When a caller has waited the **Max wait** without an agent answering, the timeout behavior runs. A ring already in progress is never cut short, so a caller an agent is being connected to is not interrupted; the behavior runs when that ring ends instead. - **None** — The call hangs up. - **Ring user** — Forwards the caller to a specific user's extension. Use for an "always-on" fallback person. - **Voicemail** — Sends the caller to voicemail. The target can be either a user (their personal voicemail) or a [shared voicemail box](./shared-voicemails.md). - **Overflow to queue** — Overflows the caller into a different queue. Useful for tiered escalation (e.g., Tier 1 → Tier 2 after waiting too long). Cycles are detected and rejected — a queue can't overflow into itself directly or transitively. The Timeout Target picker only appears once a behavior other than **None** is selected, and only shows targets compatible with that behavior. If the caller reached the queue through a [dial plan](./dial-plans.md) whose Internal Extension node has its **No Answer** exit wired to another node, that exit wins, and the timeout behavior above is not used. The same exit is taken when the queue refuses the caller outright, instead of the caller getting a busy signal. A queue refuses for two reasons: it is full, or nobody is signed in and **Join when empty** denies. A refused caller with no wired exit has no fallback, and is refused exactly as before: an external caller hears the queue-full announcement when the queue is full, and a busy signal otherwise. The timeout behavior only ever runs for a caller the queue admitted whose **Max wait** then elapsed. ### Hold music **Hold music** picks the audio callers hear while they wait in this queue. The list holds the account's [audio clips](./audio-clips.md) — upload the clip there first, then select it here. Leaving the setting on **Account default** means the queue plays whatever the account's hold music is, so one change on the account covers every queue that hasn't chosen its own. If the account hasn't set hold music either, callers hear the default hold music. Behavior notes: - The clip loops for as long as the caller waits. A position announcement, a press-1 prompt or one of the queue's own [prompts](#entry-and-periodic-prompts) interrupts it, and the clip starts again from the beginning afterwards. - Changing the setting affects new waits. A caller already waiting keeps the clip they started with — but if the queue plays any prompt, they pick up the new clip when the music resumes. Switching the queue back to **Account default** mid-wait is the exception: callers already waiting drop to the default hold music until they leave the queue. ### Entry and periodic prompts Hold music tells a caller they are waiting; it doesn't tell them anything about _why_. Two optional prompts let the queue play your own recording instead — clinic hours, an apology for the wait, a pointer to the website. Both come from the account's [audio clips](./audio-clips.md), so upload the recording there first. - **Entry prompt** — Played once, as soon as the caller joins the queue, before hold music starts. Use it for the greeting a caller should hear exactly once ("Thanks for calling. All of our team members are with other patients"). - **Periodic prompt** — Replayed for as long as the caller waits. Use it for the message that bears repeating ("You can also book online at example.com"). - **Prompt interval (seconds)** — How often the periodic prompt repeats. Defaults to 60 seconds. Minimum 10, maximum 600. The field appears once you pick a periodic prompt. Both default to **None**, which is the behavior queues had before: hold music only. Behavior notes: - Hold music pauses while a prompt plays and resumes afterwards, the same way a position announcement interrupts it. - The caller holds their place in line while the entry prompt plays, and agents are rung underneath it — an entry prompt does not delay the first ring. - A prompt is cut short the moment an agent answers, so a caller is never talking over a recording. - Only one thing plays at a time. When a periodic prompt and a position announcement come due together, the periodic prompt plays and the position announcement waits for the next interval. - Deleting a clip that a queue points at removes the prompt rather than breaking the wait — callers simply hear hold music. ### Position announcements Long, silent holds make callers anxious. With position announcements enabled, a waiting caller hears a short "You are caller number N" prompt at a configurable interval, interrupting hold music briefly each time. - **Position announcements** — Turns the announcement on for this queue. When off, callers hear uninterrupted hold music until an agent answers. - **Announcement interval (seconds)** — How often the announcement repeats. Defaults to 30 seconds. Minimum 10, maximum 600. Behavior notes: - Positions above 30 are not announced (the prompt set only covers callers 1 through 30); a caller deeper in the queue keeps hearing hold music until they reach a position the system can read. - The announcement is cut short the moment an agent answers — callers do not hear "You are caller number two" over the start of a live conversation. ### Press-1 Callbacks Long hold times push callers to hang up. With press-1 callbacks enabled, a waiting caller is offered a prompt — "Press 1 to hang up and be called back when an agent is available" — and can keep their place in line without staying on the line. When an eligible agent becomes available, the system rings that agent first; once they accept, it rings the caller back and bridges them. - **Enable callback option** — Turns the press-1 prompt on for this queue. When off, callers always wait on the line. - **Offer after (seconds)** — How long a caller waits before the prompt is offered. Lower values give the option sooner; higher values reserve it for genuinely long waits. Defaults to 60 seconds. - **Callback caller ID** — Optional. The DID used as the outbound caller ID when the system rings the caller back. Must be an active, outbound-enabled DID owned by this account. When unset, the queue falls back to the inbound DID the caller dialed (or the account's default outbound DID). Behavior notes: - Callbacks survive process restarts — pending requests are stored durably and re-evaluated on startup. - Each callback retries up to three more times on no-answer (1 minute, then 5 minutes, then 15 minutes later) before being dropped. - Callbacks are evaluated whenever an agent becomes available (including via the `*45` login and `*46` unpause star codes), so an agent coming back online wakes any waiting callbacks for queues they're a member of. - The agent who accepts the callback hears a short whisper ("Callback. Press 1 to accept, 2 to skip.") before the recipient leg is dialed, so they're never surprised mid-call. ## Members The Members card shows a banner at the top whenever no agents are currently eligible to take a call — every member is logged out, paused, or already on a call. In that state a fresh caller arriving at the queue would be rejected and skip past it in the dial plan, so the banner is a heads-up that you need to add members, have them log in, or unpause before traffic starts. The banner reflects live state (refreshed every 15 seconds) and disappears as soon as at least one member is available. Queue members are the users who answer queued calls. Each member has a **Position**, used by the **linear** and **round robin (memory)** strategies (lowest position rings first; positions start at 1). Other strategies ignore position — set it to anything. When the queue uses the **weighted random** strategy, members also get a **Weight** column. Lower weight = higher pick probability (`probability ∝ 1 / (weight + 1)`). For every other strategy the column is hidden because it has no effect. When you add a new member at a position that's already taken, existing members at that position and below shift down by one to make room — you never have to manually renumber. An agent reached at an external number sees **the original caller's number** when the call came in on one of your numbers, and one of yours when the call started inside the account. The queue only rings the **first step** of an agent's [Find Me / Follow Me](./users.md#find-me--follow-me), so an external number in a later step never receives queue calls. ### Adding a Member 1. Click **Add Member**. 2. Pick a user from the picker. Users already in the queue are hidden. 3. Set **Position** (defaults to one past the highest existing). 4. When the queue strategy is **weighted random**, set **Weight** (defaults to `0`). The field is hidden under all other strategies. 5. Click **Add**. The new member is automatically logged in for queue dispatch, so they start receiving calls right away — you don't need to ask them to dial `*45` first. Already-logged-in members are unaffected, and paused members stay paused (their break isn't ended by being added to another queue). Members can still log themselves out with `*45`. ### Removing a Member Hover over a member row and click the trash icon. Other members keep their positions; gaps are fine and don't affect ringing order. ### Agent Star Codes Agents manage their own availability from any of their devices: | Code | Action | | ----- | ------------------------ | | `*45` | Toggle logged out / in. | | `*46` | Toggle paused / unpaused | Dial the code on its own — there is no queue number to append. Availability is stored per agent, not per queue, so one `*45` logs the agent out of **every** queue they're a member of and the next logs them back in to all of them. Each code is a toggle, so an agent unsure of their state can dial it and listen: the confirmation announces the state they are now in. State follows the agent's user rather than the phone they dialed from, so a desk phone and a softphone share one state. The confirmation prompts say "paused" and "unpaused", which is the wording to use when walking an agent through it. The two codes interact in both directions. Logging out with `*45` also clears any pause. Less obviously, an agent who is logged **out** and dials `*46` is logged back in as well as paused, so `*46` is not purely a pause toggle: pressing it makes them a queue member again. ## Renaming a Queue Click the queue name at the top of the detail page to edit it inline. Press **Enter** or click away to save. ## Deleting a Queue Open the queue and use the delete action from the queue list page. Deleting a queue releases its extension number for reuse and removes all members. Existing call routing rules that point at this queue must be updated separately. --- ## Ring Groups Ring groups let you route incoming calls to multiple destinations simultaneously. When a call reaches a ring group, all members ring at the same time until someone answers or the timeout expires. ## Viewing Ring Groups Navigate to **Ring Groups** from the sidebar. The table shows all ring groups for the selected account, including their name, extension number, and member count. ## Creating a Ring Group 1. Click **Add Ring Group** in the top-right corner. 2. Enter a **Name** for the ring group (e.g., "Sales Team" or "Support"). 3. Click **Create**. ## Ring Group Details Click on any ring group row to open its detail page. The detail page has two sections: ### Settings - **Timeout** — How long (in seconds) all members ring before the call is considered unanswered. Must be between 5 and 300 seconds. - **Ignore Device Call Forwarding** — When enabled, a member's phone that redirects the call is treated as busy, and the group keeps ringing its other members. This applies to call forwarding configured on the phone itself, which the phone performs rather than the platform. It does not disable a member's [Find Me / Follow Me](./users.md#find-me--follow-me) or change which targets that produces, though it does still apply to any phone those targets reach. See [How a Member's Own Call Forwarding and Routing Apply](#how-a-members-own-call-forwarding-and-routing-apply). - **Confirm Pick-Up (External)** — When enabled, external destinations must press "1" before being connected to the caller. This prevents calls from being answered by an external voicemail system instead of a real person. It applies to phone number members **and** a member's Find Me / Follow Me external targets. The prompt plays as soon as the external destination answers, repeats every few seconds, and the person has 15 seconds to press "1"; otherwise that leg is dropped. Those 15 seconds are counted from the moment the external destination answers, so a destination that answers near the end of the ring window can hold the group open past the configured **Timeout** — the Timeout Behavior then fires once the unconfirmed leg is dropped. While the external member hears the confirmation prompt, the ring group **keeps ringing all other members**; whoever answers first (an internal member picking up, or the external member pressing 1) wins, and the remaining members are dropped. - **Timeout Behavior** — What happens when no member answers within the timeout. Options are: **None** (the call hangs up), **Ring user** (ring a specific user), **Voicemail** (send to a user's voicemail or a [shared voicemail box](./shared-voicemails.md)), or **Overflow to queue** (place the caller into a [call queue](./queues.md)). - **Timeout Target** — The user, shared voicemail box, or queue to route the caller to when no member answers. This field appears when Timeout Behavior is set to anything other than None. ### Members Members are the destinations that ring when the group receives a call. A member can be either an **extension** (a user, dial plan, voice app, or another ring group) or an **external phone number**. When the group rings an external member, a call that came in on one of your numbers shows **the original caller's number**; a call that started inside the account shows one of yours instead. In the first case, this is part of why **Confirm Pick-Up (External)** is useful: the person answering cannot tell from the caller ID alone that this is a business call. #### Adding a Member 1. Click **Add Member**. 2. Choose the member type: - **Extension** — Search for and select a user, dial plan, voice app, or ring group from your account. - **Phone Number** — Enter an external phone number. The number is validated and formatted as you type. 3. Click **Add**. #### Removing a Member Hover over a member row and click the trash icon to remove it from the ring group. #### How a Member's Own Call Forwarding and Routing Apply Two different things get called "a member's call forwarding," and a ring group treats them differently: - **Forwarding set on the member's phone** is performed by the phone, not the platform. That is what the **Ignore Device Call Forwarding** setting above controls. - **Routing set in the admin portal** is [Find Me / Follow Me](./users.md#find-me--follow-me), described below. Ignore Device Call Forwarding does not disable it or change which targets it produces. The two meet in one place: Ignore Device Call Forwarding applies to every leg the group dials, so it also covers a phone reached through a member's Find Me / Follow Me. When a member is a user, what actually rings depends on that user's Find Me / Follow Me configuration. - **No Find Me / Follow Me configured** — the group rings all of that user's devices, including their softphone. - **Find Me / Follow Me configured** — the group rings the targets in **step 1 only**, and those targets replace the user's devices rather than ringing alongside them. If step 1 lists an external number, that number rings as part of the group. Later steps and the Find Me / Follow Me **If no answer** fallback never run inside a ring group. Once the ring window closes, the group's own **Timeout Behavior** decides what happens next. This is worth checking when a member's routing looks like it was skipped, or when someone reports that a member's call forwarding was ignored. Take the common setup where step 1 rings the user's devices and step 2 rings their cell phone: dialing that user directly ladders through to the cell, but calling the ring group rings only the devices. To have a member's cell ring as part of the group, either move the number into step 1 or add it to the group as a phone number member. ## Renaming a Ring Group Click the ring group name at the top of the detail page to edit it inline. Press **Enter** or click away to save. ## Deleting a Ring Group Ring groups can be deleted from the account detail page under the Ring Groups section. --- ## Schedules Schedules define when your business is open or closed. They are used by dial plans to route calls differently during business hours, after hours, and on holidays. ## Viewing Schedules Navigate to **Schedules** from the sidebar. The table shows all schedules for the selected account, including their name, timezone, and number of time ranges. ## Creating a Schedule 1. Click **Add Schedule** in the top-right corner. 2. Enter a **Name** for the schedule (e.g., "Business Hours"). 3. Optionally select a **Timezone**. If not set, the account's default timezone is used. 4. Click **Add Schedule**. You can add weekly time ranges and holidays after creating the schedule. ## Schedule Details Click on any schedule row to open its detail page. The header shows the schedule name (editable), timezone selector, current local time, and an open/closed badge. ### Override Banner When a manual override is active, a colored banner appears at the top of the page showing whether the schedule is forced open (green) or forced closed (amber). The banner displays a live countdown until the override expires and provides a **Cancel** button. ### Weekly Hours Define when the business is open using the day selector and time picker. - **Day pills**: Click the day buttons (Mon–Sun) to select one or more days. Use the **Weekdays** or **Weekend** quick-select links below. - **Time picker**: When at least one day is selected, set the start and end times and click **Add Range**. - **Visual grid**: A 7-column (Mon–Sun) × 24-row (hours) grid shows which hours are covered. Hover over filled cells to see the exact time ranges. - **Grouped list**: Ranges are grouped by identical times (e.g., "Mon–Fri: 09:00 – 17:00") for easy scanning. - **Empty state**: If no ranges are set, click **Set weekday hours (Mon–Fri 9–5)** to quickly create standard business hours. ### Holidays Define dates when the business is closed regardless of weekly hours. - **Add manually**: Use the date pickers at the bottom to select start and end dates, then click **Add**. - **Import holidays**: Click **Import** to open a dialog with US and Canadian statutory holidays for the current and next year. Check the holidays you want and click **Import Selected**. - **Proximity badges**: Upcoming holidays show "Today", "Tomorrow", or "In X days" badges. Past holidays appear dimmed at the bottom. ### Manual Override Temporarily override the schedule to force it open or closed. 1. Click **Force Open** (green) or **Force Closed** (red). 2. Choose a duration preset: **1 hour**, **End of day**, **Tomorrow 9 AM**, or **Custom...** for a specific date and time. 3. When active, the override banner at the top shows the status and countdown. Click **Cancel** on the banner to remove the override. ## Using Schedules in Dial Plans Schedules are referenced by dial plan **schedule nodes**. A schedule node checks whether the schedule is currently open, closed, or on holiday, and routes the call to a different path for each case. See the dial plans documentation for details. --- ## Shared Voicemail Boxes Shared voicemail boxes are voicemail boxes that are not tied to any individual user. They provide a single destination where callers can leave messages for an entire department, team, or function. ## When to Use a Shared Voicemail Box Shared voicemail boxes are useful when messages should be accessible to a group rather than a single person: - **Departments** — A "Sales" or "Support" voicemail box that the whole team monitors. - **After-hours** — A general mailbox for calls that arrive outside business hours. - **Overflow** — A fallback destination when a ring group times out and no one is available. ## Viewing Shared Voicemail Boxes Navigate to **Shared Voicemails** from the sidebar. The table shows all shared voicemail boxes for the selected account, including their name, extension number, and email address. From the list, you can: - Click a row to open the detail page. - Use the **eraser** icon to clear all voicemail messages in a box. - Use the **trash** icon to delete a box entirely. ## Creating a Shared Voicemail Box 1. Click **Add Shared Voicemail** in the top-right corner. 2. Enter a **Name** for the voicemail box (e.g., "Sales Voicemail" or "After-Hours"). 3. _(Optional)_ Set an **Extension Number** to make the box directly dialable. 4. _(Optional)_ Set a **PIN** (4–10 digits) for phone access. If you leave it blank, the first team member to dial in from an account device sets it — see [Voicemail Device Access](./voicemail-device-access.md). 5. Click **Create**. ## Shared Voicemail Box Settings Click on any shared voicemail box row to open its detail page. All settings are saved automatically when you change them. - **Extension** _(optional)_ — An extension number to make the shared voicemail box directly dialable from within your phone system. - **PIN** _(optional)_ — A numeric PIN (4–10 digits) for remote voicemail access. When empty, the first team member to dial in from an account device sets it on first access. See [Voicemail Device Access](./voicemail-device-access.md). - **Unavailable Greeting** _(optional)_ — A custom audio message callers hear when they reach this voicemail box. See [Voicemail Greetings](./voicemail-greetings.md). When no greeting is set, a default system announcement is played. - **Email** _(optional)_ — An email address that receives new voicemail notifications. When set, the toggles below control what is included in each notification. - **Include AI Summary** — Include an AI-generated summary of the voicemail message. Enabled by default. - **Include Full Transcript** — Include the full text transcript of the voicemail message. Enabled by default. - **Attach Voicemail Audio** — Attach the voicemail recording as an MP3 file. Enabled by default. - **Delete After Email** — When enabled, voicemail messages are automatically deleted after the email notification is sent. This option is only available when **Attach Voicemail Audio** is enabled. Useful when the team manages voicemails entirely through email and does not need to keep them in the system. :::note **Storage limit:** Each shared voicemail box can store up to 99 messages. When the limit is reached, the oldest message is automatically deleted to make room for new ones. To retain all messages long-term, enable **Delete After Email** with an email address so messages are delivered before being removed. ::: ## Using with Ring Groups Shared voicemail boxes can be configured as the timeout destination for a ring group. When no ring group member answers within the timeout period, the call is sent to the shared voicemail box. To set this up, open the ring group's detail page and set **Timeout Behavior** to **Voicemail**, then select the target shared voicemail box from the picker. See [Ring Groups](./ring-groups.md) for more details. ## Using on Devices You can put a shared voicemail box on a phone's programmable keys so a team can see and reach it without dialing an extension. Add a **Shared voicemail** button to a [button template](./button-templates.md), pick which box the key watches, then bind the template to the deskphones that should have it. This key is a deskphone feature — it does not render on DECT handsets. Once deployed, pressing the key dials the shared voicemail box so a team member can listen to its messages. To move an active call into the box, press the key to place a consultation call and then complete a transfer — there is no one-press deposit. On phone models that support a message-waiting indicator for the key, it also lights up when the box has new messages. Which of these a phone can do varies by model — see [Button Templates](./button-templates.md#adding-buttons) for the button types and [Devices](./devices.md#programmable-buttons) for how a template is bound to a phone and how to check which buttons a given model supports. ## Renaming a Shared Voicemail Box Click the shared voicemail box name at the top of the detail page to edit it inline. Press **Enter** or click away to save. ## Clearing Voicemail Messages To delete all voicemail messages in a box without deleting the box itself, click the **eraser** icon in the list table and confirm. This permanently removes all messages and cannot be undone. ## Deleting a Shared Voicemail Box To delete a shared voicemail box and all its messages, click the **trash** icon in the list table and confirm. This action cannot be undone. --- ## Managing Users Users represent the people in your organization who make and receive calls. ## Roles A user on the account can have two independent roles, shown as badges in the **Roles** column: - **Voice** — a calling seat: they can place and receive calls on assigned devices and have their own voicemail and extension. A voice seat is what counts toward billing. - **Admin** — management access: they can manage users, devices, phone numbers, dial plans, ring groups, voice apps, schedules, and other account settings. - **Owner** — an Admin who is also the account's primary contact and signs the subscription agreement. Every account has exactly one Owner (shown with both the **Admin** and **Owner** badges). Ownership can't simply be removed — it must be transferred to another Admin first (see below). These roles are independent, so a user may be any combination: - **Voice only** — a regular caller with no management access. - **Voice + Admin** — a caller who also administers the account. - **Admin only** — an administrator or Owner with **no** calling seat. They have no **Voice** badge and are **not billed**. The account Owner is often admin-only. The two facets are independent and can be added or removed in any order — you can give an admin-only member a voice seat later, or remove someone's voice seat while keeping their Admin access. Manage them from the member's detail page, reached by clicking any row — see below. ### Transferring ownership Because an account must always have exactly one Owner, you can't remove or delete the Owner directly. Instead, transfer ownership to another Admin: 1. Make sure the user you want as the new Owner is already an **Admin** on the account (promote them first if needed). 2. Open that Admin's detail page and choose **Transfer ownership**, then confirm. The previous Owner becomes a regular Admin. The account's own contact details — its contact name and contact email — are left as-is: they describe the account, not whoever currently owns it, and you edit them independently under Account settings. Only after ownership is transferred can the previous Owner be removed. **Only the current Owner can use this action.** The Owner is the person who accepted the subscription agreement for the account, so ownership can only be handed over by them. Everyone else — other Admins on the account, and platform staff viewing it — still sees **Transfer ownership**, but it is greyed out, and hovering it explains why. If the current Owner is no longer reachable, contact support: an ownership change in that situation has to be arranged with us rather than carried out in the portal. ## Viewing members Navigate to **Users** from the sidebar. The table lists everyone on the selected account — voice users **and** admin-only administrators/Owners — with their name, email, extension, and **Roles** badges. Admin-only users (no **Voice** badge) are not billed. Use the search bar to filter by name or email. Click any row — voice or admin-only — to open that member's detail page. Its **Access** control is a single choice: - **Voice** — a calling seat, no admin access. - **Voice and admin** — a calling seat plus portal access. - **Admin only** — portal access, no calling seat (not billed). Switching between them adds or removes the voice seat and the Admin role as needed; a change that releases a seat (or that assigns one) asks you to confirm first. The Owner is shown parked on their matching option and can't be changed here — ownership moves through **Transfer ownership**. To take a member off the account entirely, use **Remove from account**. ## Creating a User 1. Click the **New User** button in the top-right corner. 2. Fill in the **Name**, and an **Email** address if the member will have admin access (the field is marked Required once you pick an admin option — it's their portal sign-in). 3. Choose the member's **Access**: **Voice**, **Voice and admin**, or **Admin only**. The extension and voicemail PIN appear under the option that grants a voice seat. (To create an Owner, add an admin and then transfer ownership — see [Transferring ownership](#transferring-ownership).) 4. Click **Create User**. As you pick the access, the dialog states what the new member will do to the account's bill: the monthly total, and the per-seat rate agreed for this account. Taxes and fees are additional. A seat added part-way through a month is charged only for the days it exists, so the first month is normally less than the figure shown. Choosing **Admin only** shows no charge, because an admin-only member takes no seat. The same note appears when you grant an existing admin-only member a voice seat from their detail page. If no per-seat rate has been agreed for the account, the note still says the seat is billable but shows no price. Sandbox and demo accounts are never billed, and say so instead of quoting a price. The member is created with only the access you chose and appears in the users table. ## Editing a User 1. Click on a user row in the table to open their detail page. 2. Edit the **Name**, **Email**, or **Extension** field directly. 3. Click the checkmark to save, or press **Enter**. Press **Escape** to cancel. ## Outbound Caller ID Each user can have a custom outbound caller ID that overrides the account default. When a user makes an outbound call, the system uses their override number instead of the account's default. To set a user's outbound caller ID: 1. Open the user's detail page. 2. In the **Outbound Caller ID** dropdown, select a phone number. 3. The change is saved automatically. Select **Account Default** to remove the override and use the account-level default instead. ## Voicemail The **Voicemail** card on the user detail page lets you configure voicemail settings. ### PIN A PIN is required to check voicemail by phone. Set a numeric PIN (4–10 digits) here, or leave it blank to let the user set their own. When the PIN is blank, the first time the user reaches their mailbox **from a device registered to them** they're prompted to create a PIN before they can listen — they must set one, or the call ends. The PIN they choose is written back here, so this field always reflects the current PIN. Setting an initial PIN yourself may be helpful when the user doesn't have a registered device yet. See [Voicemail Device Access](./voicemail-device-access.md) for the full first-time-access flow (including what a caller hears from a device that isn't registered to the mailbox owner). ### Email Notifications Users can receive email notifications when they get a new voicemail. To configure: 1. In the **Voicemail** card, toggle **Enable notifications**. 2. Optionally adjust the following settings: - **Include AI Summary** — adds a brief AI-generated summary to the email. - **Include Transcript** — includes the full voicemail transcript. - **Attach Audio** — attaches the voicemail audio file (MP3). - **Delete After Email** — automatically deletes the voicemail after the email is sent (requires Attach Audio to be enabled, since the audio file is removed from the system). :::info The user must have an email address configured. Notification emails are sent to the user's email address. ::: When **Include AI Summary** or **Include Transcript** is enabled, the email is sent after transcription completes rather than immediately. ## Find Me / Follow Me The **Find Me / Follow Me** card on the user detail page lets you configure how inbound calls to this user are routed. Each **step** rings all its targets simultaneously for a configurable timeout. Steps are tried in order — if no one answers in a step, the next step begins. ### Target types Each step can include one or more targets: - **Internal extension** — rings a user's devices (can be the current user or another user). - **External number** — dials a phone number. ### Confirm Pick-Up (External) When enabled, external number targets must press "1" before being connected to the caller. This prevents a step from being answered by a cell phone's voicemail instead of a real person — an answered leg ends the sequence, so without confirmation a target whose phone goes straight to voicemail stops the later steps from ever ringing. The prompt plays as soon as the external number answers, repeats every few seconds, and the person has 15 seconds to press "1"; otherwise that leg is dropped and the sequence continues with the next step. Those 15 seconds are counted from the moment the external number answers, not from the start of the step, so a cell phone that answers near the end of its step timeout can hold the sequence open a little past that timeout. The setting applies to the whole sequence — every external number target in every step. Internal extension targets are never prompted. It is off by default. :::note This setting is separate from a ring group's own **Confirm Pick-Up (External)**. If a user is a ring group member and is reached through their Find Me / Follow Me, enabling it here gates that user's external targets even when the ring group's own setting is off. The same holds anywhere else the user's Find Me / Follow Me is what reaches them — a call that rings everyone in the account, or a call queue they are an agent of. ::: ### Fallback After all steps are exhausted without an answer, the configured fallback action runs: - **Go to voicemail** — sends the caller to this user's voicemail box. - **Hang up** — disconnects the call. - **Route to...** — routes to a specific target (user, ring group, voice app, dial plan, or shared voicemail). ### Example A typical setup: 1. **Step 1** (20s): Ring user's devices. 2. **Step 2** (30s): Ring the user's cell phone. 3. **Fallback**: Go to voicemail. If any step rings a cell phone, enable **Confirm Pick-Up (External)** — otherwise that phone's voicemail can answer step 2 and the fallback never runs. ## User Devices The user detail page shows deskphones and DECT handsets assigned to the user, including each device's name and hardware ID. Use **Assign Device** to assign an existing deskphone or handset — the picker shows device names and can be searched by name — or to register a new deskphone by MAC address and assign it immediately. The **Devices** page also shows who each device is assigned to. The admin portal assigns one user at a time — assigning a device to a new user reassigns it from the previous one. Devices assigned to multiple users (created via the API) are shown as shared. ## Deleting a User 1. Open the user's detail page. 2. Click **Delete user**. 3. Confirm the deletion in the dialog. :::warning Deleting a user is permanent and cannot be undone. ::: --- ## Voice Apps Voice apps are automated call-handling applications that can answer calls, interact with callers, and perform actions on your behalf. Currently, supports **AI Agent** voice apps — intelligent agents that use natural language to converse with callers. ## Viewing Voice Apps Navigate to **Voice Apps** from the sidebar. The table shows all voice apps for the selected account, including their name, type, and extension number. ## Creating an AI Agent 1. Click **Add Voice App** in the top-right corner. 2. Enter a **Name** for the agent (e.g., "Front Desk" or "Appointment Bot"). 3. Review the **Extension Number** so the agent can receive calls. It is prefilled with the next available number; change it if you'd like a different one. 4. Click **Create**. ## AI Agent Configuration Click on any voice app row to open its detail page. The following settings are available: ### General - **Persona Name** — An optional display name the agent uses when introducing itself to callers. - **Greeting Name** — An optional name used in the agent's initial greeting (e.g., "Hi, this is \[greeting name\], how can I help you?"). - **Instructions** — Free-text instructions that guide the agent's behavior and tone during calls. Use this to define the agent's personality, knowledge boundaries, and conversation style. ### FAQ The FAQ section lets you define question-and-answer pairs the agent can reference during calls. Each entry has a **Question** and an **Answer** field. The agent uses these to provide consistent, accurate responses to common inquiries. - Click **Add FAQ** to add a new pair. - Click the trash icon on any row to remove it. ### Scheduling The scheduling section connects your AI agent to an external scheduling system so it can look up customers, check availability, and book appointments on behalf of callers. #### Webhook URL Provide a webhook URL to enable scheduling. Clear the URL to disable it. The URL must use HTTPS. This is the base URL of your scheduling system's webhook endpoint. automatically appends the following paths for each operation: - `/customers/lookup` — identifies the caller by phone number - `/availability/search` — fetches available time slots - `/bookings` — confirms and creates an appointment When a caller asks to schedule, the agent uses these endpoints in sequence: it looks up the customer, retrieves available slots, presents options, and books the selected time. ### Webhook Secret Tool-call webhooks the agent sends to your application — including the scheduling endpoints above — are signed with a webhook secret so your application can verify the request came from . The secret is shown on the AI agent's edit page under **Webhook secret** once the agent has been created. - Click the **Copy** icon to copy the secret to your clipboard, then paste it into your application's settings. - Click the **Rotate** icon to generate a new secret. Rotating immediately invalidates the previous value, so any application still using the old secret will reject incoming tool calls until you update it. The same secret is also visible from the underlying voice app's edit page and rotates in lockstep — they refer to the same value. ## Renaming a Voice App Click the voice app name at the top of the detail page to edit it inline. Press **Enter** or click away to save. ## Deleting a Voice App Voice apps can be deleted from the account detail page under the Voice Apps section. --- ## Voicemail Device Access Users and shared voicemail boxes can be accessed from any device on the account by dialing a short code. This page covers what to dial, how first-time access works, and the keys available while listening to messages. ## Star Codes | Code | What it does | | ---------------- | ------------------------------------------------------------------------------- | | `*99` | Access **your own** voicemail from your registered device | | `*98` | Access **any mailbox by extension** — a user's personal mailbox or a shared box | | `*98` | Same as above, but the system asks which mailbox you want | `*99` is the quickest path when you're on your own device. `*98` is the right choice when: - You're on a shared or borrowed device and need to check your own voicemail (dial `*98` followed by your extension). - You're accessing a shared voicemail box (dial `*98` followed by the shared box's extension). If you dial `*98` on its own, the system answers and asks you to enter the mailbox extension followed by `#` — useful when you'd rather be prompted than dial the whole code at once. Press `*` to clear what you've entered and start over. If the extension has no mailbox, you're told so and asked again; after three tries the call ends. ## First-Time Access (Setting a PIN) If the mailbox does not yet have a PIN, the system prompts you to set one — but only when it trusts the device you're calling from: - **Personal mailboxes** — only a device registered to the owner can set the PIN. - **Shared voicemail boxes** — any device registered to the same account can set the PIN. If the mailbox has no PIN and you're not calling from a trusted device, you'll hear "_this mailbox is not configured for phone access, please contact your administrator._" Ask your admin to set an initial PIN in the admin portal; you can then enter it from any device. **To set a PIN on first access:** 1. Dial `*99` (or `*98` for a shared box). 2. The system says the mailbox has no PIN. Press `1` to set one. 3. Enter the new PIN (4–10 digits), then `#`. 4. Re-enter to confirm, then `#`. The PIN is saved immediately and required on every subsequent call. ## Normal Access After a PIN is set, every call to the mailbox prompts for it: 1. Dial `*99`, `*98`, or `*98` and enter the extension when prompted. 2. Enter your PIN, then `#`. 3. The system announces how many new and old messages are waiting, then begins playback. You have up to three PIN attempts per call. After three wrong entries the call disconnects. ## After Your PIN Once your PIN is accepted, you hear a short summary and a menu hint, for example: > "You have 2 new messages and 1 old message. Press 1 for the new messages, press 0 for mailbox options." The menu hint always offers two paths: | Key | What it does | | --- | ----------------------------------------------------------- | | `1` | Skip the summary and start listening to messages right away | | `0` | Open the mailbox options menu (greeting, change PIN) | | `#` | Exit the mailbox at any time | You can press any of those keys while the announcement is still playing — the system stops the announcement and acts on your key immediately. ## New vs Old Messages A message is **new** until you have listened to it from start to finish (or pressed `9` to save it). After that, it becomes **old** and stays in your mailbox until you delete it. The post-PIN announcement counts the two groups separately so you can tell at a glance whether anything has come in since the last time you checked. Both groups play back together, newest first; the next message starts about three seconds after the previous one ends. ## Listening to Messages Each message plays in the following order: 1. Envelope: message number, caller's number (when known), and when it was received. 2. The message audio. 3. A brief pause before the next message (about three seconds) so you have time to press a key. If the caller's number was not passed by the carrier or was withheld (anonymous), the envelope skips the "from" segment and goes straight from the message number to the timestamp. ## Keys While Listening | Key | Action | | ---------- | --------------------------------------------- | | `1` or `5` | Replay the current message | | `4` | Previous message | | `6` | Next message | | `7` | Delete the current message | | `9` | Save (mark read and keep) | | `*` | Skip the envelope or cancel the current audio | | `0` | Mailbox options menu | | `#` | Exit the mailbox | Deletes take effect immediately and cannot be undone. The system plays a short "deleted" confirmation tone so a mis-pressed key is noticeable. ## Mailbox Options Menu (Key `0`) From the listening loop, pressing `0` opens the options menu. The menu does not announce its keys — they are listed here: | Key | Action | | --- | ---------------------------------- | | `1` | Record your "unavailable" greeting | | `5` | Change your PIN | | `#` | Exit the mailbox | Any other key plays a short "sorry, that option is not available" prompt and returns to the menu. If you stay silent for a few seconds twice in a row, the system says goodbye and hangs up. ### Recording Your Greeting From Your Device 1. From the options menu, press `1`. 2. Wait for the "record after the beep" prompt and the beep. 3. Speak your greeting (up to 90 seconds), then press `#` to stop. 4. Review the recording. Press `1` to save, `2` to re-record, or `*` to cancel. Saving from your device replaces whatever greeting was previously in place — the same slot the admin portal updates. See **Voicemail Greetings** for the portal-side controls. ## What Callers Hear When someone leaves you a message, here is the experience from their side: 1. The system plays the mailbox's greeting — your custom recording if you have one set, or a default system announcement otherwise. 2. A beep signals that recording has started. 3. The caller speaks their message. 4. The caller presses `#` to finish, or simply hangs up. After the message is recorded it is uploaded, transcribed, and delivered into your mailbox the next time you dial `*99` or `*98`. Greetings are managed on the mailbox detail page (see **Voicemail Greetings**). ## Changing Your PIN 1. While in your mailbox, press `0` to open the options menu. 2. Press `5` for "change password". 3. Enter the new PIN (4–10 digits) and `#`. 4. Re-enter to confirm. You must already be authenticated into the mailbox (via the PIN prompt at the start of the call) to change it; there is no separate current-PIN prompt. ## For Administrators - You can pre-set a user's or shared box's PIN from the admin portal when creating or editing the mailbox. This is useful for users who don't yet have a registered device of their own. - When you leave the PIN field empty, the first trusted caller sets it from their device — see **First-Time Access** above. - For shared voicemail boxes, trust is account-scoped: whoever on the team dials first and sets a PIN becomes the gatekeeper. Share that PIN with the team out-of-band. --- ## Voicemail Greetings A voicemail greeting is the audio message callers hear when they reach a voicemail box. If no custom greeting is set, plays a default system announcement. Customizing the greeting lets you welcome callers in your own voice, set expectations about response time, or provide alternate contact options. ## Where Greetings Live Greetings are managed on the detail page of the mailbox they belong to: - **User voicemail** — on a user's detail page, in the **Voicemail** card. - **Shared voicemail boxes** — on the box's detail page, in the **Settings** card. Each mailbox has a single greeting slot (the "unavailable" greeting). Uploading a new greeting replaces the previous one. ## Recording a Greeting 1. Open the user or shared voicemail box detail page. 2. Locate the **Unavailable Greeting** control. 3. Click **Record**, then **Start recording**. 4. Speak your message. The timer shows elapsed time — keep it under 90 seconds. 5. Click **Stop** when done. 6. Review the recording with the inline player. Click **Use recording** to save, or **Discard** to try again. Recording requires microphone access in your browser. Your browser will prompt for permission the first time. ## Uploading a Greeting 1. Open the user or shared voicemail box detail page. 2. Locate the **Unavailable Greeting** control. 3. Click **Upload**, then drop an audio file onto the drop zone or click to browse. 4. The file uploads and becomes the active greeting immediately. **Accepted formats:** WAV, MP3, M4A, OGG, or WebM. **Limits:** 5 MB maximum file size, 90 seconds maximum duration. Files are converted to telephony-quality audio server-side — the exact format you upload doesn't affect the final playback quality. ## Replacing or Removing a Greeting - **Replace:** upload or record a new greeting. It takes effect immediately. - **Remove:** click **Remove** next to the active greeting. The mailbox falls back to the system default announcement on the next call. ## What Callers Hear When a call lands in the voicemail box: 1. The greeting plays. 2. A beep indicates recording has started. 3. The caller leaves their message. If the greeting file is missing or cannot be played for any reason, automatically falls back to the default system announcement so callers always hear something and can leave a message. ## Tips - Keep greetings short and clear. 15–30 seconds is usually enough. - State the name of the person or team being reached so the caller knows they dialed the right number. - Mention any alternate contacts or expected response time if relevant. - Record in a quiet room. Background noise is amplified over the phone network. --- ## Admin Guide Welcome to the Admin Guide. This guide covers everything you need to know about managing your voice platform. ## Getting Started The admin portal lets you manage users, devices, phone numbers, and locations for your organization. What you can access depends on your role: - **Account Administrators** can manage users, devices, phone numbers, and locations within their assigned accounts. - **Platform Administrators** have all account admin capabilities plus platform-wide settings, branding, and multi-account management. Select a topic from the sidebar to get started. ## Call routing Incoming calls are routed through a combination of: - **[Dial Plans](./account-admin/dial-plans.md)** — visual flows that branch on schedules, dial users or groups, and fall back to voicemail or voice apps. - **[Ring Groups](./account-admin/ring-groups.md)** — ring multiple destinations (users, dial plans, voice apps, or external numbers) at once. - **[Schedules](./account-admin/schedules.md)** — business hours and holidays, referenced by Schedule nodes in dial plans. - **[Voice Apps](./account-admin/voice-apps.md)** — AI agents and custom voice applications used as dial-plan or ring-group targets. --- ## Basic Troubleshooting First-line fixes for the most common phone-system and VoiceAI issues: call quality, audio problems, dropped or failed calls, devices that won't register, and AI agent quirks. Most issues in this guide can be resolved without contacting support. This guide is written for support reps and admins who manage a DialStack-powered phone system. It is also safe to share directly with end customers; the language and steps are designed for both. If you're configuring the system rather than fixing it, you probably want the [Admin Guide](/admin-guide/) instead. ## How to use this guide Most calls into support fall into one of four patterns: 1. **The audio is bad.** Choppy, robotic, garbled, echoey, or one direction is silent. 2. **The call didn't work.** It dropped, never connected, never rang the right person, or never rang at all. 3. **A device or app is broken.** A desk phone won't boot or register, the softphone won't sign in, a handset shows the wrong user. 4. **The AI agent isn't behaving.** It won't answer, hangs up early, transcribes wrong, or doesn't transfer correctly. The fastest path is almost always the same: 1. Run the **quick triage** below to figure out the shape of the problem. 2. Open the **Call Quality** dashboard in the admin portal to see whether the numbers confirm what the customer is reporting. 3. Jump to the matching **symptom** section. 4. If you can't resolve it yourself, use the [information to collect before escalating](#information-to-collect-before-escalating) checklist so the team has what they need on the first reply. ## Quick triage (start here) Before doing anything else, answer these four questions. Your answers point you at the right section. **1. Is the platform up?** Check the status page at [status.dialstack.ai](https://status.dialstack.ai/). If many customers across multiple locations are reporting the same problem at the same time, it may be platform- or carrier-side, so check status first before going deeper. If it's clearly localized to one customer, keep going. **2. How many people are affected?** - _One person at one location:_ most likely a device, headset, network, or user-config issue. Start with [Audio and call-quality symptoms](#audio-and-call-quality-symptoms) or [Device and app symptoms](#device-and-app-symptoms). - _Multiple people at one location:_ most likely the customer's local network or internet. Start with [Customer network diagnostics](#customer-network-diagnostics). - _Multiple people across multiple locations:_ could be a regional carrier or platform issue. Re-check the status page, then collect data and escalate. **3. Did anything change recently?** A new router, an ISP change, a new firewall, a Wi-Fi reconfiguration, a phone moved to a new location, an admin update to a dial plan. Most "it just started happening" problems trace back to a change in the last 24 to 72 hours. **4. Is it one direction or both?** - _Both sides of every call sound bad:_ network or platform. - _Only one direction can hear:_ nearly always a NAT, firewall, or routing issue. See [One-way audio](#one-way-audio). - _Only inbound or only outbound calls fail:_ a routing or dial-plan issue, not network. Write these four answers down (or note them when you escalate). They speed up every later step. ## Open the Call Quality dashboard Before deep troubleshooting, open the [Call Quality dashboard](/admin-guide/account-admin/quality) in the admin portal. It gives you a numbers-based picture of what's actually happening. **What to look for:** - **Avg. MOS.** A single quality score for the selected window. Above 4.0 is good, 3.5 to 4.0 is fair, below 3.5 means real audio problems. - **Avg. Jitter** and **Avg. Packet Loss.** If either is elevated, the customer's network is the most likely cause. - **MOS Over Time chart.** Is quality steady or did it dip at a specific time? A sharp drop usually corresponds to a network event, an ISP issue, or a change at the customer's site. - **Lowest Quality Calls table.** Pull up the worst calls and click into them. The Call Detail page shows per-leg quality metrics so you can see whether the bad leg was the PSTN side (carrier or platform) or the endpoint side (customer network or device). **How to read the numbers:** | MOS | What it means | | :-------: | :-------------------------------------------------------- | | 4.3 – 5.0 | Excellent. Indistinguishable from in-person conversation. | | 4.0 – 4.3 | Good. Minor issues, callers unlikely to care. | | 3.5 – 4.0 | Fair. Occasional glitches noticeable. | | 3.0 – 3.5 | Poor. Callers likely to complain. | | Below 3.0 | Unacceptable. Conversation is difficult. | **Where the impairment is:** - **High jitter (above 30 ms)** usually points to network instability, often Wi-Fi or a congested internet connection. - **Packet loss above 1%** is enough to degrade calls noticeably. Above 3% and calls become unusable. - **High round-trip time (above 200 ms)** causes the conversational delay where people talk over each other. If MOS is consistently above 4.0 but the customer is still reporting bad calls, the problem is probably at the device, headset, or speakerphone level rather than the network. Skip to [Device and app symptoms](#device-and-app-symptoms). ## Network requirements at a glance What a healthy voice network looks like (bandwidth, the ports and destinations to allow, SIP ALG, NAT keepalives, QoS, Wi-Fi) is documented in full on the [Network Requirements guide](./network-requirements.md). That page is the source of truth and is safe to share with a customer's IT contact, so this guide points to it rather than restating it. The one thing worth keeping in front of you while you troubleshoot is the set of quality thresholds voice is sensitive to. These are the same numbers the Call Quality dashboard reports: | Metric | Acceptable | Problematic | | :----------------- | :----------: | :----------: | | Jitter | Below 30 ms | Above 30 ms | | Packet loss | Below 1% | Above 1% | | Round-trip latency | Below 150 ms | Above 200 ms | If a customer sits consistently in the "Problematic" column, voice quality will suffer on any platform, and the fix is on their network. Everything else (ports, firewall settings, NAT timeouts, QoS tagging) lives in the Network Requirements guide; the symptom sections below link to the relevant part of it when it matters. ## Audio and call-quality symptoms ### Choppy, robotic, or garbled audio **What it sounds like:** Words are cutting in and out, voices sound distorted or underwater, audio breaks up. **Most likely causes, in order:** 1. **Packet loss.** Audio packets aren't arriving. Check the Call Quality dashboard for packet loss above 1%. 2. **Jitter.** Packets arrive in bursts instead of evenly. Check for jitter above 30 ms. 3. **Wi-Fi.** Wi-Fi is the single biggest source of call-quality complaints. Wired Ethernet is always more reliable than wireless. 4. **Network congestion.** Another device on the same network is saturating the connection (large upload, video call, backup). **What to try:** 1. Have the user switch from Wi-Fi to Ethernet for one test call, even if they have to plug into a router temporarily. If the call is clean on Ethernet, the problem is Wi-Fi. 2. On Wi-Fi, move closer to the access point and make sure the device is on 5 GHz, not 2.4 GHz. 3. Run a [VoIP-grade network test](#customer-network-diagnostics) from the affected location. 4. Pause any large uploads, cloud backups, or video calls and try again. 5. If the customer has QoS available on their router, enable it for voice traffic. **When to escalate:** Network tests pass and MOS is still consistently below 3.5 across multiple users at the location. ### One-way audio **What it sounds like:** Caller A can hear caller B, but caller B can't hear caller A (or vice versa). **Most likely causes, in order:** 1. **Symmetric NAT or restrictive firewall** on the customer's network. Outbound audio packets get out, return packets get blocked. 2. **SIP ALG** enabled on the customer's router. SIP ALG modifies SIP traffic in ways that often break the return media path. 3. **Headset or mic-mute issue** on the device. Easy to dismiss, but worth confirming first. **What to try:** 1. Confirm the user's mic isn't muted, both physically and in software. Try a different headset. 2. On the customer's router, disable **SIP ALG** if it's enabled. The setting name varies by manufacturer; common labels: "SIP ALG", "SIP Helper", "SIP Transformation", "VoIP passthrough". 3. Confirm the customer's firewall allows UDP outbound and that there's no double NAT (router behind another router) on their network. 4. For WebRTC clients, the SDK uses TURN relay automatically to handle symmetric NAT, so this is rare. For SIP desk phones, NAT and SIP ALG are the usual suspects. **When to escalate:** SIP ALG is off, firewall is permissive, mic and headset are confirmed good, and one-way audio persists. ### Echo or feedback **What it sounds like:** The user hears their own voice come back at them, sometimes with a delay. **Most likely causes:** 1. **Speakerphone too close to the mic.** Acoustic feedback. Use a headset. 2. **Two devices on the same call.** Sometimes a user accidentally answers on both their desk phone and softphone. 3. **Bluetooth headset glitch.** Reconnect or pair fresh. **What to try:** 1. Use a wired headset. 2. Make sure only one device is in the call. 3. Lower speakerphone volume. Echo is almost always a device or environment issue, not a network or platform issue. ### Dropped calls mid-conversation **What it sounds like:** A call disconnects after some duration, sometimes consistently around the same time (30 seconds, 30 minutes, an hour). **Most likely causes:** 1. **ICE/media timeout** for WebRTC clients. The media path failed to establish properly and the connection drops around the 30-second mark. 2. **NAT translation timeout** for SIP desk phones. The customer's router drops the NAT mapping after a period of inactivity. 3. **Network failover** at the ISP level. The customer's internet briefly switched paths and the call couldn't recover. 4. **Wi-Fi roaming.** The user moved between access points mid-call. **What to try:** 1. Check whether the drops are consistent in timing. A drop at exactly 30 seconds for WebRTC users suggests a firewall is blocking UDP and the TURN-over-TCP fallback is blocked too, so no media path can be established. Verify the firewall config against [Ports and destinations](./network-requirements.md#ports-and-destinations) in the Network Requirements guide. 2. For desk phones, confirm the phone's SIP registration is healthy (most phones show a registration status on their display). If it re-registers frequently, the NAT timeout is too short. The DialStack-provided phones handle keep-alives correctly by default; if the customer brought their own phone, the keep-alive interval may need adjusting. 3. If drops correlate with the user moving around, suspect Wi-Fi roaming. **When to escalate:** Drops are consistent across multiple users and devices, and network tests pass. ### Calls fail to connect (fast busy, dead air, ring with no pickup) **What it sounds like:** The customer dials and hears a fast busy tone, nothing at all (dead air), or normal ringback but the call never connects to a person. **Most likely causes:** 1. **Dial plan misconfiguration.** The call is routing somewhere it shouldn't. Open the [Dial Plans](/admin-guide/account-admin/dial-plans) view in the admin portal and trace the route. 2. **Number was ported away or not yet ported in.** Check the number's status in the admin portal. 3. **Carrier-side block.** Less common, but the destination number is on a block list. 4. **Outbound calling permissions.** The user's role doesn't allow the type of call (international, premium rate). **What to try:** 1. Have the customer try a different destination number. If it works, the problem is the original number. 2. Trace the call in the Call List (find the call by time, click into Call Detail). The status field tells you exactly where it ended. 3. Check the dial plan that handles the route. ### Inbound calls don't ring **What it sounds like:** Customer says people calling them get voicemail directly, or it just rings forever, or doesn't ring at all. **Most likely causes:** 1. **Dial plan or routing issue.** The number is routing to voicemail, an inactive group, or a Voice App that's misconfigured. 2. **Do Not Disturb** is on for the user or the group. One deliberate exception: for a short period after a user dials an emergency number, calls ring them even with Do Not Disturb on, so a 911 call-taker calling back reaches them instead of their voicemail. 3. **Phones aren't registered.** No active device to receive the call. 4. **Number not assigned** to a user, group, or dial plan. 5. **NAT mapping pruned by the customer's router.** The phone reports itself as registered, but the router silently dropped the NAT entry between keepalives, so the inbound call has nowhere to land. Common on enterprise firewalls and UTM appliances with aggressive UDP connection-tracking timeouts. 6. **A cell phone's voicemail answered an earlier step.** If a Find Me / Follow Me sequence, a ring group, or a call queue reaches someone on an external number, that phone's carrier voicemail answering counts as an answer — so the call connects to that voicemail and the remaining steps or members never ring. The customer reports it as "the later steps don't work". The fix is **Confirm Pick-Up (External)**, which requires the external number to press "1" before being connected. It is a per-user setting on the Find Me / Follow Me card (it also covers a queue agent reached on their cell) and a separate per-group setting on a ring group. A dial plan's **External Number** node has no such option yet. **What to try:** 1. Confirm the number is assigned and routed correctly in the admin portal. 2. Check that the receiving user or group has at least one registered device. The Call Logs will show whether the call attempted to ring a device. 3. Test by calling the number from an outside line. 4. If the phone shows registered but inbound calls still don't ring, suspect NAT pruning. Reboot the phone (which forces a fresh registration and a new NAT mapping) and immediately try the call again. If the call works right after a reboot but fails later, the customer's router is pruning the NAT entry more aggressively than the keepalive interval. See [UDP connection tracking](./network-requirements.md#leave-udp-connection-tracking-alone) in the Network Requirements guide for the fix. ### Voicemail not delivering **What it sounds like:** Customer says voicemails come in but the notification, transcription, or email forward isn't arriving. **Most likely causes:** 1. **Email address on the user is wrong** or notifications aren't enabled. 2. **Email is being filtered as spam** by the customer's email provider. 3. **Notification settings turned off** at the user level. **What to try:** 1. Verify the email address on the user's profile. 2. Check the recipient's spam folder. 3. Send a test voicemail and confirm it appears in the user's voicemail box in the admin portal (which confirms the voicemail itself worked) before assuming the notification chain is broken. ### Caller ID wrong or showing "unknown" **What it sounds like:** Outbound calls show a wrong number, or inbound calls show "Unknown" instead of a name. **Most likely causes:** 1. **The call was forwarded, and the caller ID is correct.** A call that arrived on one of the customer's numbers and was sent back out to an external number deliberately shows **the original caller**, not the customer's own number. This covers a dial plan's External Number node, Find Me / Follow Me, a ring group's external member, and a queue agent on an external number. The outbound caller ID setting is not consulted on these paths, so changing it has no effect. A transfer a user performs themselves is the exception, from any device: it shows the transferring user's number. 2. **Outbound caller ID** is set to the wrong number on the user, or on the account default the user falls back to. A location's primary number is not used for outbound caller ID, so changing it will not help here. 3. **CNAM registration.** "Caller name" (CNAM) is a separate registration from the number itself and can take days to propagate after a number is added. 4. **Receiving carrier doesn't look up CNAM** (common on mobile networks). This is not something either you or DialStack can change. **What to try:** 1. Establish whether the user placed the call or it was forwarded. Only the first is a settings problem. 2. Confirm the outbound caller ID is set correctly on the user's profile in the admin portal. Each user can have a per-user outbound caller ID that overrides the account default; check that this is set to the number you expect. 3. Check that the number itself is still **active** with **outbound calling** enabled. If a user's override is inactive or has outbound calling switched off, the call does not fall back to the account default — it falls back to another active number on the account, which is a common cause of "the wrong number showed up". 4. For inbound "Unknown" calls, the calling party's CNAM may not be in any directory, which is normal. ## Device and app symptoms ### Desk phone won't boot or shows no display 1. Confirm the phone is getting power: PoE from the network switch, or a separate power adapter. The phone's power LED should be on. 2. Confirm the network cable is in the LAN port, not the PC port. 3. If the phone is using PoE, try a different switch port. Some switch ports are non-PoE. 4. Reboot the phone by unplugging it for 30 seconds, then plugging it back in. ### Desk phone won't register (shows "no service" or similar) 1. Confirm the phone is on the network. Most phones display their assigned IP address on the status screen. If it's 0.0.0.0 or self-assigned, the phone isn't getting DHCP. 2. Confirm the customer's firewall allows the [required ports](./network-requirements.md#ports-and-destinations). 3. Re-provision the phone from the admin portal. See [Managing Devices](/admin-guide/account-admin/devices) for the re-provision flow. 4. Last resort: factory reset the phone, then re-provision. Per-vendor reset steps (Snom, Yealink, Poly) are in the [Migrating Existing Phones guide](./phone-swap.md#factory-reset-procedures). ### Desk phone shows the wrong user or extension The device is assigned to a different user than expected. Open the user's profile in the admin portal, reassign the device, and the phone will re-provision on its next check-in (usually within a few minutes; reboot the phone to force it). ### Softphone or WebRTC client won't sign in 1. Confirm the user's credentials are correct. 2. Confirm the user's account is active in the admin portal. 3. Try in a different browser or on a different network to isolate whether it's a local issue. 4. For WebRTC specifically, the user's browser must allow microphone access. Chrome, Edge, and Firefox prompt the first time; if the user previously denied the prompt, the permission has to be reset in browser settings. ### DECT handset won't pair or shows "no base" 1. Confirm the base station is online and registered. 2. Bring the handset within a few feet of the base. 3. Re-run the pairing process from the handset menu (varies by manufacturer; usually under Settings → Registration). 4. If the base shows the handset is registered but the handset still says "no base", reboot both. 5. If pairing still fails, factory reset the base station and let the handsets re-pair; per-vendor steps for the Snom M-series and Poly Rove bases are in the [Migrating Existing Phones guide](./phone-swap.md#dect-base-stations). ## VoiceAI symptoms DialStack's VoiceAI agent (a [Voice App](/admin-guide/account-admin/voice-apps)) is a configurable AI receptionist that answers calls, follows instructions, references an FAQ, and (when configured) looks up customers and books appointments via a webhook. Below are the common things that go wrong, and how to triage them. ### Agent doesn't answer 1. Confirm the Voice App is wired into the dial plan that handles the affected number. Open the dial plan and look for a Voice App node on the path. 2. Confirm the Voice App is enabled and configured. An empty Instructions field or a missing greeting can cause the agent to fail to start. 3. Check whether the call is reaching the Voice App at all in the Call Logs. If the call ends before the Voice App node, the issue is upstream in the dial plan, not in the agent. ### Agent hangs up early or seems confused 1. Review the agent's **Instructions** field. Vague or contradictory instructions are the most common cause of unexpected behavior. 2. Review the **FAQ** entries. The agent leans heavily on these for answers; if a customer asks something the FAQ contradicts, the agent will get confused. 3. Check call recordings or transcripts on the Call Detail page to see exactly what the caller said and how the agent responded. Most "the AI is broken" reports resolve into "the instructions or FAQ need a small edit." ### Agent doesn't transfer to a person The agent can perform a **blind transfer** to a person: it says a handoff sentence, then connects the caller to an extension or an outside phone number. What it cannot do yet is an **attended (warm) transfer** — speaking to the receiving person before handing the caller off. Attended transfer is on the DialStack roadmap. If transfers aren't happening when they should: 1. Review the agent's **Instructions**. The agent transfers when its instructions tell it to; if they never mention when to transfer or who to transfer to, it won't. Spell out the trigger and the target, e.g. "If the caller asks for billing, transfer them to extension 101." 2. Confirm the transfer target is valid: an extension that exists on the account, or a full phone number. If the target user has no registered device, the transfer rings nothing — check [device registration](#desk-phone-wont-register-shows-no-service-or-similar). 3. Check the transcript on the Call Detail page to see whether the agent attempted the transfer at all, then where the call went after it. If callers should reach a human _before_ the agent in some cases, handle that upstream in the dial plan instead: an IVR that offers "1 for our team, 2 to talk to our AI assistant", or a ring group that rings real people during business hours and falls back to the Voice App after hours. ### Booking or scheduling integration isn't working 1. Confirm the **scheduling webhook URL** on the Voice App is correct and reachable. 2. Confirm the webhook secret matches the one configured on the customer's scheduling system. 3. The webhook is signed; the receiving system needs to verify the signature. If the customer's system isn't verifying correctly, requests will be rejected. 4. Check the customer-side webhook logs for any error responses sent back. ### Transcription is missing or wrong 1. Transcription happens after the call ends. Wait a minute or two and reload the Call Detail page. 2. Accuracy depends on audio quality. If the underlying call had high packet loss or low MOS, transcription quality suffers. Check the Quality Metrics on the same Call Detail page. 3. Background noise, multiple talkers, and accents all reduce accuracy. This is a limit of speech-to-text in general, not a DialStack-specific defect. ## Customer network diagnostics When the Call Quality dashboard points at the customer's network, confirm it from their side using the validation tests and firewall checks in the Network Requirements guide. Run them from a device on the same connection as the affected phones (wired if the phones are wired, the same Wi-Fi if they're wireless): - Run the [VoIP-grade network test and continuous ping](./network-requirements.md#validate-before-cutover) (the Cloudflare speed test plus a ping over a few minutes), then compare the result against the thresholds above. - Confirm [SIP ALG is disabled](./network-requirements.md#disable-sip-alg) on the router. - Rule out [double NAT](./network-requirements.md#avoid-double-nat). If the tests pass but quality is still poor, that's an escalation (see [Information to collect before escalating](#information-to-collect-before-escalating)). The guide also covers Wi-Fi, PoE, DHCP, and QoS specifics if you need to go deeper. ## Information to collect before escalating When you need to escalate an issue to DialStack, the speed of resolution depends almost entirely on the data you send with the escalation. Collect the following before you write: - **Customer account name** in the admin portal. - **Affected location**, if more than one. - **Affected users or phone numbers**, with caller and called numbers for any specific failed calls. - **Exact timestamp** of the failed call, with timezone (e.g., "2026-05-28 14:32 PT"). - **Call ID** from the Call Detail page if you can pull it. - **Symptom**, in the customer's words: "calls cut out", "people can't hear me", "ring with no answer". - **Reproducibility:** one-off, intermittent, always. - **MOS / jitter / packet loss** from the Call Quality dashboard for the affected window. - **Network test results** if you ran one. - **What you've already tried** so DialStack doesn't suggest the same things back. - **Screenshots** of any error messages or unexpected behavior. An escalation with this in the first message gets resolved in one round of back-and-forth. One without it usually takes three or four. **Where to escalate:** send it through your shared Slack channel with DialStack, or to your DialStack contact if you don't have a channel set up yet. ## Glossary - **MOS (Mean Opinion Score):** A 1 to 5 rating of perceived voice quality. Above 4.0 is good. - **Jitter:** Variation in the arrival time of audio packets. High jitter makes audio choppy. - **Packet loss:** Percentage of audio packets that fail to arrive. Anything above 1% is noticeable. - **Latency / RTT (round-trip time):** How long a packet takes to travel from one end to the other and back. High latency causes conversational delays. - **Codec:** The format used to encode voice. DialStack uses Opus and other high-definition voice codecs wherever both endpoints support them, on supported physical desk phones and even on PSTN legs when the upstream carrier can negotiate HD. G.711 is the fallback when an HD codec isn't available end-to-end. - **SIP:** The signalling protocol used by desk phones to register and place calls. - **RTP:** The protocol used to carry the actual audio. - **SIP ALG:** A router feature that inspects and modifies SIP traffic. Usually does more harm than good with hosted VoIP. Turn it off. - **NAT (Network Address Translation):** The mechanism a router uses to share one public IP across many private devices. - **STUN / TURN:** Servers that help voice clients work through NAT and restrictive firewalls. - **DSCP / QoS:** Tagging that lets a router prioritize voice traffic over other traffic. - **PoE (Power over Ethernet):** A way to power a desk phone through the same network cable that carries its data. - **Voice App:** DialStack's name for an AI agent configured to handle calls. ## Related - [Admin Guide](/admin-guide/) - [Network Requirements](./network-requirements.md) - [Call Quality dashboard documentation](/admin-guide/account-admin/quality) - [Dial Plans](/admin-guide/account-admin/dial-plans) - [Managing Devices](/admin-guide/account-admin/devices) - [Voice Apps](/admin-guide/account-admin/voice-apps) - [WebRTC Network & Troubleshooting](/webrtc/network) - [Changelog](/changelog) --- ## Branding Customize the look and feel of your admin portal with your own branding. Changes apply to all users who access your platform. ## Platform Name Set the display name that appears throughout the admin portal. This replaces the default "DialStack" branding with your organization's name. ## Logo Upload your organization's logo. Supported formats are PNG, JPEG, SVG, and WebP (max 2MB). The logo appears in the sidebar and login pages. To upload a logo: 1. Click the upload area or drag and drop a file. 2. Wait for the upload to complete. 3. The new logo appears immediately. ## Theme Colors Customize the color scheme for both light and dark modes. Each theme supports the following colors: | Color | Purpose | | ---------------------- | ---------------------------------------- | | **Primary** | Buttons, links, and interactive elements | | **Primary Foreground** | Text on primary-colored buttons | | **Background** | Page and sidebar backgrounds | | **Foreground** | Main text and headings | | **Card** | Card and panel backgrounds | | **Muted** | Subtle backgrounds and disabled states | | **Muted Foreground** | Secondary text, labels, and placeholders | | **Accent** | Hover states and highlighted elements | | **Border** | Borders, dividers, and input outlines | ### Adding a Custom Theme 1. Click **Add Custom Light Theme** to create a light mode theme. 2. Adjust the color values using hex codes (e.g., `#6B2CFF`). 3. Optionally click **Add Custom Dark Theme** for a dark mode variant. 4. Use the theme preview panel next to the color pickers to see how your changes look. The preview renders the same components used across the portal — primary, secondary, outline and hovered ghost buttons, a card with description, an input at rest and with the focus ring, a muted block, and a popover/tooltip surface — so contrast issues (for example a help icon that disappears against the accent color) show up before you save. 5. Click **Save** to apply. :::note A light theme is required before you can add a dark theme. ::: ## Custom Fonts Choose custom fonts from Google Fonts for headings and body text: - **Heading Font** — Used for h1-h6 headings and titles - **Body Font** — Used for paragraphs, labels, and general text Select a font from the dropdown or search by name. Choose **Use default** to revert to the default Poppins font. ## Preview vs Save The branding form has two distinct actions — they do very different things: - **Preview** applies your current form values across the whole portal **for you only, in this browser**. Nothing is published; your users keep seeing the current branding. Use it to walk through real pages — dashboard, directory, call logs — with the candidate look before committing. A confirmation dialog restates this each time you start a preview. While previewing, a banner stays visible at the top of every page with an **Exit preview** button, and the preview expires on its own after 4 hours. - **Save** publishes the form values to your platform. This is the only action that changes what your users see. A typical flow: edit (or import) branding → **Preview** → browse around → come back to the branding form → adjust if needed → **Save**. :::note Preview uses the logo **URL** only — a logo file selected for upload is not part of the preview and is only stored when you Save. The login page and emails always show the published branding, never a preview. ::: ### Import and Export JSON Instead of filling the form by hand, click **Import JSON** and paste a branding payload. This only fills the form fields — like typing the values yourself — so the same rules apply: **Preview** to try it, **Save** to publish it. All fields are optional: ```json { "name": "Acme Telecom", "logoUrl": "https://acme.example/logo.png", "theme": { "primary": "#1a56db", "background": "#ffffff" }, "darkTheme": { "primary": "#93c5fd" }, "fonts": { "heading": "Inter", "body": "Inter" } } ``` Colors are 6-digit hex values; fonts must be from the same Google Fonts list as the font pickers. **Export JSON** copies the current form values to the clipboard in the same format, so a configuration can be saved, shared, or re-imported later. ## Reset to Defaults Click **Reset to Defaults** to clear the branding **form** back to the default appearance — name, logo, themes, and fonts. Like any other form edit, this is not published until you click **Save**; if you reset by mistake, navigate away or reload the page and the form returns to your saved branding. --- ## Platform Administration As a platform administrator, you have all the capabilities of an account administrator plus access to platform-wide settings. This page is the entry point for features exclusive to platform admins — the rest of this section goes deeper into each topic. The order below mirrors how the pages are grouped in the portal: configuration first, then people, then the accounts on the platform. ### Platform configuration - [Platform Settings](./platform-settings.md) — Handle, status, API keys, and Webhook Secret - [Branding](./branding.md) — Customize your platform's appearance - [Messaging Partners](./messaging-partners.md) — Approve third-party messaging providers for your platform's numbers - [Phone Provisioning](./phone-provisioning.md) — Decide which method to use across your fleet (manual URL, DHCP Option 66, or zero-touch) and handle the rare cases where a phone won't register ### People - [Manage Platform Admins](./manage-platform-admins.md) — Add or remove platform administrators ### Accounts - [Manage Accounts](./manage-accounts.md) — Create and manage accounts within your platform - [Onboarding Dashboard](./onboarding-dashboard.md) — Track onboarding progress across accounts --- ## Manage Accounts Accounts represent individual organizations or business units within your platform. Each account has its own users, devices, phone numbers, and settings. ## Viewing Accounts Platform admins can see all accounts via the **Accounts** section on the platform detail page. The list shows each account's name, email, region, timezone, and creation date. Each account also shows its **Account ID** (`acct_…`) beneath the name. Account names do not have to be unique, so a customer with several sites may run three accounts all called "Smile Dental"; the Account ID is what tells them apart. It is the same identifier the API returns and webhooks send, so it is also the value to quote when raising a support request about a specific account. The full ID, with a button to copy it, is on the account's own page under **Account Information**. You can search by it as well. The account search box matches the ID the same way it matches names: paste a whole ID, or type any part of one, including a run of characters from the middle. Fragments shorter than three characters are treated as an ordinary name search. ## Creating an Account 1. Navigate to **Accounts** and click **New Account**. 2. Fill in the required fields: - **Name** — A display name for the account (e.g., "Acme Inc.") - **Account Mode** — Choose **Live** or **Sandbox** - **Email** — Contact email for the account. Required — at creation it establishes the account owner and is used to send the onboarding invite (see [Onboarding](#onboarding)). Afterwards it is an ordinary contact address you can change at any time, or clear entirely; changing it never changes who owns the account. Note that clearing it leaves the account incomplete for onboarding purposes. - **Primary contact name** — The account owner's name (required). - **Main Location** — The account's address (required). It becomes the account's **main location** — the default location used for emergency (911) calling and for tax and fee jurisdiction — so a single-site account is set up correctly the moment it's created, with no separate step. To move the account later, edit that location's address on the account's **Locations** tab rather than here. - **Pricing** — Monthly rates agreed with the customer (required, see below) 3. Optionally configure: - **Hardware** — Phones to ship with the account (optional, see below) Telephony settings — extension length, timezone, call transcription, recording, sensitive-data redaction, parking timeout, and emergency-call notification recipients — are configured after creation from the account's detail page (the **Telephony** and **Locations** tabs). New accounts start with the platform defaults. 4. Click **Create Account**. Owner email, primary contact name, the main-location address, and pricing must all be filled in first — the button validates them and won't proceed until they're provided. A confirmation dialog then asks how to finish: - **Create & send invite** — create the account and immediately email the owner their onboarding invite. - **Create account** — create the account without sending an invite; you can send it later from the account's **Onboarding** card. You are then redirected to the new account's detail page. ## Ordering Hardware When creating an account, you can add hardware line items — pick a phone model from the catalog and set a quantity for each line. A few rules apply: - Each model appears on at most one line — adjust the quantity instead of adding the same model twice. - Orders are limited to 10 line items. Cordless (DECT) handsets must be associated with a base station to work. If your order includes handsets without one, the form shows a reminder — order a base station too unless the customer already has one on site. Hardware is optional, but it can only be ordered during account creation — there is no ordering flow afterward. To add or modify hardware after the account is created, contact DialStack. ## Pricing When creating an account, record the monthly rates agreed with the customer, in USD: - **Per-User Rate** - **Per-DID Rate** — per phone number - **Per-VoiceAI Location Rate** All three rates are required to create the account. These rates are used to compute the account's taxes and fees, so they must be kept accurate. Rate changes take effect at the start of the next billing period. You can update these rates at any time after the account is created — open the account detail page and edit the **Pricing** card, or change them in the account's edit form. Each rate must fall within DialStack's approved range. If you need to set a rate outside those ranges, contact DialStack support. ## Onboarding Because the account's address (its main location) and pricing are captured at account creation, an account is ready to onboard as soon as it exists. You invite the account owner by email — a link to sign in and start onboarding. The subscription agreement must be accepted by the account's **owner** — the single person who holds the owner role on that account. An account admin is not enough: admins can see the agreement and that service is blocked until it is accepted, but only the owner can accept it. Platform and support admins cannot accept on a customer's behalf through their platform-level access either. The one exception is a person who genuinely _is_ the owner of that specific account, whatever else they also are — they may accept it themselves. If the owner cannot be reached, contact DialStack support rather than looking for another admin to sign. ### Sending the invite You can send the onboarding invite: - **At creation** — choose **Create & send invite** in the confirmation dialog, or - **Later** — open the account detail page and, in the **Onboarding** card, click **Send the invitation email**. Sending the invite requires the account to have an owner. Every account created through the portal gets one from the email you supply at creation, so this is normally already true; the Onboarding card tells you if the account has no owner, which needs support to resolve — the contact email is not the owner, so editing it will not establish one. The invite always goes to the owner's own address, which is not necessarily the account's contact email — if you have since changed the contact email, the invite still reaches the owner. Once sent, the card shows that the invite was emailed along with the owner's current invite status. ### Onboarding status on the account list The **Accounts** list shows an **Onboarding** column for each account: - **Not invited** — no invite has been sent yet. - **Awaiting acceptance** — the invite was sent; the owner hasn't signed in yet. - **In Progress** — the owner is working through onboarding; a small ring shows how far along they are. - **Complete** — the account has finished onboarding. ### Resending the invite If the owner loses the email, resend it — a fresh link is emailed and the old one stops working. You can resend from either: - The **Onboarding** card on the account detail page, or - The **Resend onboarding invite** action on the **Accounts** list row (shown only while an invite is outstanding). ## Subscription-agreement (SSA) coverage Live accounts must accept the Service Subscription Agreement before they can place any calls — including 911. The **Accounts** list surfaces where each account stands with an **Agreement** column: - **Signed** — the account has accepted the current agreement; the acceptance date is shown beneath the badge. - **Unsigned** — a live account that has not accepted yet (or accepted an older version that has since been updated). These accounts are blocked from all calling until they accept. - **Not required** — a sandbox or demo account. These are never prompted for the agreement. Use the **Agreement status** filter above the list to isolate a single group — for example, **Unsigned** to see every live account still blocked from calling. ## Live vs. Sandbox accounts Every account runs in one of two modes — **Live** or **Sandbox** — and the mode is locked at creation. - **Live accounts** connect to the real telephone network. Calls place and ring real phones, text messages go to real handsets, and everything is billable. - **Sandbox accounts** are completely isolated from the real world. They exist so you can explore the platform, build integrations, or run demos without affecting a live customer and without a phone bill. Nothing you do in a sandbox — placing a call, ordering a number, sending a message — touches real carriers or real phones. Sandbox actions do **not** affect live accounts, and live accounts do not see sandbox activity. ### Sandbox phone numbers are not dialable Phone numbers in a sandbox account are synthetic. They follow the reserved format `+1AAA555‑01XX` (North American 555-01XX range), which the telephone industry reserves for fictional use. Two quick ways to recognize a sandbox number: - The seven digits after the area code begin with `555` (not a real exchange). - The line portion is in the `0100–0199` range. Calling a sandbox number from a real phone will not reach — the call will fail at the originating carrier. Sandbox numbers can only be used within the sandbox itself. ## Editing an Account 1. Navigate to the account detail page from the **Accounts** list. 2. Click the **Edit** button in the Account Information card. 3. Update any editable fields. 4. Click **Save Changes**. ## Account Limits The **Account Limits** controls on the account's Telephony settings are visible only to platform admins. They cap how much an account can consume and act as spend and abuse guardrails: - **Phone Number Limit** — the maximum number of phone numbers the account can hold (1–1000, default 25). It applies both to buying numbers and to porting them in — creating a port order, or adding numbers to one — and an order that would take the account past the limit is rejected. The count includes numbers a port order has reserved but not yet completed, so a port in flight uses up the allowance; released numbers don't count. Raising the limit unblocks a rejected order, and so does releasing numbers the account no longer needs. Recovering a previously released number counts as acquiring one, so it is refused at the limit too. - **Inbound Fax Limit** and **Outbound Fax Limit** — the maximum number of faxes accepted or sent per rolling hour across the account (1–1000, default 25). Faxes beyond the limit are rejected. Each field shows the account's effective value (the default when unset). Edits save automatically. Account admins can see the rest of the Telephony settings but neither see nor change these limits. ### Managed AI Agent Also in this platform-admin section is the **Managed AI Agent** control, which overrides the platform-wide default (see [Platform Settings](./platform-settings.md)) for this one account: - **Inherit** — follow the platform default. - **Show** — always offer the managed AI agent for this account. - **Hide** — never offer it for this account. Like the platform-wide setting, hiding only affects creating new managed agents; existing ones keep working. Only platform (and super) admins can see or change this control. ## Default Outbound Caller ID The **Default Outbound Number** setting controls which phone number appears as the caller ID on outbound calls from this account. By default, the system uses the first available outbound-enabled number. To set a default: 1. Open the account settings page. 2. In the **Default Outbound Number** dropdown, select a phone number. 3. The change is saved automatically. Individual users can override this default from their user detail page. See [Managing Users](/admin-guide/account-admin/users#outbound-caller-id) for details. ## Deleting an Account 1. Open the account settings page. 2. Click **Delete account**. 3. Confirm the deletion. :::warning Deleting an account is permanent and removes all associated users, devices, phone numbers, and data. ::: ## Switching Between Accounts Use the account picker in the top navigation bar to switch between accounts. All account-scoped pages (Users, Devices, Phone Numbers, Locations) automatically reflect the selected account. --- ## Manage Platform Admins Platform administrators have full access to manage all accounts, settings, and branding within a platform. ## Viewing Platform Admins Navigate to your platform detail page. The **Platform Administrators** section lists all current admins with their email, name, role, and creation date. ## Adding a Platform Admin 1. Click **Add Admin** in the Platform Administrators section. 2. Enter the new admin's **Email** address. 3. Enter their **Name**. 4. Click **Add Administrator**. The new admin will receive an email invitation to set up their account. Once they sign in, they'll have full platform admin access. ## Resending a Pending Invitation Admins who have not yet accepted their invitation show a **Pending** badge in the Platform Administrators list. Hover that row and click the resend (paper-plane) action to email them a fresh invitation link. ## Removing a Platform Admin 1. In the Platform Administrators list, find the admin you want to remove. 2. Click the delete action. 3. Confirm the removal. :::note You cannot remove the last platform admin. At least one administrator must remain. ::: --- ## SMS Port-Out When a customer wants to send or receive text messages on a phone number hosted by using a third-party messaging provider, that provider sends a **messaging transfer request** (an SMS **port-out**) to the carrier. The carrier passes it to , which approves or denies it automatically at request time. Authorization is **per number** and **time-boxed**. Every number starts **locked** — a port-out request is denied. To allow one, you open a **window** on that specific number: while the window is open, an incoming port-out request for it auto-approves; once the window's expiry passes (or you lock it again), requests are denied. ## Opening or locking a window (admin) On a phone number's detail page, the **SMS Port-Out** card (platform-admin only) shows the number's current state: - **Locked** — port-out requests are denied. - **Open until <date>** — requests auto-approve until then. Enter how many days the window should stay open and select **Open window** to authorize port-out from now until that many days out (maximum 365). Select **Lock** to close it again immediately. The window is fixed when you open it — it does not move if you change it later for other numbers. ## Automating it (API) The same control is available on the public API as a singleton sub-resource on the phone number, so a platform can script it (for example, opening a window right after provisioning a number for a customer who is moving their texting): ``` GET /v1/phone-numbers/:phone_number_id/sms-port-out → { "expires_at": "2026-06-29T09:30:00Z" | null } POST /v1/phone-numbers/:phone_number_id/sms-port-out { "expires_at": "2026-06-29T09:30:00Z" } # open until then POST /v1/phone-numbers/:phone_number_id/sms-port-out { "expires_at": null } # lock ``` `expires_at` is the instant the window stays open until; `null` means locked. It must be a future timestamp no more than 365 days out. The endpoint is authorized with your API key (plus the `DialStack-Account` header) — it is not exposed to embedded SDK session tokens. The SDK wraps it as `client.phoneNumbers.retrieveSmsPortOut(id)` and `client.phoneNumbers.updateSmsPortOut(id, { expiresAt })`. --- ## Network Requirements What a customer's network needs in order to run the phone system cleanly. Use this as a pre-flight checklist before cutover, not as a reactive troubleshooting manual. This guide covers both kinds of endpoint: - **WebRTC clients** — the browser-based softphone and the mobile app - **SIP devices** — desk phones and DECT bases provisioned through It is written for the person who runs the customer's network (an internal IT lead or their managed-service provider) and is safe to share directly with them. The thresholds and ports below are the same regardless of integration tier. ## The short version Most SMB networks already meet every requirement here out of the box. If the customer has a normal business internet connection and a router they have not heavily locked down, voice will almost certainly work on day one. The handful of things worth confirming in advance: 1. **Bandwidth is rarely the issue.** A voice call uses about 100 kbps per direction. Even a busy office is well under a few Mbps of voice. What matters is whether that bandwidth is _stable_, not how much there is. 2. **Turn off SIP ALG** on the router if it is present. It is the single most common cause of strange voice problems. 3. **Avoid double NAT** (a customer router plugged in behind another router that is also doing NAT). 4. **Allow the outbound ports** in the [Ports and destinations](#ports-and-destinations) table. Most routers already do. 5. **Prefer wired Ethernet** for desk phones and **5 GHz Wi-Fi** for everything else. 6. **Run one network test** from the customer's site before cutover (see [Validate before cutover](#validate-before-cutover)). The rest of this guide is the detail behind those six points. ## Bandwidth A single voice call uses very little bandwidth, on both WebRTC and SIP endpoints: - **WebRTC clients** use the Opus codec: roughly 50 to 60 kbps per direction including overhead. - **Desk phones** use G.711: roughly 80 to 100 kbps per direction including overhead. The table below is sized at 100 kbps per call, so it is safe for either endpoint type: | Concurrent calls | Approx. bandwidth (each direction) | | :--------------: | :--------------------------------: | | 1 | 100 kbps | | 5 | 500 kbps | | 10 | 1 Mbps | | 25 | 2.5 Mbps | In practice, modern internet connections have plenty of headroom for voice. Bandwidth is rarely the cause of a problem; how the bandwidth is _shared_ with other traffic is what matters. A 100 Mbps line saturated by a large upload at the wrong moment can still break a call. That is what [Quality of Service](#quality-of-service) addresses. ## Network quality thresholds Voice is sensitive to the _quality_ of a connection, not just its speed. Three metrics matter: | Metric | Good | Acceptable | Problematic | | ------------------ | ------------ | ------------ | ------------ | | Jitter | Below 20 ms | Below 30 ms | Above 30 ms | | Packet loss | Below 0.5% | Below 1% | Above 1% | | Round-trip latency | Below 100 ms | Below 150 ms | Above 200 ms | These are industry-standard thresholds for VoIP. If a connection consistently sits outside the "Acceptable" column, voice quality will suffer on any platform, so it is worth measuring before cutover rather than after the first complaint. The [Validate before cutover](#validate-before-cutover) section explains how to measure them. Once the system is live, the admin portal's [Call Quality dashboard](/admin-guide/account-admin/quality) reports the same metrics (Avg. MOS, jitter, packet loss) per call, so there is a numbers-based record of how the network is actually performing. ## Ports and destinations The customer's firewall needs to allow **outbound** traffic to the destinations below. No inbound port-forwarding rules are required; keeps the connection alive from the endpoint side. | Protocol | Destination | Port | Used by | Purpose | | -------------- | ------------------------------------------------ | ----------- | ------------------------- | ------------------------------- | | SIP (UDP/TCP) | `sip-east.dialstack.ai`, `sip-west.dialstack.ai` | 5060, 5061 | SIP devices | Desk phone and DECT signalling | | RTP (UDP) | media servers (see note below) | 10000–20000 | SIP devices | Desk phone and DECT audio | | HTTPS (TCP) | `prov.dialstack.ai` | 443 | SIP devices | Automatic provisioning | | WSS (TCP) | `api.dialstack.ai` | 443 | WebRTC, softphone, mobile | Signalling WebSocket | | STUN (UDP) | `global.stun.twilio.com` | 3478 | WebRTC | NAT type discovery | | TURN (UDP/TCP) | `global.turn.twilio.com` | 3478 | WebRTC | Media relay | | TURN (TLS/TCP) | `global.turn.twilio.com` | 443 | WebRTC | Media relay (firewall fallback) | The SIP signalling hostnames resolve to static IP addresses (one per region) and are suitable for allowlisting; allow both regions. The exact WebRTC media-relay servers and their credentials are provided to clients dynamically at call time. **About the RTP media destinations:** call audio flows directly to the platform's media servers, whose addresses are supplied per-call in signalling and are not fixed. The simplest rule — and the one we recommend — is to allow outbound UDP on ports 10000–20000 to any destination; an RTP stream is useless anywhere else, so this carries no practical risk. On networks that cannot allow "any destination", scope the rule to the published AWS IP ranges ([ip-ranges.amazonaws.com/ip-ranges.json](https://ip-ranges.amazonaws.com/ip-ranges.json), service `EC2`, regions `us-east-1` and `us-west-2`). Those ranges change over time, so the firewall needs to refresh them — many enterprise firewalls support AWS ranges as a built-in dynamic feed. The two endpoint types reach the network differently, which is worth understanding when allowlisting: - **WebRTC clients** always signal over TCP 443 (the standard HTTPS port) and prefer UDP for media, falling back to a TURN relay over TCP 443 when UDP is blocked. On a firewall that only permits TCP 443 outbound, WebRTC calls still work, with slightly higher latency. See [WebRTC clients: specifics](#webrtc-clients-specifics). - **SIP devices** signal over SIP (UDP/TCP 5060/5061) and carry audio over RTP on a range of UDP ports. They do not have the TCP 443 fallback that WebRTC has, so on a locked-down network the SIP and RTP destinations must be explicitly permitted. See [SIP devices: specifics](#sip-devices-specifics). Most consumer and small-business routers allow all of this outbound by default. Problems almost always come from a deliberately locked-down enterprise firewall, an aggressive UTM appliance doing deep packet inspection, or SIP ALG (covered next). ## Firewall and router configuration Four settings cause the large majority of network-side voice problems. Confirm each before cutover. ### Disable SIP ALG SIP ALG (Application Layer Gateway) is a router feature that inspects and rewrites SIP traffic as it passes through. It is well-intentioned but almost always interferes with modern hosted voice, and it is the single most common source of one-way audio and registration problems on SMB networks. Look for any setting labeled "SIP ALG", "SIP Helper", "SIP Transformation", or "VoIP passthrough" and turn it off, then reboot the router. The label varies by manufacturer. ### Avoid double NAT If the customer's modem also acts as a router, and they have plugged their own router in behind it, traffic passes through two layers of NAT. This frequently breaks voice. Either put the upstream modem into bridge mode so only one device does NAT, or connect the phones to the device that is doing NAT. ### Leave UDP connection tracking alone For inbound calls to reach a phone, the router has to keep its NAT mapping open between calls. sends keepalives every 20 seconds to refresh that mapping, so the router only needs to not prune the mapping faster than that. Virtually every consumer and small-business router clears this bar by default; out-of-the-box UDP connection-tracking timeouts are measured in minutes, not seconds. The only place this becomes a problem is a deliberately tuned firewall or UTM appliance configured to prune UDP sessions on very short timers, or one running a "SIP-aware" or "VoIP-aware" inspection profile that recycles sessions on its own schedule. On those networks: - Leave the default UDP connection-tracking timeout alone, or set it to at least 60 seconds. - Do not add custom rules that close or recycle UDP sessions for SIP or RTP traffic on short timers. - Disable or relax any "SIP-aware" or "VoIP-aware" inspection feature; these usually interfere more than they help. The symptom this prevents is inbound calls not ringing on a phone that otherwise shows as connected. ### No inbound port forwarding needed Because the endpoints maintain the connection outbound, there is no need to open inbound ports, set up port forwarding, or place phones in a DMZ. If a previous phone system required port forwarding, those rules can be removed. ## Local network and switching Mostly relevant to SIP devices, but the wired-versus-wireless guidance applies to everything. - **Power over Ethernet (PoE).** -supplied desk phones are typically powered over the network cable by a PoE switch. Confirm the switch ports the phones will use supply PoE, or plan to use the phone's separate power adapter. Some ports on a mixed switch are non-PoE. - **Wired beats wireless for desk phones.** A desk phone on wired Ethernet is the most reliable setup. Use the phone's LAN port, not its PC pass-through port, for the connection to the switch. - **DHCP.** Phones get their network configuration from DHCP by default. Make sure the network has DHCP available on the segment the phones are on. A phone showing an IP of 0.0.0.0 or a self-assigned address is not getting DHCP. - **Automatic provisioning.** -supplied phones provision themselves when they connect to the network and reach the internet. There is nothing to type into the phone by hand. The partner then assigns each device to a user in the admin portal, where its status shows as "provisioned". See [Managing Devices](/admin-guide/account-admin/devices). - **VLANs (optional).** A separate voice VLAN is not required. On larger or busier networks it can help isolate voice from other traffic and is where QoS tagging is usually applied, but it is an optimization, not a requirement. If a voice VLAN is used, set the VLAN ID through the device provisioning settings in the admin portal rather than relying on phone-side auto-discovery. - **Wi-Fi.** For softphones, the mobile app, and DECT, prefer 5 GHz over the more crowded 2.4 GHz band. Wi-Fi 6 handles voice better than older standards under load. Mesh systems vary; some hand off cleanly between access points mid-call and some drop the call. ## Quality of Service QoS is how a router protects voice traffic when the network is busy with other things (video conferencing, large uploads, backup software). -provisioned phones already tag their traffic by default — **DSCP 46 (Expedited Forwarding)** for audio and **DSCP 26 (Assured Forwarding 31)** for signalling — so the router only needs to honor those tags and give the voice class priority. QoS is optional on most SMB networks, where there is enough headroom that voice never has to compete. It is worth configuring if call quality dips correlate with busy periods, or on any network where voice shares a link with heavy data traffic. On a network with a dedicated voice VLAN, this is where the prioritization is usually applied. ## WebRTC clients: specifics WebRTC covers the browser-based softphone and the mobile app. The connection model is built to work behind almost any firewall. - **Signalling** is always a WebSocket over TLS (WSS) on TCP 443, indistinguishable from normal HTTPS traffic. - **Media** prefers a direct UDP path and uses ICE to find the best route. If UDP is blocked, it relays through TURN over TCP 443 as a last resort. That fallback adds a small amount of latency but means calls work even on networks that only allow outbound HTTPS. - **Encryption** is mandatory: media is DTLS-SRTP and signalling is WSS. There is no unencrypted path. - **NAT** is handled automatically. Symmetric NAT, which breaks many older voice clients, is resolved by the TURN relay without any configuration. - **Microphone permission.** The browser must allow microphone access for the softphone. Chrome, Edge, and Firefox prompt the first time; if a user previously denied the prompt, the permission has to be re-enabled in browser settings. Because WebRTC always has the TCP 443 fallback, it is the most resilient option on a restrictive network. If a customer's network cannot be opened up for SIP, the softphone will usually still work. ## SIP devices: specifics This covers -provisioned desk phones and DECT bases that register over SIP. - **Signalling** uses SIP over UDP or TCP on ports 5060 and 5061 to the SIP servers. - **Media** uses RTP over UDP ports 10000–20000 to the media servers. Unlike WebRTC, SIP devices do not fall back to TCP 443, so on a tightly controlled network the SIP and RTP destinations in the [Ports and destinations](#ports-and-destinations) table must be explicitly permitted outbound. - **Codecs.** Desk phones use G.711, the standard carrier-grade voice codec; WebRTC clients use Opus. No codec configuration is needed on the phone. - **Registration keepalives** are handled automatically, on the interval described in [Leave UDP connection tracking alone](#leave-udp-connection-tracking-alone). Nothing needs to be set on the phone. - **Provisioning** is automatic on network connection, as described in [Local network and switching](#local-network-and-switching). Bringing your own existing SIP devices is covered separately and is out of scope for this guide. ## Validate before cutover Two quick tests from the customer's site confirm the network is voice-ready before the phones go live. Run both from a device on the same network and the same connection the phones will use (wired if the phones are wired, the same Wi-Fi if they are wireless). ### Run a VoIP-grade network test A regular speed test reports raw bandwidth but not whether a connection is voice-grade. For voice, the test has to measure jitter and packet loss as well. The recommended tool is the **Cloudflare speed test** at [speed.cloudflare.com](https://speed.cloudflare.com/). It is free, browser-based, runs from the customer's network, and reports jitter and latency alongside bandwidth. Let it finish, then compare the result against the [Network quality thresholds](#network-quality-thresholds). If jitter or packet loss lands in the "Problematic" column, fix the network before cutover. ### Run a continuous ping A speed test is a snapshot; a continuous ping shows whether the connection holds steady over a few minutes. From a computer on the customer's network: **macOS or Linux:** ```bash ping -c 200 1.1.1.1 ``` **Windows:** ``` ping -n 200 1.1.1.1 ``` Let it run for two to three minutes, then look at the packet-loss percentage (should be at or near 0%) and the spread between minimum and maximum round-trip time (a large spread indicates jitter). Wild variation or dropped packets means the connection has a problem that voice calls will inherit. ### Confirm E911 on a live test call E911 location is registered during onboarding and must be in place before the phones make outbound calls. Once the phones are provisioned, confirm emergency routing by dialing **933** (the test number that reads back the registered address), never 911. See [Locations](/admin-guide/account-admin/locations) in the Admin Guide. ## Pre-flight checklist A one-screen version to hand to the customer's IT contact before cutover. **Internet and bandwidth** - [ ] Business internet connection in place at the site - [ ] Headroom for voice (about 100 kbps per concurrent call; effectively always true on modern connections) **Network quality (from [speed.cloudflare.com](https://speed.cloudflare.com/))** - [ ] Jitter below 30 ms - [ ] Packet loss below 1% - [ ] Round-trip latency below 150 ms **Router and firewall** - [ ] SIP ALG disabled - [ ] No double NAT (single device doing NAT, or upstream modem in bridge mode) - [ ] Outbound ports allowed (see [Ports and destinations](#ports-and-destinations)) - [ ] No aggressive UDP session pruning or "SIP-aware" inspection on managed firewalls - [ ] No inbound port forwarding needed (remove old rules if present) **Local network (desk phones)** - [ ] PoE available on the switch ports the phones will use, or power adapters on hand - [ ] DHCP available on the phones' network segment - [ ] Desk phones cabled to a switch (LAN port, not PC pass-through) **Wireless (softphone, mobile, DECT)** - [ ] Devices on 5 GHz where possible **Optional, busier networks** - [ ] QoS configured to prioritize voice (honor DSCP 46 for audio) **After provisioning** - [ ] Test call placed and audio is clean both directions - [ ] 933 dialed to confirm the registered E911 address ## When to escalate Work through this guide and the two validation tests first; the large majority of network readiness is self-serve. Escalate an issue to support when: - The network tests pass (jitter, packet loss, and latency are all in range) but call quality is still poor across multiple users at the site, or - SIP ALG is off, the firewall is permissive, double NAT is ruled out, and phones still will not register or inbound calls still will not ring. When you escalate, include the network test results, the affected site and users, and what you have already tried, so the support team can pick up where you left off rather than suggesting the same steps back. The [Call Quality dashboard](/admin-guide/account-admin/quality) is the place to pull per-call evidence. ## Glossary - **WebRTC:** The browser and mobile real-time voice technology behind the softphone and the mobile app. Works over HTTPS-style connections and needs no special client install in the browser. - **SIP:** The signalling protocol physical desk phones use to register and place calls. - **RTP:** The protocol that carries the actual call audio. - **Codec:** The format used to encode voice. WebRTC clients use Opus; desk phones use G.711. - **Jitter:** Variation in the arrival time of audio packets. High jitter makes audio choppy. - **Packet loss:** Percentage of audio packets that fail to arrive. Anything above 1% is noticeable. - **Latency / RTT:** How long a packet takes to travel to the far end and back. High latency causes people to talk over each other. - **MOS (Mean Opinion Score):** A 1 to 5 rating of perceived voice quality. Above 4.0 is good. - **SIP ALG:** A router feature that inspects and rewrites SIP traffic. Usually does more harm than good with hosted voice. Turn it off. - **NAT:** The mechanism a router uses to share one public IP across many private devices. - **STUN / TURN:** Servers that help WebRTC clients work through NAT and restrictive firewalls. - **DSCP / QoS:** Packet tagging that lets a router prioritize voice over other traffic. - **PoE (Power over Ethernet):** Powering a desk phone over the same network cable that carries its data. ## Related - [Call Quality dashboard](/admin-guide/account-admin/quality) - [Managing Devices](/admin-guide/account-admin/devices) - [Locations (E911)](/admin-guide/account-admin/locations) --- ## Onboarding Dashboard The Onboarding Dashboard gives admins a unified view of onboarding health — both at the platform level and for individual accounts. ## Why onboarding matters Every feature on assumes the onboarding steps have been completed: numbers can only route calls once they're assigned, users can only place calls once devices are provisioned, and call quality data only starts flowing once real traffic is running. An account that stalls partway through onboarding isn't broken — it simply isn't using the platform yet, and the customer is paying for something they haven't unlocked. That's what makes this dashboard useful day-to-day: - **Catch stuck accounts early.** An account sitting on the same step for days is a leading indicator of a customer who will churn or open a support ticket. Reach out before they do. - **Prioritize outreach.** The in-progress breakdown tells you where customers get stuck most often — if most accounts stall on Numbers, the fix is in porting or area-code coverage; if they stall on Hardware, it's shipping or provisioning. - **Measure platform health.** The percentage of accounts that have completed onboarding is a concrete retention and activation metric — not a vanity number. Treat any account stuck in **In Progress** or sitting in **Not Started** as an action item, not a statistic. ## Accessing the Dashboard From the **Accounts** page, click the **Onboarding Dashboard** button in the top-right corner. ## Platform Admin View As a platform admin, the dashboard shows: ### Platform Setup Checklist A checklist with a circular progress indicator showing how far along your platform setup is: - **Branding & Theme** — has your platform's branding been configured? - **Account Created** — has at least one account been created? - **Account Admin Assigned** — has an account admin user been assigned? The circular progress ring reflects the percentage of accounts that have completed onboarding. ### Account Stats Four summary cards show: | Card | Description | | -------------- | --------------------------------------------- | | Total Accounts | Total number of accounts on the platform | | Completed | Accounts that finished the onboarding flow | | In Progress | Accounts currently working through onboarding | | Not Started | Accounts that haven't begun onboarding | ### In-Progress Step Breakdown A bar chart showing how in-progress accounts are distributed across the three onboarding steps: - **Account Setup** — configuring basic account details - **Numbers** — assigning phone numbers - **Hardware** — provisioning devices This chart only appears when there are accounts in progress. ### Accounts Table A full list of all accounts with their onboarding status, current step, and completion date. ## "Service Agreement not accepted" banner Until an account accepts the Service Agreement, blocks all of its calling — inbound and outbound, including emergency (911) — as well as number provisioning. The account owner is prompted to accept it during onboarding. When you view such an account (as a platform admin, or while using **View As**), a slim amber banner appears at the top of the screen naming the account and noting that calling is blocked until the agreement is accepted. This is a **read-only signal to explain why the account can't place calls** — the agreement can only be accepted by the account's own owner, so there is no action for you to take here. The banner disappears automatically once the owner accepts. --- ## Phone Provisioning(Platform-admin) Provisioning is how a phone learns its SIP credentials, line assignments, and other settings without anyone typing them in by hand. As a platform admin you decide which method your accounts' phones will use — the choice is usually the same across your whole fleet, and your account-admins do not need to think about it once it is in place. If you are moving customer-owned phones over from another provider, see [Migrating Existing Phones](./phone-swap.md) for the full procedure (factory reset, previous-provider locks, rollback). ## Supported devices Provisioning supports phones from these vendors: | Vendor | Model lines | Zero-touch | | ------- | -------------------------------------------------------------------- | ---------- | | Snom | D-series deskphones; M-series DECT (base stations and handsets) | Yes | | Yealink | T-series deskphones | No | | Poly | VVX-series deskphones; Rove-series DECT (base stations and handsets) | No | Phones from other vendors — including Cisco and Grandstream — are not supported. Within these model lines the set of supported models changes over time, so **before standardising on a phone family or placing a large order, contact support** to confirm the specific models are currently supported. There are three ways a phone can find its configuration. The resulting configuration is the same; the difference is how the phone discovers DialStack on first boot. ## Manual URL The provisioning URL is pasted into the phone's local admin interface. You enter it once per phone; the phone then pulls its full configuration from DialStack, reboots, and registers on its own. - Works on every supported phone, with no network changes. - One-time per phone, but each phone has to be touched at least once. - Best when you have phones on a bench before they ship, or for one-off deployments. ### Before you start 1. **Copy the provisioning URL.** It is on the account's [**Account Settings**](../account-admin/accounts.md#provisioning) page, under the collapsible **Provisioning** section — use the copy button. The same URL is used for every phone on the account. 2. **Add the phone as a device** by MAC address on the [Devices](../account-admin/devices.md) page, so DialStack recognises it when it checks in. 3. **Put the phone on the same network as your computer**, or note the phone's IP address, so you can reach its web interface. 4. **Know the phone's admin password.** A brand-new or factory-reset phone still uses the vendor default noted below; newer models prompt you to set a password during first-boot setup. Once a phone has been provisioned, its admin password is the **phone admin password** shown alongside the URL on the [Account Settings](../account-admin/accounts.md#provisioning) → **Provisioning** section. To clear an old configuration first, see [Migrating Existing Phones](./phone-swap.md#factory-reset-procedures). The steps below use each vendor's own field names, and menu labels vary a little between models and firmware. Once the URL is saved and the phone reboots, it fetches its configuration with no further input. ### Snom (D-series deskphones) Snom deskphones take the provisioning URL from their web interface. 1. Find the phone's IP address: press the **settings key** (wrench icon) on the phone → **Information** (or **Status**) → **Network**, and note the IPv4 address. 2. In a browser on the same network, open `http://[phone-IP]` and sign in (username `admin`; the default password is `0000` on older firmware, or the password you set during first-boot setup). 3. Go to **Advanced** → **Update** tab. 4. Paste the provisioning URL into the **Setting URL** field. 5. Click **Apply**, then **Save**, then **Reboot**. On the current D8xx models (for example the D812 and D865) the web interface is arranged differently; if you can't find the **Setting URL** field, use DHCP Option 66 or zero-touch instead, or contact support for the model-specific path. ### Snom (M-series DECT) For a DECT system, configure the **base station** — the paired handsets pick up their configuration from the base, so you don't touch them individually. Sign in to the base's web interface at `http://[base-IP]` (default username `admin`, password `admin`); find the base's IP address from its status display or your router's DHCP leases. The field's location depends on the model: - **M500 (and M5x single-cell bases):** **Servicing** → **Provisioning** → paste the URL into the **Server URL** field → **Save**, then reboot from **Servicing** → **Reboot**. - **M700 / M900:** **Management** → enter the URL as the **Configuration server address** → **Save**, then reboot the base station. ### Yealink (T-series deskphones) Yealink deskphones can take the URL from the web interface or from the phone's own menu. The web interface is easier for entering a long URL. **Web interface (recommended):** 1. Find the phone's IP address: press **Menu** → **Status**, and note the IPv4 address. 2. In a browser on the same network, open `http://[phone-IP]` and sign in (default username `admin`, password `admin`). 3. Go to **Settings** → **Auto Provision**. 4. Paste the provisioning URL into the **Server URL** field and click **Confirm**. 5. Click **Auto Provision Now** to fetch the configuration immediately. **On the phone:** 1. Press **Menu** → **Advanced Settings** (labelled **Advanced** on some color-screen models such as the T54W; default password `admin`) → **Auto Provision**. 2. Enter the provisioning URL in the **Server URL** field. 3. Save to confirm. The phone provisions and reboots. ### Poly (VVX / Rove) Manual-URL steps for Poly phones aren't documented here yet. Use DHCP Option 66 where you control the network, or contact support for the current per-model procedure. ### Confirming it worked After the phone reboots it should come online and pull its configuration. Note that coming online is not the same as being ready to call — a deskphone or handset also has to be assigned to a user before it can place and receive calls. The [device setup checklist](../account-admin/devices.md#is-my-phone-working) on the device's detail page shows exactly how far the phone has gotten. If it stays on "Initializing" or shows a registration error, double-check the MAC address on the Devices page and confirm the phone can reach the provisioning URL from its network. ## DHCP Option 66 The provisioning URL is set on the customer's network DHCP server as **Option 66** (or Option 159 / 160, depending on the equipment). Phones plugged into that network read the URL automatically on boot. - Works for any number of phones on the same network with zero per-phone setup. - Requires that you (or someone on-site) controls the customer's DHCP server. - Best when you manage the customer's network or they manage it themselves competently. ## Zero-touch For select phones from supported vendors, the phone discovers DialStack automatically the first time it boots. No URL, no DHCP, no on-site configuration. - Add the phone to the account by MAC address on the [Devices](../account-admin/devices.md) page, then ship it directly to the end site. - The phone contacts its vendor's redirection service on first boot, gets pointed at DialStack, and pulls its configuration. - Best for ship-to-site deployments where no one will be on hand to touch the phone or the network. Zero-touch is enabled automatically — nothing to configure. See [Supported devices](#supported-devices) for which vendors offer it. ## Picking a method Most platforms standardise on one method for the bulk of their fleet and keep one of the others as a fallback. | Deployment shape | Recommended | | --------------------------------------------------- | ------------------------------------------------ | | Ship-to-site (you never touch the phone or network) | Zero-touch where supported, otherwise manual URL | | You install on-site or manage the network | DHCP Option 66 | | Pre-staging phones in your warehouse | Manual URL while staging, then ship | | Mixed fleet | Pick one default and document the exceptions | ## MAC conflicts Some phones arrive pre-registered to a previous provider — this is common with refurbished units and with [migrated phones](./phone-swap.md). When this happens the phone's zero-touch registration is refused, and a transfer request is opened automatically for our team to action — no support ticket needed. The transfer typically takes a couple of days; provision the phone with the manual URL method if you can't wait, and contact support if a transfer seems stuck. --- ## Migrating Existing Phones This guide applies when a customer keeps phones they already own instead of buying new hardware. A supported phone that was registered to another provider can be moved onto DialStack: you factory-reset it to clear the previous provider's configuration, then provision it like any new device. ## Supported vendors Existing phones can be migrated if they are [supported by provisioning](./phone-provisioning.md#supported-devices) — Snom, Yealink, and Poly. The set of supported models changes over time, so **before committing to migrate a customer's fleet, contact support** to confirm the specific models are currently supported. ## Before you start Run through this checklist before touching any phone: 1. **Confirm the model is supported.** Check with support if in doubt — an unsupported phone can't be migrated and the customer will need new hardware. 2. **Collect each phone's MAC address.** It is printed on the label on the back or bottom of the unit. 3. **Check for a previous zero-touch registration.** If a phone was deployed through another provider's zero-touch service, it may still be tied to that provider. See [Previous-provider zero-touch lock](#previous-provider-zero-touch-lock) below. 4. **Keep the previous provider's SIP credentials.** DialStack never sees or stores them, and a factory reset erases them from the phone — if the swap has to be rolled back, they are the only way to re-register the phone with the previous provider. 5. **Add each device in the admin portal** by MAC address. See [Managing Devices](../account-admin/devices.md). 6. **Have the provisioning URL ready.** It lives on the **Account Settings** page under the **Provisioning** section. 7. **Pick a provisioning method.** See [Choosing a provisioning method](#choosing-a-provisioning-method) below. ## Factory reset procedures :::caution[Factory reset erases everything on the phone] A factory reset removes all SIP accounts and settings, including the previous provider's configuration — that is the point, and it is required before the phone can take its DialStack configuration. Make sure the previous provider's credentials are saved somewhere first (see the checklist above). ::: ### Snom (D-series) - **Phone menu:** Settings → Maintenance → Reset Values → enter the admin password (default: `0000`) → confirm. On D86x models the path is Settings → System → Advanced → Factory reset. - **Web UI:** open `http://[phone-IP]` in a browser → Advanced → Update → **Reset**. On D86x models, open the Info menu in the top right of the Phone Manager page and select **Trigger Factory Reset**. After the reset the phone shows "Initializing" and pulls a new configuration on reboot. ### Yealink (T-series) - **Phone menu:** Menu → Advanced (password: `admin`) → Reset Config → Reset to Factory. - **Web UI:** open `http://[phone-IP]` → Settings → Upgrade → Reset to Factory Default. - **Most models:** hold the **OK** key for 10 seconds, then confirm the "Reset to Factory" prompt. ### Poly (VVX series) - **Phone menu:** Home → Settings → Advanced (password: `456`) → Administration Settings → Reset to Defaults → Reset to Factory. - **Web UI:** open `https://[phone-IP]` → Utilities → Phone Backup & Restore → Global Settings → **Restore**. ### DECT base stations For DECT systems, factory-reset the **base station** — the handsets re-pair with the base afterwards and don't need a separate reset. **Snom M500:** - **Base:** press and hold the **LINK** button for at least 20 seconds until all LEDs turn off (or hold the recessed reset button on the rear for 15 seconds). - **Web UI:** open `http://[base-IP]` → Servicing → Provisioning → Reset Configuration → **Reset**. **Snom M700 / M900:** - **Base:** press the recessed reset button (use a paper clip) for 10 seconds, until the LED turns solid red. - **Web UI:** open `http://[base-IP]` → Management → **Default Base Station** (bottom of the page). **Poly Rove (B2 / B4):** - **Web UI:** open `http://[base-IP]` (default login `admin` / `admin`) → System Management → Device Maintenance → Reset Configuration → **Reset**. - **Base:** the base also has a recessed reset button; see the vendor's documentation for the hold sequence, as it varies by firmware (and the button can be disabled by configuration). ## Choosing a provisioning method After the reset, the phone needs to discover DialStack. The three methods are covered in [Phone Provisioning](./phone-provisioning.md); in a migration context: | Method | When to use | | -------------- | --------------------------------------------------------------------------- | | Manual URL | Phones on a bench, one-off swaps, or while a zero-touch transfer is pending | | DHCP Option 66 | You (or the customer) control the site's DHCP server | | Zero-touch | Snom phones with no previous zero-touch registration | ### Previous-provider zero-touch lock A phone that was deployed through another provider's zero-touch service stays registered with that provider until the registration is transferred. Symptoms: after a factory reset the phone is redirected to the previous provider's server and never shows the DialStack configuration. **Snom:** when you add such a device by MAC address, the zero-touch registration attempt is refused and a transfer request is opened automatically — no support ticket needed. Our team handles the transfer, which typically takes a couple of days. If the swap can't wait, provision the phone with the **Manual URL** method in the meantime; the zero-touch transfer only matters for future factory resets. **Yealink and Poly:** zero-touch isn't used for these vendors, so a leftover registration with the previous provider's redirection service isn't detected automatically. If a phone keeps reaching the old provider after a factory reset, provision it with the **Manual URL** method (which overrides the redirection), and contact support if it still won't take the DialStack configuration. ## Who does what | Step | Platform | Customer | | -------------------------------------------- | :------: | :------: | | Verify the phone is supported | ✓ | | | Collect MAC addresses | ✓ | | | Add devices in the admin portal | ✓ | | | Factory reset the phones | ✓ | ✓ | | Connect phones to the network | | ✓ | | Provide the provisioning URL (manual method) | ✓ | | | Confirm calls are working | ✓ | ✓ | Factory resets can be done by either side: by the platform when phones are staged on a bench, or by the customer on-site following the vendor steps above. ## Failure modes and rollback **Phone keeps pulling the old provider's configuration.** This is the zero-touch lock described above. For Snom, the transfer request opens automatically when the device is added and our team handles it — typically a couple of days. For Yealink and Poly, use the Manual URL method; contact support if the phone still won't take the configuration. In all cases, Manual URL is the fastest path while a transfer is pending. **Provisioning fails after the reset.** The phone boots but stays on "Initializing" or shows registration errors: 1. Verify the MAC address was entered correctly on the Devices page. 2. Check that the account's provisioning token hasn't been rotated since the device was added. 3. Confirm the phone can reach the provisioning URL from the customer's network over HTTP/HTTPS. The [device setup checklist](../account-admin/devices.md#is-my-phone-working) on the device detail page shows exactly how far the phone has gotten. **Rollback.** To reverse a swap, factory-reset the phone again and have the previous provider re-register it on their end. DialStack never stores the previous provider's SIP credentials — this only works if they were retained before the swap (checklist step 4). --- ## Platform Settings The platform settings page shows your platform configuration, including status and API keys. ## Viewing Platform Information Navigate to your platform from the sidebar (the platform name appears in the navigation for platform admins). The platform detail page shows: - **Handle** — Your platform's unique identifier - **Status** — Whether the platform is Active, Suspended, or Cancelled - **Publishable Key** — The public API key used in client-side SDK integrations - **Created** — When the platform was created ## Managed AI Agent The **Managed AI Agent** toggle controls whether the built-in AI agent is offered to your accounts when they create a voice app. Turn it off if your platform provides its own AI experience and you don't want the built-in agent shown to your customers. When the toggle is off: - The managed AI agent option no longer appears when creating a voice app, and its documentation is hidden from your branded help. Custom (webhook) voice apps are unaffected. - Any managed agents that already exist keep working and stay editable — turning the setting off never strands existing configuration. This is the platform-wide default. You can override it for an individual account from that account's settings (see [Managing Accounts](./manage-accounts.md)). ## API Keys Your platform has two types of API keys, each with a live and a sandbox variant (see [Sandbox vs live keys](/guides/authentication#sandbox-vs-live-keys) for what each variant does): - **Publishable Key** — Safe to include in client-side code. Used to initialize the SDK. - **Secret Key** — Must be kept confidential. Used for server-to-server API calls. This key is only shown once, at platform creation or when you rotate keys. Click the copy icon next to any key to copy it to your clipboard. ## Webhook Secret The **Webhook Secret** is separate from the Secret Key. It is used to sign webhook deliveries so your receiving endpoint can verify that a webhook actually came from and hasn't been tampered with. It is _not_ an API key and cannot be used to call the API. In short: - **Secret Key** — authenticates _your_ calls to our API. - **Webhook Secret** — lets you authenticate _our_ calls to your webhook endpoint. ## Account-scoped webhook endpoints A webhook endpoint is normally **platform-wide**: it receives events for every account in its mode (live or sandbox). You can instead register endpoints scoped to a **single account**, so a dedicated receiver gets only that account's events. Account scoping is **additive**: an account-scoped endpoint receives its account's events _in addition to_ your platform-wide endpoints — it does not remove those events from the platform-wide delivery. This is useful when one account (for example a staging or demo account) needs its events delivered to a separate URL, without that receiver ever seeing any other account's events. Manage an account's webhook endpoints from that account's page (**Webhook Endpoints**, on the General tab). This section is available to platform administrators only. Over the API, include the `DialStack-Account` header on the webhook-endpoint calls to manage a specific account's endpoints; omit it to manage your platform-wide endpoints. ## Tracking billable changes DialStack can notify your systems whenever an account's user or phone number count changes, so you can reconcile before an invoice arrives instead of after. Subscribe a webhook endpoint to `user.created`, `user.deleted`, `phone_number.activated`, and `phone_number.disconnected`; each notification names what changed and the billable count either side of the change. Two things to keep in mind: - **These are resource counts, not invoice amounts.** Billing is prorated, so a user added mid-period is charged only for the remainder of that period. The counts tell you what is in service, not what you owe. - **Who made each change comes from the audit log**, not the notification itself, so that permissions are applied consistently. Each notification carries a correlation ID you can look up in the audit log to see the person responsible. For phone numbers, look up the order instead — a number goes into service when the upstream provider confirms it, so the change itself is not attributed to a person. See [Webhook Events](/guides/webhook-events#billable-count-changes) for the notification format and the audit-log lookups. --- ## Changelog New features, improvements, and fixes shipped to DialStack. Newest releases first. ## August 31, 2026 ### New features #### Webhooks - **Know when an account's billable counts change** — four new events (`user.created`, `user.deleted`, `phone_number.activated`, `phone_number.disconnected`) fire whenever a user or a phone number enters or leaves service, each carrying the billable count either side of the change. See [Webhook Events](/guides/webhook-events#billable-count-changes) and [Platform settings](/admin-guide/platform-admin/platform-settings). - **`call.ringing` says which way the call is going** — the event now carries `direction` (`inbound`, `outbound` or `internal`), like the other call lifecycle events, so a screen pop can tell an incoming call from an outgoing one without a second lookup. See [Webhook Events](/guides/webhook-events). #### Find Me / Follow Me - **Confirm Pick-Up (External) for Find Me / Follow Me** — when a step rings an external number, an external voicemail can answer and end the sequence before the later steps ever ring. Turn on Confirm Pick-Up (External) and an external callee has to press "1" to be connected; anything else is dropped and the sequence carries on. Off by default. See the [Users guide](/admin-guide/account-admin/users). #### Embedded portal - **Control who may sign the subscription agreement** — accepting the agreement is now its own session component, `agreement_acceptance`, separate from `account_onboarding`. A session with onboarding alone can read the agreement and show the accept screen; only a session you mint with the acceptance component can submit it. The onboarding portal takes a matching `canAcceptAgreement` so a non-signer sees an explanation rather than a button that fails. See [Account sessions](/sdks/server/sessions). - **A component can tell sandbox from live** — `DialStackInstance.livemode` reports whether the instance is working on live data, derived from the publishable key, with no extra call. See the [SDKs](/sdks). ### Bug fixes - The subscription agreement could be signed by anyone with access to an account, not only the person entitled to sign for it. Signing is now checked when the acceptance is submitted, not only in the browser. - A softphone signed in on more than one device or tab rang on only one of them. Every active session for the user now rings, including sessions connected in a different region. - Softphone calls from some native mobile clients intermittently failed to establish audio, because the answer could come back without the information needed to negotiate a media path when the client was slow to gather network candidates. - A call queue whose agents are reached at an external number — a cell phone, typically — never rang anybody, because the outgoing leg was placed without a caller identity and the carrier refused it. - On a call queue served from another region, an agent's leg could occasionally fail to dial, so fewer phones rang than should have. - With **Confirm Pick-Up (External)** on a ring group, the call log, `call.answered` and `call.end` reported the call as connected from the moment the external number answered, which could be tens of seconds before anyone pressed "1". The connection time is now the moment the two parties are actually joined. - On some deskphone models the programmable keys could not be configured — the phone reported no keys at all, silently, while registering and calling normally. - The white-label guide described signing a user into the portal by redirecting with a session token. Nothing consumed it, so it never worked. The guide now documents the portal access that is actually supported. See the [White-label guide](/integration-tiers/white-label). ## August 27, 2026 ### New features #### Do not disturb - **Server-side Do Not Disturb** — a user can now be put on do-not-disturb through the API. A call dialed to them, or ringing them through a ring group, is silenced on every device at once: deskphones, softphone, and their follow-me destinations. It sits alongside the key on the phone itself, which still silences only that handset. See the [Users API](/api#tag/Users) and the [Presence guide](/webrtc/presence). #### Accounts - **An account can see the rates it is paying** — account settings now show the account's standing monthly rates, and adding a user or ordering a number states what it adds to the bill. The figures are the rates in force today, with any agreed change flagged ahead of it. Available over the API too. See the [Accounts API](/api#tag/Accounts/operation/getAccountEffectivePricing). - **Account IDs are visible and searchable** — an account's ID now appears next to its name in the portal and can be searched on, which matters when several accounts share a name. #### Phone numbers - **Add many numbers to a port order at once** — a port order no longer takes one number per row. Paste a whole list — from a spreadsheet column, a comma-separated line, or a carrier's own export — and every number is validated as it lands, with duplicates and unusable entries called out individually. See the [Porting guide](/admin-guide/account-admin/port-orders). - **Inbound caller ID prefix at a glance** — the phone numbers table has a column for each number's inbound caller ID prefix, so it can be checked across an account without opening every number. See the [Numbers guide](/admin-guide/account-admin/phone-numbers). - **Inbound caller names** — inbound calls now carry the caller's name where the carrier network publishes one, so a deskphone shows who is calling and not only their number. See the [Numbers guide](/admin-guide/account-admin/phone-numbers#inbound). #### Voicemail - **Dial `*98` and be asked which mailbox** — `*98` still opens a mailbox directly; `*98` alone now asks which one, which is easier from a borrowed phone. See the [voicemail access guide](/admin-guide/account-admin/voicemail-device-access). ### Bug fixes - With **Confirm Pick-Up (External)**, the time to press "1" was whatever was left of the ring timeout, so a destination that answered late — a mobile, typically — was dropped mid-prompt. Every destination now gets 15 seconds from when it answers, and the prompt repeats. See the [Ring groups guide](/admin-guide/account-admin/ring-groups). - An External Number step in a dial plan silently discarded numbers it considered invalid — no message, and the field was empty again when reopened. A rejected number now explains itself, the step keeps the number it already had, and Save is held until the entry is corrected. - A portal user invited into an account that signs in through single sign-on had to set a password before they could use single sign-on at all. They can now activate directly. - The webhook guide promised in-order delivery per account, which was read as a guarantee about a call's own lifecycle events. It is not one — a recording event can arrive before the call ends — and the guide now says which orderings hold. See the [Webhook events guide](/guides/webhook-events). - Deleting a sandbox account could release a test phone number that other sandbox accounts were still using, because those fictional numbers are shared. Deletion is now scoped to the account it is deleting. - An outbound call dialed from a phone and refused before it was placed — a destination the account may not call, for instance — left a failed call-log entry and sent no webhooks. Nothing is placed, so nothing is logged now. Calls started through the API still record the entry. - Settings that a platform controls on behalf of its accounts could be changed through the API by an account administrator. They are now refused server-side, as the portal already implied. - The phone number limit on an account governs both ordering and porting, but several labels and messages described it as applying to new numbers only. Every place it appears now says the same thing. ## August 7, 2026 ### New features #### Presence - **Live park slot presence** — the presence API now reports what is parked right now, alongside user presence. Ask for every slot in the account or name the ones you care about, and you get each slot's occupant — the caller's number and label, who parked them, when, and when the call rings back — plus an update the moment a slot fills or empties. See the [Presence API](/api#tag/Presence). #### Accounts - **Creating an account produces its main location** — the address collected when an account is created now becomes the account's main location, so a single-site account is immediately correct for 911 and for taxes and fees with no separate step. In the portal, the new-device form pre-selects that location; creating a device through the API still takes the location you give it, and leaves it unset if you give none. The separate billing address is deprecated but still accepted, and most existing accounts have had a main location created from theirs. Later address changes are made on the location itself. See the [Locations guide](/admin-guide/account-admin/locations) and the [Accounts API](/api#tag/Accounts). - **See who administers an account** — a new read-only endpoint lists the people who can administer an account in the portal, with their roles. These are a different population from the account's telephony users: an administrator or owner may have no phone seat and so never appeared on any public endpoint. Useful if you want to show an administrator-only link or sync the right people into your own system. See the [Admin Portal Users API](/api#tag/Admin-Portal-Users). #### Phone numbers - **Search a long list of numbers** — the phone numbers list now has a search box that filters the tab you are viewing as you type, matching on the number, caller ID name, carrier, and call-routing target. It lives in the shared phone-numbers component, so it appears both in the portal and anywhere you embed that component yourself. See the [Numbers guide](/admin-guide/account-admin/phone-numbers). #### Softphone SDK - **Wider server SDK coverage** — the server SDK now forwards `expand[]` on the methods that accept it, along with `search` on the user list, and adds several resources that had no server-side surface before. `users.list` in particular previously dropped both outright and `users.retrieve` sent no query string at all, so an expanded field could not be requested even by hand. See the [SDKs documentation](/sdks). ### Bug fixes - Some calls lost the far party's audio from its recording and from live-listen, while the call itself sounded normal to both parties. Recordings now capture both sides. - A number's caller ID prefix reached most forwarded calls but not all of them, and showed less than it should have. When the caller's name was not known, the prefix replaced their number rather than labelling it, so the answering phone saw `[Acme]` and no number; it now shows `[Acme] +14084806024`. A call forwarded by a voice app lost the prefix entirely. A number routed to a call queue and answered on an external number, such as a staff member's cell phone, showed the company's own number in place of the original caller — that path now presents the original caller with the prefix, like every other forwarded call. - A location whose emergency address was rejected by the carrier failed silently — the account looked fine, but devices at that location could not place outbound calls. A failed location is now flagged in the portal so the address can be corrected and retried. - In Firefox, an outbound softphone call that drew early ringback from the carrier showed a "call failed" message while connecting perfectly normally. Firefox does not support the mechanism used for early media, and that is now handled rather than reported as an error. - A rare stall in the call-handling layer of one region could stop new calls from being set up there, and several behaviors then extended a brief stall into a long one. Those have been removed, so a stall no longer spreads or persists the way it did, and it is now detected and alerted on promptly. - Branded documentation pages hosted for a platform returned an error instead of the page. They now render, and several links between pages that led nowhere have been corrected. ## August 3, 2026 ### New features #### Call recording & transcription - **Sentiment on transcribed calls and voicemails** — a transcribed call or voicemail now carries an AI-derived sentiment alongside its summary: an overall verdict plus a per-speaker breakdown on calls. It appears on the AI Summary and Transcript cards in the portal and on the call and voicemail resources in the API. See [Call Logs](/admin-guide/account-admin/call-logs#transcript) for how sentiment is judged and when the per-speaker badges appear, and the [Calls API](/api#tag/Calls). #### Presence - **Separate WebRTC reachability signal** — a user's presence now includes a `webrtc` object with its own state and a count of live browser sessions, reported separately from their overall availability. See the [Presence API](/api#tag/Presence). #### Softphone SDK - **Discover other users from a user session** — a new `GET /v1/me/directory` endpoint returns every other user on the signed-in user's account, as an id and display name only, so a softphone can build its own contact list, presence watch list, and transfer targets without your backend supplying a roster. See the [User Profile API](/api#tag/User-Profile). - **Watch other users' presence from the softphone** — `phone.subscribePresence(userIds)` now subscribes over the softphone's existing connection and delivers a snapshot plus updates, so you can show who is available, on a call, or offline. Offline covers a user with no connected device at all, whether they work from a browser softphone, a deskphone, or a DECT handset. The basic softphone example demonstrates a team presence list. See the [SDKs documentation](/sdks). #### Accounts - **Manage voice access and portal role independently** — a user's voice seat and their administrator or owner role can now be added or removed separately, in any order, and both can be chosen when creating a user. An administrator or owner can exist with no voice seat and no billing, and a voice user can be promoted without being recreated. See the [Users guide](/admin-guide/account-admin/users). #### AI agents - **DialStack docs skill for AI agents** — if you build against DialStack through an AI agent, you can now install a skill that makes it read these docs before answering and cite the page it used, instead of answering from memory. It is deliberately thin — a pointer layer with almost no product detail of its own — so it does not go stale when we ship. It follows the open [Agent Skills](https://agentskills.io) standard, so Claude Code, Cursor, Codex, Copilot and Gemini CLI can all read it, and an organization owner can install it once for a whole support or admin team working in the browser. See the [AI agents guide](/guides/ai-agents) for install steps. #### Documentation - **Redesigned developer documentation** — the developer docs have a new look, and the guide above is reachable from the site's "For AI" menu. ### API changes - **`@dialstack/sdk` 2.0.0 is published on npm** — the SDK is published to npm as a major release. Its breaking changes are the ones announced in the last release and on June 29 — required owner email and contact name, required billing address and pricing on account creation, and the removal of `account_role` from user responses — plus one not previously listed: the deprecated `users.endpoints` resource has been removed. See the [SDKs documentation](/sdks). ### Bug fixes - On some deskphone models, placing a call on hold played a continuous dial tone into the handset for as long as the call was held. The tone is now off by default, and can be turned back on from the account's provisioning settings. - A deskphone could keep showing a SIP password prompt after a line was removed from it, because the removed line kept trying to register. Unused lines are now deactivated when the phone's configuration changes. - Retrieving a parked call from a deskphone could produce a call log entry with an internal identifier in place of the caller and no user attached; the entry is now attributed to the user and device that retrieved the call. - Calls answered by an AI agent connected and then went silent after about a second. Voice-app calls now hold their media path for the life of the call. - A voicemail deposit that captured no caller audio — an immediate hangup, or a dead-air robocall — could not be played back, and because playback never completed the message stayed unread. Once a mailbox held one, the phone's message-waiting light stayed lit no matter how many messages were heard. Empty deposits are now discarded and the light clears. This affected both personal and shared mailboxes. - Deleting a location that still had devices or pending hardware orders attached left them pointing at it. Those devices lost outbound calling, because they no longer resolved to an emergency address, and the staged orders could not be fulfilled. A location with devices or orders attached can no longer be deleted. Devices left pointing at an already-deleted location now show as having no location assigned, so an administrator can assign the correct one. - Changing an account's phone number limit in Telephony settings reported success and then reverted to the default of 25; the limit now saves, and is enforced when numbers arrive by port as well. - Fax-enabled numbers showed a call-routing target and a "Set routing" link in the phone numbers table, suggesting routing had to be configured for faxing to work. They now show "Not applicable". - An account's email address could not be changed on any account that had an owner — the attempt returned a generic failure with no explanation. The contact email is now independent of the owner's sign-in identity and saves normally. - The "Transfer ownership" action was offered to account administrators who were not permitted to use it, and the click was then refused. It is now shown disabled with the reason, for anyone other than the current owner or a platform administrator. - An account created on a platform that is not yet billable was stamped as billable, so making the platform billable later would have started charging for accounts created during setup, including test accounts. New accounts now inherit the platform's billing state. - Webhook deliveries refused before they left our network — for example an endpoint whose public DNS resolves to a private address — were dropped without counting toward the endpoint's health, so the endpoint was never flagged and nobody was notified. They now count, and such an endpoint reaches the unhealthy state and emails the platform's administrators like any other failing endpoint. ## July 27, 2026 ### New features #### Testing your integration - **Trigger every webhook event in sandbox, with no real call** — authenticate with a sandbox key and every event DialStack emits in production can be exercised without placing a real call. See the [testing guide](/guides/testing-your-integration). - **Creating a call runs a simulated two-leg call.** It advances on timers and produces `call.*` events, a recording, a transcript, and a summary with sample content, plus a complete call record when it ends. - **The number you dial scripts the outcome.** `+1 500 555 01xx` selects answered, no answer, busy, answering machine, or a mid-call transfer; `933` produces `call.emergency`. Your tests drive your real code path, with no test-only branches. - **Two sandbox-only endpoints cover the rest.** `POST /v1/test_helpers/calls` starts an inbound call for screen-pop and caller-ID testing, deposits a voicemail, or sets an explicit outcome and timings. `POST /v1/test_helpers/events` fires a single fax, queue, or other standalone event on demand. - **Delivery is your real path.** The same signed, retried, ordered webhooks and the same `/v1/events` stream as live traffic — only the source of the events is fabricated. #### Devices - **Roll out a button layout across your phones** — a button template can now be applied to many phones at once and designated as the account default, so new deskphones and DECT handsets inherit it automatically instead of being configured one at a time. Each phone still shows only the rows its model supports. See the [Button Templates guide](/admin-guide/account-admin/button-templates). #### Calls - **Look up a call while it is still in progress** — retrieving a call by ID now returns a live view during the call instead of only after it ends, so you can poll an in-flight call for its current state and caller display name. See the [Calls API](/api#tag/Calls). - **New `connected_at` timestamp for time-to-pickup** — a new `connected_at` field records when the winning leg — a user's device or an external-number fallback — answered, on both the call resource and the `call.answered` / `call.end` events. It is null when nobody took the call: the caller hung up during the greeting or while ringing, or the call went to voicemail. It gives you a precise time-to-pickup signal on inbound calls that play a greeting, menu, or voice app before ringing a user, where the call is answered up front so the audio plays reliably. `answered_at` is unchanged and keeps its existing meaning: the signalling answer, which a greeting, menu, or voice app answering the media path satisfies. See the [Calls API](/api#tag/Calls). #### Phone numbers - **Set call routing up front when ordering or porting a number** — the number ordering and porting flows now include a "Call routing" step where you pick a routing destination, so a number is already routed the moment it activates instead of sending the first callers to a "configure routing" prompt. The step is optional — you can choose "Set up routing later" and set it on each number afterwards. This completes the work behind setting routing ahead of a port, released on July 20. See the [Numbers guide](/admin-guide/account-admin/phone-numbers). #### Accounts - **Accept the subscription agreement with your API key** — a platform provisioning accounts programmatically can now accept the subscription agreement on an account's behalf using its secret key, instead of requiring the account owner to sign in to the portal. By calling it you assert that you presented the terms to the account owner and that the owner accepted them. See the [Accounts API](/api#tag/Accounts). - **Account members list includes admins and owners without a phone seat** — the account's people list now shows everyone with access, including administrators and owners who have no telephony seat, with badges marking which are billable. Previously anyone without a seat was invisible. See the [Users guide](/admin-guide/account-admin/users). #### Webhooks - **Retries pause for endpoints that keep failing** — after five consecutive failed deliveries, an endpoint is treated as unhealthy: events are attempted once instead of retried, and the platform's administrators are emailed so the endpoint can be fixed. A single successful delivery clears the state and normal retries resume. See the [webhook events guide](/guides/webhook-events). #### Softphone SDK - **Compose your own softphone from its parts** — the softphone's pieces (dial pad, call list, emergency-address banner, and the rest) are now self-contained and independently usable, so you can lay out your own interface instead of embedding the whole overlay. The SDK reference and a new usage guide cover the available hooks and components. See the [SDKs documentation](/sdks). - **Native keypad tones on mobile** — dialed digits are now sent natively during a call on mobile, so interactive menus and calling-card prompts respond reliably. - **Sessions recover after a device sleeps** — the softphone now detects a connection that went silent while the device was asleep and re-establishes it, instead of appearing connected but unable to place or receive calls. - **Steadier connect and emergency-address handling** — connecting and registering the softphone is more reliable, and the emergency-address state is reported more accurately during setup. ### API changes - **`POST /v1/accounts` now requires `email` and `primary_contact_name`** — both fields were optional and are now validated, so that every account is created with an owner. Creates that omit either field return a validation error. See the [Accounts API](/api#tag/Accounts). - **`account_role` removed from `/v1/users` responses** — this field was an admin-portal concept that had already been removed from the published API reference; the responses now match. Administrator and owner roles are available in the account's members list in the portal. See the [Users API](/api#tag/Users). ### Bug fixes - When checking voicemail from a phone, messages played back-to-back with no pause and no way to replay or erase one after hearing it. The options available after each message are now announced, so callers can replay, save, delete, or move between messages. This affected both personal and shared voicemail boxes. - Some deskphone models in timezones that observe daylight saving time showed the time one hour early; they now apply the seasonal change correctly. - A call log entry could show internal identifiers and untranslated text instead of readable numbers and labels, for calls that reached voicemail through a device's voicemail key or a shared voicemail button. - A call answered by an external fallback number was attributed to the wrong user on the call log and on call events; the call is now attributed to whoever actually answered, with the user who was rung recorded separately. - A menu that routed one of its own options back to itself could hang up the call instead of replaying the menu. - Editing an existing location's address in the portal reported success but kept the old address; the change now saves. - Creating an AI agent from within the dial plan editor showed a form with no way to save it; the form now scrolls and can be completed from that entry point. - Sandbox accounts are no longer asked to accept the subscription agreement, which had blocked opening them in the portal. - More port-in rejection reasons from the current carrier are now spelled out on the port order rather than shown as unknown. - A platform's handle can no longer be changed after the platform is created. Changing it left sign-in and branding pointing at the old handle. ## July 20, 2026 ### New features #### Call recording & privacy - **Automatic redaction of sensitive information** — accounts can now opt in to have sensitive details (card numbers, Social Security numbers, and personal names and addresses) automatically removed from call transcripts and silenced in the recording audio. Turn it on from the account's Telephony settings. See the [Accounts guide](/admin-guide/account-admin/accounts). #### Softphone SDK - **Multiple calls at once in the softphone** — the SDK softphone now handles more than one call at a time: a second incoming call gives a call-waiting alert, and you can swap between calls or transfer between them. See the [SDKs documentation](/sdks). - **Standalone React Native package** — the softphone now ships as a dedicated React Native package that any React Native app can embed, with native emergency-address handling built in. See the [SDKs documentation](/sdks) and the [React Native softphone example](https://github.com/dialstack/dialstack-sdk/tree/main/examples/mobile). - **Sessions stay connected longer** — softphone connections now refresh their access token automatically before it expires, so long-running calls and idle sessions no longer get disconnected and have to reconnect. #### Phone numbers - **Per-number caller ID prefix** — you can now set a short label on a phone number that is added in front of the caller's name on inbound calls (for example, `[Acme] John Smith`), so users can tell at a glance which number a call came in on. See the [Numbers guide](/admin-guide/account-admin/phone-numbers). - **Set routing before a port completes** — when you add numbers to an in-progress port order, they now appear right away so you can assign their inbound routing ahead of the scheduled cutover, instead of showing as unroutable until the port finishes. See the [Numbers guide](/admin-guide/account-admin/phone-numbers). #### Webhooks - **Account-scoped webhook endpoints** — platforms can now point a specific account's events at their own dedicated webhook receiver, without that receiver seeing any other account's events. See the [Webhook Endpoints API](/api#tag/Webhook-Endpoints) and the [webhook events guide](/guides/webhook-events). #### Developer settings - **Rotate live and sandbox keys independently** — you can now rotate your live secret key or your sandbox secret key on its own, instead of rotating both at the same time. See the [Platform Settings guide](/admin-guide/platform-admin/platform-settings#api-keys). #### Accounts - **Non-billable account owner** — an account owner can now be designated to sign the subscription agreement and administer the account without being billed as a telephony user. See the [Accounts guide](/admin-guide/account-admin/accounts). ### Bug fixes - Voice-app calls that dial or transfer to an internal extension now reach the user across all of their devices, along with ring groups, queues, and other voice apps. - The "Routed to" value on a call log now shows the extension that actually answered, for cases where it could previously display an intermediate number. - Improved handling of a rare timing case so a parked caller stays connected when the agent who answered from a queue is transferred elsewhere. - On a ring group, choosing "None (hang up)" as the timeout action now reliably clears a previously configured action. - A recently cancelled phone number now updates to inactive promptly once the carrier completes the disconnect. - A call queue's extension now stays displayed correctly after you edit other settings on the queue. - When a port order is rejected because the account number on file with the current carrier is incorrect, the status now spells out the reason to make it easier to correct. - Tightened the softphone's emergency-address check so an address is shown as confirmed only once the session's network location has been registered. ## July 13, 2026 ### New features #### Softphone SDK - **Embeddable softphone for web and mobile** — a complete, ready-to-use softphone dialer now ships with the SDK, built from a single shared codebase that runs on both web and React Native. Drop it into your own application to give users a full calling experience — placing and receiving calls, attended transfer, hold, an incoming ringtone, and emergency-address handling — without building a dialer from scratch. See the [SDKs documentation](/sdks) and the [React Native softphone example](https://github.com/dialstack/dialstack-sdk/tree/main/examples/mobile). #### Call recording - **Pause and resume recording** — an agent can now pause recording while collecting sensitive information (such as a payment card number) and resume afterward. Pause and resume from the API, or with the in-call `*7` toggle from the phone. The recording stays a single file with a silent gap. See the [Calls API](/api#tag/Calls/operation/pauseCallRecording). #### Phone numbers - **More inbound routing choices for a number** — a number with no target now rings every user on the account instead of dead-ending, and you can also deliberately set a number to "route to nothing" so inbound calls are dropped silently. See the [Numbers guide](/admin-guide/account-admin/phone-numbers). #### Call queues - **Reconnect waiting callers when an agent is made available** — making an agent available from the admin portal (or adding a new agent to a queue) now immediately reconnects callers already waiting on hold, instead of waiting for the next queue event. See the [Queues guide](/admin-guide/account-admin/queues). #### Devices - **One-touch park** — on supported deskphones, a park key now parks a live call with a single press, retrieves the call with a single press, and lights up when a call is waiting. See the [Devices guide](/admin-guide/account-admin/devices). - **Clearer status keys** — on supported deskphones, speed-dial, busy-indicator, and park keys now glow green when idle and red during a call, instead of staying dark until a call is in progress. #### Platform admin - **Edit account pricing after creation** — platform admins can now adjust an account's retail pricing from the account's Pricing card, within the approved bands. See [Managing Accounts](/admin-guide/platform-admin/manage-accounts). ### Bug fixes - Voicemail messages could occasionally be deleted at random when a mailbox reached its message limit; mailboxes now correctly remove only the oldest messages. - The first second or so of some prompts (voicemail access, on-hold, parking, and dial-plan audio clips) could be clipped; prompts now play from the beginning. - Voicemail menus were refined: the options menu now announces itself, sitting idle at the PIN prompt now plays a simple goodbye instead of a "too many attempts" message, and pressing a key during a multi-part prompt reliably interrupts it. - Some inbound calls answered by an external forwarding number never sent a `call.end` webhook; the terminating event is now delivered. - Attended-transfer consultation calls now send a final call event to webhook consumers, instead of appearing to start and never end. - Voice-app calls that dial or transfer to an outside number now use the account's outbound caller ID instead of being rejected. - Callers who reach a queue with no available agents now get a clear outcome instead of a repeating ringback tone. - Fixed a rare timing issue where answering a parking ring-back — when it rang more than one of your devices at once — could fail to reconnect you to the parked caller. - Creating a number order for a number already in use on another account no longer leaves the number unroutable. - Emergency-call notification emails for calls placed from a softphone now include the device and the caller's emergency address, matching desk-phone calls. - Line keys edited directly from a deskphone no longer override the managed configuration on the next provisioning cycle. - Removing a user's extension in the admin portal now saves correctly. - Adding a device that is already on the account now shows a clear message instead of a generic error. - Platforms that do not define a dark theme no longer have dark styling appear in the admin portal. ## July 6, 2026 ### New features #### Numbers & E911 - **Use a temporary number as a location's primary number** — a location can now use a temporary number as its primary number, so emergency (E911) service can be set up while a permanent number is still being ported in. See the [Locations guide](/admin-guide/account-admin/locations). #### Faxes - **Guardrail when auto-deleting faxes** — if a fax line is set to delete documents automatically, a notification recipient is now required, so incoming faxes are always delivered somewhere before they're removed. See the [Faxes guide](/admin-guide/account-admin/faxes). #### Call queues - **Smarter waiting-caller routing** — several refinements to how queued callers are matched to agents: a waiting caller is connected as soon as an agent frees up, and no-answer handling moves smoothly through the available agents. See the [Queues guide](/admin-guide/account-admin/queues). #### Call parking - **More parking slots** — accounts can now use many more call parking slots, supporting larger multi-location businesses. See the [Parking guide](/admin-guide/account-admin/parking). #### Platform admin - **Hide the built-in AI voice agent** — platforms that provide their own AI voice solution can now hide DialStack's default AI agent option from their accounts. ### Bug fixes - Completing an attended or cross-region transfer no longer occasionally drops the connected call; the remaining parties now stay on the line as expected. - Fixed a failure that could stop call listening, AI voice, and transcription audio from starting. - Opening a link straight to a specific port order or call log in the admin portal now loads the right account instead of a blank account picker. - The busy indicator (BLF) light for a parked call now lights reliably on subscribed phones. - Fixed the voicemail waiting light on DECT handsets, which was not being driven correctly. - Speed-dial and busy-indicator keys on supported deskphones now show the assigned name and a working status light instead of a raw identifier. - Admins with both platform and account access can once again switch between accounts in the account picker. - When creating an account fails, the portal now shows the actual reason instead of a generic error. ## June 29, 2026 ### New features #### Platform admin - **Create accounts with billing and pricing in one step** — the Create Account flow now captures the billing address and the agreed retail pricing as part of creating a customer account, with the option to send an onboarding invite immediately. See [Managing Accounts](/admin-guide/platform-admin/manage-accounts). Note for API users: `POST /v1/accounts` now requires `billing_address` and `pricing`. See the [Accounts API](/api#tag/Accounts). #### Developers - **Configurable webhook endpoints** — you can now register multiple webhook endpoints and subscribe each to only the event types it cares about (for example one endpoint for call events and another for voicemails), instead of every event going to a single URL. Endpoints are also scoped by mode, so sandbox traffic never reaches a live URL. See the [Webhook Endpoints API](/api#tag/Webhook-Endpoints). - **Voicemail summary webhook** — a new `voicemail.summary.complete` event fires when the AI-generated summary of a voicemail is ready; fetch the summary text from the [Voicemails API](/api#tag/Voicemails). #### Devices - **An additional deskphone model is supported** — one more desk phone model can now be added and provisioned. See the [Devices guide](/admin-guide/account-admin/devices). ### Bug fixes - Fixed a bug that prevented recording, and the resulting transcript and summary, for some inbound calls answered automatically (for example by a voice app, menu, or queue). - A number port that had been approved but not yet submitted to the carrier was shown with a confirmed-looking status and transfer date, implying the port was already scheduled. It now reads "Ready to Submit" with no date until it is actually submitted. - After a number was re-ported and the transfer completed, the number could incorrectly appear under the "Cancelled" tab and could not be assigned routing from the portal. Such numbers are now correctly shown as active. - Fixed a rare case where retrieving a parked call that had been parked during an attended transfer left the caller hearing only hold music for the rest of the call. - A parking-slot busy lamp on some deskphones could stay lit after the parked call was picked up; it now clears correctly. - The hardware Message/voicemail key on some deskphones could return a fast-busy instead of reaching the mailbox; it now connects reliably. - Fixed certain desk phone models not lighting their message-waiting lamp when a new voicemail arrived. - A device assigned to a button template can no longer lose its primary line key, which previously could prevent held calls from being retrieved on some phones. - Sandbox-mode accounts could not place internal extension-to-extension calls, so those calls produced no events or webhooks. Internal calls in sandbox now connect and emit events; only calls to the public phone network remain blocked. - Opening a fax from the account portal no longer stacks two detail dialogs; faxes now open as a normal page. - Fixed platform branding changes failing to save with a generic error. - The documentation support assistant now works for visitors who are not signed in. ## June 22, 2026 ### New features #### WebRTC - **Early media on outbound calls** — outbound WebRTC calls now play early media before the call connects: carrier ringback, busy and other in-band tones, and any pre-answer announcements or auto-attendant audio. Previously the caller heard silence until the other side answered. See the [WebRTC guide](/webrtc). - **Local ringback tone** — for internal calls and when an out-of-band ringing signal is received, the SDK now generates a ringback tone locally so the caller hears ringing instead of silence while the call is connecting. #### Extensions - **Assisted extension-length renumbering** — changing an account's extension length now opens a confirmation dialog that previews and applies a renumbering of every existing extension so they keep working at the new length, updates speed-dial buttons that point at them, and reprovisions the account's phones automatically. See [Changing the extension length](/admin-guide/account-admin/accounts). #### Numbers - **Set up routing before a number is active** — you can now assign call routing to a number that is still porting in or being provisioned, so it rings the right place the moment it activates instead of needing a manual change after the fact. #### Platform admin - **Subscription agreement acceptance** — account owners now review and accept the DialStack service subscription agreement (including the 911/E911 acknowledgement) at first sign-in, and the accepted version, date, and IP are recorded. #### Developers - **User presence API** — a new read-only presence resource reports whether a user is reachable, offline, or on a call right now, for both the signed-in user and any user on the account. See the [Presence API](/api#tag/Presence). - **Voicemails linked to their call** — a voicemail now references the call that produced it; request `expand[]=call` to inline the full call object. See the [Voicemails API](/api#tag/Voicemails). ### Bug fixes - Click-to-call (calls started from the API or app) now follows the user's full Find Me / Follow Me sequence instead of ringing only the first step. - The `recording.transcription.complete` and `recording.summary.complete` webhooks now send the call identifier in the same format as every other event and the documentation. - The SDK event stream now stops trying to reconnect once a session has expired or been revoked, instead of retrying indefinitely with a stale token. - Number port rejections now report a specific, actionable reason (such as an invalid porting PIN) instead of a generic carrier error. - Removing a line from a device no longer leaves the user unable to be assigned a new device. - Fixed certain phone models incorrectly showing buttons as unsupported when applying a button template, even though the model does support them. - When call recording is turned off but transcription is on, the audio captured only to produce the transcript is now reliably deleted even if transcription fails. ## June 15, 2026 ### New features #### Fax - **Fax is now available** — send and receive faxes from DialStack. A new Faxes page in the account portal lets you send a fax by picking a fax-enabled number and uploading a PDF, browse inbound and outbound faxes with filtering by direction, status, and number, open any fax in an in-app viewer, and see at a glance which inbound faxes are unread. Enable fax on a dedicated phone number — while fax is on, that number receives inbound faxes instead of voice calls — and add email recipients to be notified when one arrives. See the [Faxes guide](/admin-guide/account-admin/faxes) and the [Fax API](/api#tag/Faxes). #### Voice apps - **Transfer to an external SIP endpoint** — a dial plan transfer can now target a SIP address (for example, an external AI voice agent), in addition to extensions and phone numbers. See the [Voice Apps guide](/guides/voice-apps). - **AI agent call transfer** — the built-in voice AI agent can now hand a caller off to an extension or phone number after its closing message, with no dead air. See the [voice AI guide](/guides/voiceai-byo). #### Devices - **Ordered hardware in the Devices section** — hardware you have ordered but not yet received now appears under Devices, where you can pre-stage each unit (who it is for and which location) before it ships. - **Base station deletion preserves handsets** — deleting a DECT base station no longer deletes its paired handsets; the handsets remain so they can be re-paired to another base. - **Reorder phone buttons** — programmable button templates now support drag-and-drop reordering. See the [Button Templates guide](/admin-guide/account-admin/button-templates). #### Platform admin - **Agreed pricing in the admin portal** — the agreed retail pricing added through the API last release can now be set directly in the UI, on the Create Account form and in a new Pricing section on the account page. See [Managing Accounts](/admin-guide/platform-admin/manage-accounts). - **Troubleshooting and network requirement guides** — new Basic Troubleshooting and Network Requirements guides are now available under Platform Admin. See [Basic Troubleshooting](/admin-guide/platform-admin/basic-troubleshooting). #### WebRTC - **Longer-lived sessions and instant revocation** — WebRTC user session tokens now last 24 hours by default and can be configured up to 7 days, reducing how often your app has to refresh them, and a new endpoint immediately revokes all of a user's active sessions. See the [API](/api#tag/Sessions/operation/revokeUserSessions). #### Misc - **Tabbed account settings** — the Account Settings page is now organized into tabs (General, Telephony, Locations, Devices, Activity) instead of one long scrolling page. - **Dial plan editor zoom** — the dial plan editor now zooms with the scroll wheel, centered on the cursor; drag to pan. ### Bug fixes - Fixed a case where audio could be silent toward the other party after answering an incoming WebRTC call, so the far end now reliably hears the browser microphone. - Fixed incoming WebRTC calls that could fail to connect on the first press of Answer — a rare timing issue while the browser was still prompting for microphone access; a second press worked. - Resubmitting a number port order now reports a clear, actionable reason when something needs attention (such as a scheduled date that has passed) instead of a generic error, and an approved order whose submission failed can now be edited and resubmitted. - Fixed the call logs page failing to load for an account when a call had incomplete direction information. - Fixed a registered device that could incorrectly show as Offline because of a username letter-case mismatch. - Fixed a "no SIP lines configured" error when rebooting or resyncing shared devices that are not assigned to a user. - Assigning a device to a user who already has one now shows a clear message instead of a raw error. - Setting or changing a voicemail PIN by phone now works reliably; digits pressed while the prompt is still playing are no longer discarded. - The hardware Voicemail key on deskphones and DECT handsets now reliably dials your mailbox. - Extended the busy-lamp fix so a monitored extension's indicator clears reliably in ring groups, queues, and Find Me / Follow Me scenarios. - Transferring a call to 911 no longer attaches an incorrect dispatchable location; the emergency operator determines the location instead. ## June 8, 2026 ### New features #### WebRTC - **Call transfer** — WebRTC users can now transfer calls, both blind (transfer immediately) and attended (consult the destination first, then complete). Available in the [SDK](/sdks) and the [softphone example](https://github.com/dialstack/dialstack-sdk/tree/main/examples/basic-softphone-example). - **E911 emergency calling** — WebRTC users can now register a validated emergency address that is bound to their device and used to route 911 with a dispatchable location. Emergency calls are never blocked. With emergency calling in place, WebRTC users can now place regular outbound calls to the public phone network, which were held back until E911 support was ready. See the [emergency calling guide](/webrtc/emergency). #### Platform admin - **Agreed pricing** — platforms can now set and retrieve agreed retail pricing per account through the [API](/api#tag/Accounts/operation/getAccountPricing), supporting platform-initiated onboarding. #### Devices - **Programmable phone buttons** — configure the buttons on desk phones and DECT handsets through reusable templates. Rather than picking low-level key types, you choose what each button should do — speed dial, busy-lamp/presence monitoring of another extension, or a shared mailbox key that lights when messages wait, dials in to listen, and can transfer a caller into the box — and bind the template to devices, with per-device overrides. See the [Button Templates guide](/admin-guide/account-admin/button-templates). - **DECT handset improvements** — the handset model can now be edited from the device page, and the add-device hardware selection flow has been refreshed. See [Managing Devices](/admin-guide/account-admin/devices). - **Faster line updates on assignment** — assigning, reassigning, or unassigning a user on a device now refreshes the affected phones immediately, with no manual reload prompt. ### Bug fixes - Hanging up an answered incoming call from the WebRTC softphone now reliably ends the call for both parties. - Fixed a rare timing issue where a call could keep ringing even after it was answered, instead of connecting. - Searching for available numbers by an unsupported ZIP code now returns a clear, actionable message instead of a generic error. - Deleting an account now correctly disconnects its numbers. - Fixed a busy-lamp indicator that could stay lit on some desk phones after a monitored extension's call ended. - Fixed a failure that prevented voicemails left in a shared mailbox from being saved. - Fixed an occasional incorrect voicemail transcription summary. ## June 1, 2026 ### New features #### WebRTC - **WebRTC initial release** — users can now make and receive calls over WebRTC. Up to 8 simultaneous calls per user are supported, with hold/resume functionality. See the [WebRTC guide](/webrtc) for more details. Limitations: outbound calls to the public phone network are temporarily disabled while we finish emergency-calling support (expected within a week), and more advanced features (e.g. call transfer) will follow soon. - **Softphone example** — a [ready-to-run browser softphone](https://github.com/dialstack/dialstack-sdk/tree/main/examples/basic-softphone-example) built on the SDK is now included in the SDK examples. #### Voice apps - **Notify mode** — the Voice App dial-plan node now supports a **Notify** mode for streaming live call audio to an external listener (for example, an AI agent or observer) without joining the call. Pick the mode directly in the dial-plan editor. #### Locations - **Main location** — every account now has a designated main location used to determine tax jurisdiction and regulatory fees for users and numbers without a location of their own (such as WebRTC-only users). The first location you create is set automatically; swap it any time from **Account Settings**. The main does not affect E911 — emergency calls always use the calling device's own location. See the [Locations guide](/admin-guide/account-admin/locations). #### Misc - The holiday exit for schedule nodes is back in the dial plan editor, allowing separate call routing on holidays. ### Bug fixes - Call logs that intermittently failed to load now load reliably. - Creating a new queue from the dial-plan editor no longer fails. - Fixed a bug where phones dialed for a queue or Find Me / Follow Me call would sometimes not stop ringing when the call was abandoned before being answered. - Ordering a new (fake) number in sandbox mode now works reliably. - Fixed a failure when porting a number to DialStack. ## May 25, 2026 ### New features #### Queues - **Position announcements** — callers waiting in queues now hear their position. - **Queue UX overhaul** — saner defaults, dispatch warnings, queue-member auto-login on add, and settings polish. - **Lifecycle webhook events** — new webhook events for queue state transitions. #### Devices - **DECT handsets** — handsets are now a supported device type in [`/v1/devices`](/api#tag/Devices): add-device flow, handset detail page, nullable paired base, per-device location for E911. - **Devices section redesign** — the Devices UI has been refreshed. #### Call parking - **Configurable park timeout** — set the call-parking timeout under account settings. - **New docs** — see the [Call Parking guide](/admin-guide/account-admin/parking). #### Call routing - **Connected-line identity** is now stamped on calls from the PSTN to a user's devices, internal dials, queue ringback, and ring-group ringback — callers and callees see the right name and number. - **[`hang_up`](/api#tag/Dial-Plans) dial-plan node** — an explicit hangup step is now available in the dial-plan editor. ### Bug fixes - When a parked caller hangs up, all phones that were ringing them back now stop ringing immediately. - Helper CDRs are filtered out of call logs. --- ## Activity Logging Write every call, voicemail, and recording into your system of record. This is a **pattern built on [Webhook Events](./webhook-events.md)**, not a separate API — webhooks are the right transport because they give you durable retries, signed payloads, and stable event IDs. The pattern is the same regardless of your stack: receive → dedupe → write. ## The four primary event types These four cover the minimum needed to reconstruct a call log. `call.end` carries everything you need to write the row — direction, timestamps, duration, status, caller/callee — so you don't need `call.start` for logging. Subscribe to `call.start` or `call.incoming` additionally if you want a pre-answer row (e.g. to show ringing calls in a live dashboard). | Event | When it fires | What to persist | | ----------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------- | | `call.end` | Call completes (answered, no-answer, busy, or failed) | A row in your call log — timestamps, duration, direction, status | | `voicemail.new` | A voicemail is left | A row in your voicemail log with the audio URL and caller info | | `recording.available` | A call recording finishes | The recording, attached to the call log row for that `call_id` | | `recording.transcription.complete` / `voicemail.transcription.complete` | Transcripts become ready | The transcript text, attached to the call or voicemail row | See [Webhook Events](./webhook-events.md) for every field in every payload. ## Minimal handler ```ts // POST /webhooks/dialstack app.post('/webhooks/dialstack', express.raw({ type: 'application/json' }), async (req, res) => { verifySignature(req); // see webhook-events.md const event = JSON.parse(req.body); // Dedupe by event.id — DialStack may deliver the same event more than once. if (await db.processedEvents.exists(event.id)) { return res.status(200).end(); } await db.processedEvents.insert({ id: event.id }); switch (event.type) { case 'call.end': await db.calls.upsert({ call_id: event.data.call_id, account_id: event.account_id, direction: event.data.direction, from: event.data.from_number, to: event.data.to_number, user_id: event.data.user_id, status: event.data.status, started_at: event.data.started_at, answered_at: event.data.answered_at, ended_at: event.data.ended_at, duration_seconds: event.data.duration_seconds, }); break; case 'recording.available': // May arrive BEFORE call.end (e.g. calls that reach voicemail), so // upsert rather than update — the row may not exist yet. The payload // carries no URL; fetch the audio from the API when you need it. await db.calls.upsert({ call_id: event.data.call_id, account_id: event.account_id, recording_duration_seconds: event.data.duration_seconds, has_recording: true, }); break; case 'recording.transcription.complete': await db.calls.update(event.data.call_id, { transcript: event.data.transcript, }); break; case 'voicemail.new': await db.voicemails.insert({ voicemail_id: event.data.voicemail_id, account_id: event.account_id, from: event.data.from_number, audio_url: event.data.url, received_at: event.created_at, }); break; case 'voicemail.transcription.complete': await db.voicemails.update(event.data.voicemail_id, { transcript: event.data.transcript, }); break; } res.status(200).end(); }); ``` Respond `200` fast. If any persist step is slow, queue it (SQS, BullMQ) and let the webhook return immediately — DialStack will treat anything slower than a few seconds as a failure and retry. ## Retry behavior DialStack retries non-`2xx` responses with exponential backoff. That means: - **Dedupe on `event.id`.** Every event carries a stable ID. A retry of the same event reuses that ID, so if you key your writes on it (or record the IDs you've already processed), retries won't produce duplicate rows. - **Accept out-of-order delivery.** `recording.available` can arrive after `recording.transcription.complete` if the transcript came from an earlier attempt. Persist what you have, enrich what comes later. - **Short timeouts.** DialStack's own timeout is tight; don't make upstream calls on the webhook path. ## Enrichment window `call.end` fires immediately when the call ends. Recordings and transcripts are produced by separate pipelines that key off the recording, not the call, so neither is ordered against `call.end` and **either can arrive before it**: - `recording.available` — usually within seconds of the recording finishing, which is not necessarily when the call ends. A recording covers the conversation, and a call can outlive its conversation — a call that reaches voicemail is the common example — so the event can arrive well before `call.end` — see [Ordering](./webhook-events.md#ordering). - `*.transcription.complete` — typically under a minute after the recording finishes; longer for long calls. Transcription starts from the finished recording, so it can also complete before `call.end`. Your UI should show the call row as soon as it has one — usually on `call.end`, but on `recording.available` if that arrives first — and progressively enrich it as the other events arrive. Write all of these with an upsert keyed on `call_id` rather than an update: an update would silently match zero rows, and a deduped event is never redelivered. ## See also - [Webhook Events](./webhook-events.md) — full event surface + signature verification. - [Screen Pop](./screen-pop.md) — the other half of the event loop: react to calls as they ring. - [Appointment Webhooks](./appointment-webhooks.md) — request/response webhooks for AI Scheduling. --- ## Using DialStack with AI agents If you work through an AI agent — Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI — install the **DialStack docs skill**. It makes the agent read this documentation before answering, and cite the page it used. ## Why bother These docs are public, so most models already have some knowledge of DialStack baked in. That sounds helpful and is the actual hazard: the recalled answer is fluent, usually close, carries no source, and goes stale silently. You cannot tell which half of it to trust, and neither can the agent. The skill changes that. It is a thin pointer layer — it holds almost no product detail of its own, so it does not go out of date when we ship. It tells the agent where to look, insists it look, and insists it cite. ## Install ### Claude Code ``` /plugin marketplace add dialstack/dialstack-sdk /plugin install dialstack-docs@dialstack ``` ### Cursor, Codex, Copilot, Gemini CLI, and others The skill follows the [Agent Skills](https://agentskills.io) open standard, which these tools all read. Copy the skill directory into wherever your tool keeps skills: ```bash git clone https://github.com/dialstack/dialstack-sdk cp -r dialstack-sdk/skills/dialstack-docs ``` Check your tool's documentation for that path. ### Claude.ai and Cowork Useful for support and account-admin teams who work in the browser rather than in an editor. An organization owner installs it once for everyone, under **Settings → Skills → Organization skills**. Build the upload archive by running `./package.sh` inside the skill directory. ## What it does - Fetches the relevant page before answering, instead of relying on recall. - Distinguishes portal questions from API questions. Several topics are documented both ways, and the answers are different. - Cites the page it used, so you can check it. - Refuses to invent endpoints, parameters, or portal screens, and says so when the docs do not cover something. ## If you are not using the skill Point your agent at these two files, which exist for exactly this purpose: | File | What it is | | ----------------------------------------------------------------- | ------------------------------------------------------------- | | [`/llms.txt`](https://docs.dialstack.ai/llms.txt) | Index of every page with a one-line description | | [`/api/endpoints.md`](https://docs.dialstack.ai/api/endpoints.md) | Every API operation with its verb, path, and query parameters | Any page can be fetched as clean markdown by prefixing `/docs` and appending `.md` — so `/guides/pagination` is also available at `/docs/guides/pagination.md`. --- ## Appointment Webhooks import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Appointment Webhooks Receive appointment availability searches and booking requests from DialStack. ## Overview When DialStack's voice AI searches for availability or creates a booking, your platform can receive webhook notifications to handle these requests. Webhooks are optional — if not configured, the voice AI will receive an error. ## How It Works ``` ┌─────────────┐ ┌───────────┐ ┌──────────────┐ │ DialStack │ │ DialStack │ │ Your Platform│ │ Voice AI │ │ API │ │ │ └──────┬──────┘ └─────┬─────┘ └──────┬───────┘ │ │ │ │ 1. Search/Book │ │ │────────────────────▶│ │ │ │ │ │ │ 2. Webhook POST │ │ │────────────────────▶│ │ │ │ │ │ 3. JSON Response │ │ │◀────────────────────│ │ │ │ │ 4. Pass-through │ │ │◀────────────────────│ │ ``` 1. Voice AI sends request to DialStack 2. DialStack relays to your platform's webhook URL 3. Your platform processes and responds 4. DialStack passes the response back to the voice AI ## Configuration Appointment webhooks are configured per platform. To enable them, provide DialStack with: - **Base URL**: where DialStack sends availability and booking requests (e.g., `https://api.yourplatform.com/dialstack`) - **Signing secret**: a shared secret DialStack uses to sign each request so you can verify it (see [Signature Verification](#signature-verification)) This configuration is separate from [event webhook endpoints](./webhook-events.md) — appointment webhooks are synchronous request/response calls, not fire-and-forget event notifications. DialStack appends the endpoint path to your base URL: - `{base_url}/availability/search` - `{base_url}/bookings` ## Webhook Endpoints ### Search Availability Receive availability search requests. **Endpoint:** `POST {base_url}/availability/search` **Request Headers:** ``` Content-Type: application/json X-DialStack-Signature: t=1697634600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd X-DialStack-Account-Id: acct_01h2xcejqtf2nbrexx3vqjhp41 ``` **Request Body:** ```json { "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "query": { "filter": { "start_at_range": { "start_at": "2024-01-15T09:00:00Z", "end_at": "2024-01-15T17:00:00Z" } } } } ``` **Expected Response (200 OK):** ```json { "availabilities": [ { "start_at": "2024-01-15T10:00:00Z", "duration_minutes": 30 } ] } ``` ### Create Booking Receive booking creation requests. **Endpoint:** `POST {base_url}/bookings` **Request Headers:** ``` Content-Type: application/json X-DialStack-Signature: t=1697634600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd X-DialStack-Account-Id: acct_01h2xcejqtf2nbrexx3vqjhp41 ``` **Request Body:** ```json { "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "idempotency_key": "booking-req-123456", "booking": { "start_at": "2024-01-15T10:00:00Z", "duration_minutes": 30, "customer": { "phone": "+15551234567", "name": "John Doe", "email": "john@example.com" }, "notes": "Initial consultation - referred by AI assistant" } } ``` **Expected Response (200 OK):** ```json { "booking": { "id": "bkg_01h2xcejqtf2nbrexx3vqjhp41", "status": "confirmed", "start_at": "2024-01-15T10:00:00Z", "end_at": "2024-01-15T10:30:00Z", "location": { "name": "Main Office", "address": "123 Main St, City, ST 12345" } } } ``` ## Signature Verification All webhook requests include a signature header for verification. Always verify signatures to ensure requests are from DialStack. ### Header Format ``` X-DialStack-Signature: t=,v1= ``` - `t`: Unix timestamp (seconds) when the request was signed - `v1`: HMAC-SHA256 signature (hex-encoded) ### Verification Algorithm The signature is computed as: ``` signature = HMAC-SHA256(webhook_secret, timestamp + "." + request_body) ``` ### Implementation Examples ```javascript import crypto from 'crypto'; function verifySignature(payload, signatureHeader, secret) { // Parse the signature header const parts = signatureHeader.split(','); const timestamp = parts[0].replace('t=', ''); const signature = parts[1].replace('v1=', ''); // Check timestamp (reject if older than 5 minutes) const now = Math.floor(Date.now() / 1000); if (now - parseInt(timestamp) > 300) { throw new Error('Signature timestamp too old'); } // Compute expected signature const signedPayload = `${timestamp}.${payload}`; const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); // Compare signatures (timing-safe) if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { throw new Error('Invalid signature'); } return true; } // Express.js example app.post( '/dialstack/availability/search', express.raw({ type: 'application/json' }), (req, res) => { try { verifySignature( req.body.toString(), req.headers['x-dialstack-signature'], process.env.DIALSTACK_WEBHOOK_SECRET ); } catch (error) { return res.status(401).json({ error: { code: 'invalid_signature', message: error.message } }); } // Process the request... } ); ``` ```python import hmac import hashlib import time def verify_signature(payload: bytes, signature_header: str, secret: str) -> bool: # Parse the signature header parts = signature_header.split(',') timestamp = parts[0].replace('t=', '') signature = parts[1].replace('v1=', '') # Check timestamp (reject if older than 5 minutes) now = int(time.time()) if now - int(timestamp) > 300: raise ValueError('Signature timestamp too old') # Compute expected signature signed_payload = f"{timestamp}.{payload.decode('utf-8')}" expected = hmac.new( secret.encode('utf-8'), signed_payload.encode('utf-8'), hashlib.sha256 ).hexdigest() # Compare signatures (timing-safe) if not hmac.compare_digest(signature, expected): raise ValueError('Invalid signature') return True # Flask example @app.route('/dialstack/availability/search', methods=['POST']) def availability_search(): try: verify_signature( request.data, request.headers.get('X-DialStack-Signature'), os.environ['DIALSTACK_WEBHOOK_SECRET'] ) except ValueError as e: return jsonify({'error': {'code': 'invalid_signature', 'message': str(e)}}), 401 # Process the request... ``` ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "strconv" "strings" "time" ) func verifySignature(payload []byte, signatureHeader, secret string) error { // Parse the signature header parts := strings.Split(signatureHeader, ",") timestamp := strings.TrimPrefix(parts[0], "t=") signature := strings.TrimPrefix(parts[1], "v1=") // Check timestamp (reject if older than 5 minutes) ts, _ := strconv.ParseInt(timestamp, 10, 64) if time.Now().Unix()-ts > 300 { return errors.New("signature timestamp too old") } // Compute expected signature signedPayload := timestamp + "." + string(payload) h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(signedPayload)) expected := hex.EncodeToString(h.Sum(nil)) // Compare signatures (timing-safe) if !hmac.Equal([]byte(signature), []byte(expected)) { return errors.New("invalid signature") } return nil } ``` ## Error Handling Return appropriate HTTP status codes and error responses: | Status | When to Use | | ------ | ------------------------------- | | 200 | Request processed successfully | | 400 | Invalid request format | | 409 | Slot unavailable (for bookings) | | 500 | Internal server error | **Error Response Format:** ```json { "error": { "code": "slot_unavailable", "message": "The requested time slot is no longer available" } } ``` ## Idempotency Booking requests include an `idempotency_key` field. Use this to prevent duplicate bookings: 1. Store the idempotency key when processing a booking 2. If the same key is received again, return the original booking response 3. Keys can be safely expired after 24 hours ## Timeouts - DialStack waits up to **30 seconds** for your webhook response - If your webhook times out, the voice AI receives a 504 Gateway Timeout error - Design your endpoints to respond quickly; defer heavy processing if needed ## Testing ### Local Development Use a tool like [ngrok](https://ngrok.com/) to expose your local server: ```bash ngrok http 3000 ``` Then configure your platform's webhook URL to the ngrok URL: ``` https://abc123.ngrok.io/dialstack ``` ### Manual Testing Test your webhook endpoint with cURL: ```bash # Generate a test signature TIMESTAMP=$(date +%s) PAYLOAD='{"account_id":"acct_test","query":{"filter":{}}}' SECRET="your_webhook_secret" SIGNATURE=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "${SECRET}" | cut -d' ' -f2) # Send test request curl -X POST http://localhost:3000/dialstack/availability/search \ -H "Content-Type: application/json" \ -H "X-DialStack-Signature: t=${TIMESTAMP},v1=${SIGNATURE}" \ -H "X-DialStack-Account-Id: acct_test" \ -d "${PAYLOAD}" ``` --- ## Architecture overview A one-page mental model of how DialStack fits together. Read this once and the rest of the docs will make more sense. ## Tenancy model DialStack is multi-tenant from the ground up. Every resource lives inside this hierarchy: ```mermaid %%{init: {"flowchart": {"curve": "linear", "nodeSpacing": 100, "rankSpacing": 80}}}%% flowchart TD P[Platform] --> A1[Account A] P --> A2[Account B] A1 --> U1[User] A1 --> U2[User] U1 --> S1[Softphone
browser or mobile] U1 --> H1[Physical device
desk phone] U2 --> S2[Softphone
browser or mobile] ``` - **Platform** — your SaaS product. One per partner. - **Account** — one of your customers (a clinic, a dealership, a contractor). - **User** — a person at that customer with phone service. - **Softphone / physical device** — what rings. A user can have any number of softphone sessions (browser + mobile) plus at most one physical device (a desk phone or a DECT handset). All registered endpoints ring in parallel; first to answer wins. Your API key is scoped to your Platform. Every request includes a `DialStack-Account` header to pick which customer you're acting on (or carries the account in a JWT claim; see [Authentication](#authentication-at-a-glance) below). ## The account resource graph Inside an account, resources form a small, opinionated graph. Inbound calls arrive on a **phone number** or an **extension**, both of which route to routing targets (e.g., a user, a ring group, a dial plan, or a voice app). ```mermaid flowchart LR PN[Phone Number
inbound PSTN] --> T{routing_target} EXT[Extension
internal dial code] --> T T --> U[User] T --> RG[Ring Group] T --> DP[Dial Plan] T --> VA[Voice App] T -.-> More[...] DP -->|schedule node| SCH[Schedule] DP -->|ring_all_users node| U DP -->|internal_dial node| RG DP -->|internal_dial node| VA DP -->|external_dial node| EXTN[External number] DP -.-> MoreNodes[...] RG --> U U --> SP[Softphones
browser · mobile] U --> PD[Physical device
desk phone or DECT · at most one] VA --> AIA[AI Agent] ``` - **Phone numbers** are the PSTN entry point. Each has a `routing_target` — the ID of the user, ring group, dial plan, or voice app that inbound calls to that number hit. - **Extensions** are short internal dial codes (e.g., dial `200` to reach reception). They route to the same routing targets as phone numbers; they're a parallel surface, not a hop on the way. - **Dial plans** are the branching logic — e.g., schedule-based routing, ring-all, external transfer, dial, or voice-app handoff. - **Ring groups** parallel-dial users. - **Voice apps** are programmable call handlers (REST webhook + optional audio WebSocket); [AI Agents](/api#tag/AI-Agents) are a DialStack-managed Voice App with a pre-built receptionist persona. - **Users** can register any number of **softphones** (browser, mobile) plus at most one **physical device** (desk phone or DECT handset). All registered endpoints ring in parallel. ## Call flow You interact with DialStack through four surfaces: the REST API, Webhooks, SSE (Server-Sent Events), and WebSockets. Everything else is managed for you. ```mermaid sequenceDiagram participant PSTN participant DS as DialStack participant App as Your backend participant Browser as User browser (SSE) participant Endpoint as User endpoint PSTN->>DS: Inbound call Note over DS,Browser: Events dispatched concurrently DS-->>App: POST /webhooks (call.incoming) DS-->>Browser: event: call.incoming Browser->>Browser: Screen pop (CRM lookup) DS->>Endpoint: Ring all of user's endpoints Endpoint->>DS: Answer Note over DS,Browser: Events dispatched concurrently DS-->>App: POST /webhooks (call.answered) DS-->>Browser: event: call.answered Note over PSTN,Endpoint: Media flows PSTN ↔ DialStack ↔ Endpoint Endpoint->>DS: Hang up Note over DS,Browser: Events dispatched concurrently DS-->>App: POST /webhooks (call.end + CDR) DS-->>Browser: event: call.end ``` Key properties: - **Webhooks are fire-and-forget.** Respond with an HTTP `200` OK quickly (before executing long-running logic); you cannot return data to influence the call. React in your own systems. - **SSE (Server-Sent Events) is account-scoped** and safe to consume in browsers with a session token — ideal for Screen Pop. - **Media is relayed by DialStack.** Your app never touches audio unless you explicitly `attach` a WebSocket (e.g., for BYO VoiceAI or Listeners). ## The four surfaces | Surface | Direction | Use for | | ---------------------- | ------------------------- | ------------------------------------------------------------------------------------------- | | **REST API** | You → DialStack | Provisioning, config, triggering calls, reading history. Auth: Bearer token. | | **Webhooks** | DialStack → your backend | Durable event delivery with retries and signed payloads. Platform-scoped. | | **SSE** (`/v1/events`) | DialStack → your frontend | Real-time browser notifications. Account-scoped via session token. | | **WebSockets** | Bidirectional | `attach` for bidirectional audio (Voice App Control mode); Listeners API for one-way audio. | Full docs: [REST](/api), [Webhooks](/guides/webhook-events), [SSE](/guides/events), [WebSocket API](/websocket-api). ## Authentication at a glance | Token | Who uses it | Scope | TTL | | ------------------------------------ | ---------------------------- | ------------------------------------------------------------------ | ----------------------------------- | | **Platform API key** (`sk_live_...`) | Your backend | Your entire platform; needs `DialStack-Account` header per request | Never | | **Session token** (JWT) | Your frontend via the SDK | One account; claims carry the account | 1 hour, auto-refresh | | **User token** (JWT) | A specific end user's device | One user in one account | Short, refresh via `/v1/auth/token` | All three use `Authorization: Bearer `. Full flow: [Authentication guide](/guides/authentication). ## What DialStack hosts vs. what you host ### What DialStack hosts and manages - PSTN / SIP edges, carrier interconnects - Call routing engine, dial plan execution - WebRTC media relay, TURN - Voicemail, recording, transcription - AI Agents (native receptionist) - White Label admin portal - Regulatory and tax/fee compliance ### What you build and host - Your signup flow, account sync, user lifecycle - Webhook handler for activity logging / Screen Pop - Your frontend (Embedded tier) or nothing (White Label tier) - (BYO VoiceAI only) an audio-bridge WebSocket server - (AI Scheduling) availability + booking HTTP endpoints Nothing about calls, carrier relationships, or media transport is your responsibility. You own the business logic and the data you care about. ## Identifiers All DialStack IDs are opaque strings of at most 255 characters. Treat them as opaque; persist them exactly as returned. Every webhook event carries an `id` field. That's the idempotency key for consumers — DialStack may deliver the same event more than once, so dedupe by `event.id` before writing. ## What's next - [Quickstart](/guides/quickstart) — provision your first account, user, and phone number. - [Webhook events](/guides/webhook-events) — the firehose of everything that happens on a call. - [Dial plans](/guides/dial-plans) — route calls where you want them. - [Integration tiers](/integration-tiers/white-label) — pick between White Label, Embedded, and Direct API. --- ## Authentication import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Authentication DialStack supports three authentication methods for different use cases. | Method | Format | Scope | Use case | | ------------------ | ------------ | -------------- | ------------------------------------------------------- | | **API keys** | `sk_` prefix | Platform-wide | Server-to-server integrations (provisioning, analytics) | | **Session tokens** | JWT | Single account | Embedded UI components (call logs, voicemails) | | **User tokens** | JWT | Single user | Softphones and user-facing apps (WebRTC, call history) | ## API Keys Your platform has two types of API key, each used in a different place: | Key | Format | Where it's used | | --------------- | ------------ | -------------------------------------------------------------------------------------------------------------- | | **Secret** | `sk_` prefix | Server-side requests. Grants full access to your platform — keep it confidential. | | **Publishable** | `pk_` prefix | Client-side code. Safe to ship in a browser or mobile app; used to initialize the [SDK](/sdks/authentication). | Your secret key is used for all server-side API requests: ```bash curl https://api.dialstack.ai/v1/accounts \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" ``` The same request works against your sandbox — just swap in your [sandbox key](#sandbox-vs-live-keys) (`sk_test_*`). :::danger[Keep It Secret] Never expose your secret key in client-side code, public repositories, or browser applications. Store it securely in environment variables on your server. ::: ### Sandbox vs live keys Every key — secret and publishable alike — comes in two variants: | Variant | Format | Acts on | | ----------- | ------------------------- | ---------------- | | **Live** | `sk_live_*` / `pk_live_*` | Live accounts | | **Sandbox** | `sk_test_*` / `pk_test_*` | Sandbox accounts | The variant you authenticate with decides which environment your request acts on, and the two are completely isolated: - A **live** key only sees [live accounts](/admin-guide/platform-admin/manage-accounts#live-vs-sandbox-accounts). Calls place on the real telephone network, ring real phones, and are billable. - A **sandbox** key only sees [sandbox accounts](/admin-guide/platform-admin/manage-accounts#live-vs-sandbox-accounts). Nothing touches the real world — no real carriers, no ringing phones, no charges — so you can build and run integrations safely. Live accounts are invisible to a sandbox key, and sandbox accounts are invisible to a live key. There is no switch on the request itself: the environment is determined entirely by the key prefix, so you switch by changing which key you send. The same rule extends to [webhook endpoints](/guides/webhook-events#sandbox-vs-live-keys), which are scoped to the environment of the key that created them. :::tip[Best practice] Use **sandbox** keys in development and CI, and reserve **live** keys for production. Never commit either key to version control, and never expose a secret key (`sk_`) in client-side code. ::: ### Account Context Most endpoints require an account context. Include the `DialStack-Account` header: ```bash curl https://api.dialstack.ai/v1/users \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` ### Getting Your API Keys API keys are provided during platform onboarding. Contact support@dialstack.ai if you need access. ## Session Tokens For embedded voice components in your frontend, create a session using the Account Session API. Sessions are account-scoped and expire after 1 hour. ```javascript import { DialStack } from '@dialstack/sdk-server'; const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); const session = await dialstack.accountSessions.create({ account: 'acct_01h2xcejqtf2nbrexx3vqjhp41', components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); // Pass session.client_secret to your frontend ``` The account is automatically derived from the JWT claims — no `DialStack-Account` header needed. ## User Tokens For client-side applications where end users interact directly (softphones, call history, voicemail). User tokens are scoped to a single user within an account. :::tip[When to use which] Use **API keys** when your backend is making requests on behalf of your platform. Use **session tokens** for embedded UI components. Use **user tokens** when the end user's device connects to DialStack directly (WebRTC softphone, mobile app). ::: ### How User Tokens Work ``` ┌──────────────┐ ┌──────────────────┐ ┌───────────────┐ │ Your App │ │ Your Backend │ │ DialStack │ │ (browser / │ │ │ │ │ │ mobile) │ │ │ │ │ └──────┬───────┘ └────────┬─────────┘ └───────┬───────┘ │ 1. User logs in │ │ │─────────────────────>│ │ │ │ 2. POST /v1/user_sessions │ │──────────────────────>│ │ │ │ │ │ 3. { user_token } │ │ │<──────────────────────│ │ 4. Return token │ │ │<─────────────────────│ │ │ │ │ │ 5. Connect to /v1/webrtc with token │ │─────────────────────────────────────────────>│ ``` 1. The user logs into **your** application using your own authentication 2. Your backend calls `POST /v1/user_sessions` with the user's identity 3. DialStack returns a user token (JWT) 4. Your backend passes the token to the client 5. The client uses the token to connect to the WebRTC signalling channel and access user-scoped REST endpoints ### Platform Setup :::info[Token exchange is coming soon] The `POST /v1/auth/token` token-exchange flow described below is documented ahead of release so you can review the design before it ships — it is not implemented yet. Today, mint user tokens with [`POST /v1/user_sessions`](/api#tag/Sessions/operation/createUserSession) using your secret API key — see [Obtaining a User Token](#obtaining-a-user-token). If token exchange would (or wouldn't) fit your integration, we'd love to hear about it at [api@dialstack.ai](mailto:api@dialstack.ai). ::: Configure token exchange for your platform during onboarding. This lets your backend exchange your own JWTs for DialStack user tokens — your users authenticate once with your app and get seamless access to DialStack calling with no additional login prompt. Configuration requires: - **JWKS URL** — where DialStack fetches your public keys to verify your JWTs - **Issuer** — the `iss` claim value in your JWTs - **Audience** — the `aud` claim value DialStack expects (typically `dialstack`) - **User ID claim** — which JWT claim maps to the DialStack user (typically `sub`) Contact your DialStack account team to configure these settings. ### Mapping Users Set the `external_id` field when creating users to match the user's identifier in your system: ```javascript const user = await dialstack.users.create( { name: 'Jane Doe', email: 'jane@example.com', external_id: 'your-system-user-id-123', }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### Obtaining a User Token ```javascript const { client_secret, expires_at, user, account } = await dialstack.userSessions.create({ user: 'user_01h2xcejqtf2nbrexx3vqjhp42', ttl_seconds: 3600, // optional; defaults to 86400 (24 hours), max 604800 (7 days) }); ``` ```bash curl -X POST https://api.dialstack.ai/v1/user_sessions \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "user": "user_01h2xcejqtf2nbrexx3vqjhp42", "ttl_seconds": 3600 }' ``` Response: ```json { "user": "user_01h2xcejqtf2nbrexx3vqjhp42", "account": "acct_01h2xcejqtf2nbrexx3vqjhp41", "client_secret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": "2026-04-10T22:00:00Z" } ``` Use `client_secret` as the Bearer token in the client. ### Token Lifecycle User tokens expire after the TTL you request at mint time — **24 hours** by default, 7 days at most. Mint a new session proactively (e.g., 5 minutes before expiry) to avoid interrupting active WebRTC sessions; the WebRTC SDK's `onTokenExpiring` callback is the hook for this. Revoke a user's outstanding tokens when they log out or are offboarded: ```bash curl -X POST https://api.dialstack.ai/v1/users/user_01h2xcejqtf2nbrexx3vqjhp42/revoke_sessions \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" ``` This invalidates every token minted for the user before that instant and tears down any active WebRTC session using one. ### Scoped Access User tokens grant access to a limited set of endpoints: | Endpoint | Method | Description | Availability | | -------------------------------- | ----------------- | ----------------------------------- | ------------ | | `/v1/webrtc` | WebSocket | Signalling channel for WebRTC calls | Available | | `/v1/webrtc/ice-servers` | GET | TURN/STUN server credentials | Available | | `/v1/me/emergency-addresses` | GET, POST, DELETE | E911 emergency addresses | Available | | `/v1/me` | GET | Authenticated user's profile | Coming soon | | `/v1/me/calls` | GET | User's own call history | Coming soon | | `/v1/voicemails` | GET | User's voicemails | Coming soon | | `/v1/voicemails/{id}` | GET, POST, DELETE | Single voicemail | Coming soon | | `/v1/voicemails/{id}/transcript` | GET | Voicemail transcript | Coming soon | | `/v1/me/presence` | GET, PUT | User's presence status | Coming soon | The **Coming soon** endpoints are documented ahead of release so you can review their design — they are not callable yet. If they would (or wouldn't) fit your use case as designed, tell us at [api@dialstack.ai](mailto:api@dialstack.ai). User tokens cannot access platform-level endpoints (accounts, phone numbers, dial plans, etc.). Those require API keys. Session tokens and user tokens are both JWTs but **not interchangeable** — using the wrong token type is rejected with `401 Unauthorized`. ## Error Responses ### 401 Unauthorized ```json { "error": "Invalid API key", "code": "authentication_failed" } ``` **Common causes:** - Missing `Authorization` header - Invalid or expired API key / token - Wrong key format ### 403 Forbidden ```json { "error": "You don't have permission to access this resource", "code": "forbidden" } ``` **Common causes:** - Trying to access another platform's resources - Using a user token on a platform-level endpoint - Using a session token on a user-scoped endpoint ## Best Practices - Store API keys in environment variables, never in client-side code - Use secrets management tools (AWS Secrets Manager, HashiCorp Vault) - Refresh user tokens proactively before expiry - Revoke user tokens on logout ## Next Steps - [Quickstart Guide](/guides/quickstart) — Build your first integration - [WebRTC Guide](/webrtc) — Build a softphone with user tokens - [API Reference](/api) — Full API documentation --- ## Call Queues import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Call Queues Hold incoming callers and dispatch them to agents according to a configurable strategy. ## Overview A call queue answers the caller, places them on hold music, and rings agents (queue members) one by one — or all at once — until somebody picks up. Unlike a ring group, the queue persists callers between dispatch attempts: when no agent is available, the caller keeps waiting instead of the call ending. Use cases: - **Support desk** — distribute incoming tickets across a team without losing callers to busy signals. - **Sales overflow** — let a primary salesperson take the first crack via a ring group, then overflow into a queue if nobody answers. - **After-hours fallback** — small queue with a short timeout into voicemail. ``` ┌─────────────┐ │ Incoming │ │ Call │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ Queue │ │ "Support" │ │ (on hold) │ └──────┬──────┘ │ strategy: ringall, linear, ... ▼ ┌────────────┼────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Alice │ │ Bob │ │ Carol │ │ (agent) │ │ (agent) │ │ (agent) │ └─────────┘ └─────────┘ └─────────┘ ``` ## Queues vs ring groups | | Ring group | Call queue | | ------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Members | Users **or** phone numbers | Users only | | Behavior | All members ring in parallel; first to answer wins | Caller waits on hold; agents dispatched per strategy | | Caller experience while waiting | Ringback tone | Music on hold | | If no member is available | Call ends after `timeout_seconds` | Caller keeps waiting (subject to `max_queue_length`) | | Strategies | One (parallel) | Seven (`ringall`, `linear`, `rrmemory`, `leastrecent`, `fewestcalls`, `random`, `wrandom`) | | Per-agent state | None | Logged-in / paused / wrap-up tracked per user | Ring groups are the right choice when every call needs to ring _everyone, every time_. Queues are the right choice when callers need to wait for an agent, agents need to manage their own availability, or you want fairness across many incoming calls. ## Creating a queue ```javascript const queue = await dialstack.queues.create( { name: 'Support', strategy: 'ringall', timeout_seconds: 120, wrap_up_seconds: 15, max_queue_length: 50, timeout: { type: 'voicemail', voicemail: 'svm_01h2xcejqtf2nbrexx3vqjhp60', }, }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); console.log(queue.id); // qu_01h2xcejqtf2nbrexx3vqjhp61 ``` ```bash curl -X POST https://api.dialstack.ai/v1/queues \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "name": "Support", "strategy": "ringall", "timeout_seconds": 120, "wrap_up_seconds": 15, "max_queue_length": 50, "timeout": { "type": "voicemail", "voicemail": "svm_01h2xcejqtf2nbrexx3vqjhp60" } }' ``` ## Dispatch strategies `strategy` controls how the queue picks the next agent to ring. | Strategy | Behavior | When to use | | ------------- | ----------------------------------------------------------------- | --------------------------------------------------------------- | | `ringall` | Ring every available agent in parallel; first to answer wins. | Small teams where coverage matters more than load balancing. | | `linear` | Ring agents in `position` order (lowest first). | Tiered escalation — try Tier 1, then Tier 2, then Tier 3. | | `rrmemory` | Round-robin, resuming after the last agent dialed. | Even distribution across a steady-state team. | | `leastrecent` | Ring the agent whose last call ended the longest ago. | Fairness when call volume is uneven. | | `fewestcalls` | Ring the agent with the fewest calls taken in the current window. | Load balancing across a workday. | | `random` | Pick uniformly at random from available agents. | Simple even spread without per-agent state. | | `wrandom` | Weighted random; lower `penalty` is more likely to be picked. | Mix of senior and junior agents where seniors should take more. | `wrap_up_seconds` (0–600) gives an agent a cool-down after each call ends, during which they aren't eligible for dispatch. `leastrecent` and `fewestcalls` use the same per-user state to break ties. ## Managing members Queue members are users — each member references a `user_id`. A user can belong to multiple queues. Penalty and position are strategy hints (see [Dispatch strategies](#dispatch-strategies)). ### Add a member ```javascript await dialstack.queues.addMember( 'qu_01h2xcejqtf2nbrexx3vqjhp61', { user_id: 'user_01h2xcejqtf2nbrexx3vqjhp42' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/queues/qu_01h2xcejqtf2nbrexx3vqjhp61/members \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42"}' ``` Adding the same user twice returns `409 Conflict`. ### Add a member with explicit penalty and position ```javascript await dialstack.queues.addMember( 'qu_01h2xcejqtf2nbrexx3vqjhp61', { user_id: 'user_01h2xcejqtf2nbrexx3vqjhp43', penalty: 2, position: 3, }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### List members The first 10 members are embedded inline on the queue resource under `members`. Use the `next_page_url` (or call the standalone endpoint directly) to page through the rest: ```bash curl https://api.dialstack.ai/v1/queues/qu_01h2xcejqtf2nbrexx3vqjhp61/members \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` ### Remove a member ```bash curl -X DELETE \ https://api.dialstack.ai/v1/queues/qu_01h2xcejqtf2nbrexx3vqjhp61/members/qum_01h2xcejqtf2nbrexx3vqjhp62 \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` ## Agent state Each user that's a member of any queue has an availability state: - **`available`** — eligible for dispatch. - **`paused`** — logged in but temporarily skipped (break, training, focus time). Optional `reason` string for the pause. - **`logged_out`** — not eligible for dispatch on any queue. State is maintained per user (not per queue), so an agent in three queues toggles availability for all three at once. ### Read or update agent state ```javascript // Mark available await dialstack.users.updateQueueAgent( 'user_01h2xcejqtf2nbrexx3vqjhp42', { status: 'available' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); // Pause with a reason await dialstack.users.updateQueueAgent( 'user_01h2xcejqtf2nbrexx3vqjhp42', { status: 'paused', reason: 'break' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); // Log out await dialstack.users.updateQueueAgent( 'user_01h2xcejqtf2nbrexx3vqjhp42', { status: 'logged_out' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); // Read current state const agent = await dialstack.users.retrieveQueueAgent('user_01h2xcejqtf2nbrexx3vqjhp42', { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41', }); ``` ```bash curl -X POST https://api.dialstack.ai/v1/users/user_01h2xcejqtf2nbrexx3vqjhp42/queue-agent \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{"status": "paused", "reason": "break"}' ``` `status` is derived from the underlying timestamps: - `paused_at` set → `paused` - `logged_in_at` unset → `logged_out` - otherwise → `available` Toggling between `available` and `paused` preserves `logged_in_at`. Logging out clears `paused_at`, `pause_reason`, and `logged_in_at`, but the row itself is retained so historical attributes survive a logout/login cycle. ## Star codes Agents can manage their own state from any registered endpoint: | Code | Action | | ----- | ----------------------- | | `*45` | Toggle login / logout. | | `*46` | Toggle pause / unpause. | Dial the code on its own — there is no queue number to append. Agent state is stored per user, not per queue, so one `*45` logs the agent out of **every** queue they belong to, and the next `*45` logs them back in to all of them. There is no way to log out of one queue while staying logged in to another. Each code is a toggle with no separate "log in" and "log out" code, so an agent who is unsure of their current state can dial `*45` and listen to the confirmation, which announces the state they are now in. Each star code plays a short audio confirmation and ends the call. The state change is applied to the user account associated with the dialing endpoint, so an agent's desk phone and softphone share one state — logging out on either logs the user out everywhere. Logging out also clears any pause, so an agent who was paused before logging out comes back available rather than still paused. ## Routing into a queue There are three ways to send callers into a queue. ### 1. Assign an extension number Create an extension whose `extension_type` is `queue` and whose `queue_id` points at the queue. Internal users dial the extension number to reach the queue. ```javascript await dialstack.extensions.create( { extension_number: '500', extension_type: 'queue', queue_id: 'qu_01h2xcejqtf2nbrexx3vqjhp61', }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### 2. Point a phone number's dial plan at the queue In the dial plan editor, set the destination of the relevant rule (or the default rule) to the queue. Inbound calls to that number land in the queue once any earlier rules (schedules, IVR, etc.) finish. ### 3. Overflow from a ring group Set a ring group's `timeout_action` to `queue` and `timeout_target` to the queue ID. When the ring group times out, the caller is moved into the queue rather than the call ending. ```javascript await dialstack.ringGroups.update( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { timeout_action: 'queue', timeout_target: 'qu_01h2xcejqtf2nbrexx3vqjhp61', }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ## Timeout behavior Two limits govern how long a caller stays in the queue. ### `timeout_seconds` How long any individual caller waits for an agent to answer before the queue's `timeout` action fires. Range `0` to `3600` seconds (one hour), defaulting to `300`. `0` means the 3600 maximum. A ring already in progress when the wait is up is allowed to finish, so a caller an agent is being connected to is never cut off; the `timeout` action runs when that ring ends instead. When the timeout elapses, the configured action runs: - `ring_user` — ring a fallback user. `timeout.user` is the user ID. - `voicemail` — send the caller to a user's voicemail or a shared voicemail box. `timeout.voicemail` accepts the ID of either a user or a shared voicemail box. - `queue` — overflow the caller into another queue, for tiered escalation. `timeout.queue` is the queue ID. A target that would close a routing loop, directly or transitively, is rejected when you save. To change the timeout configuration, update the queue: ```javascript await dialstack.queues.update( 'qu_01h2xcejqtf2nbrexx3vqjhp61', { timeout_seconds: 180, timeout: { type: 'voicemail', voicemail: 'svm_01h2xcejqtf2nbrexx3vqjhp60' }, }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` To clear the timeout configuration entirely, send `timeout: null`: ```javascript await dialstack.queues.update( 'qu_01h2xcejqtf2nbrexx3vqjhp61', { timeout: null }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### `max_queue_length` How many callers can wait at the same time. `0` means unlimited. When the limit is reached: - **External callers** hear a short "queue full" announcement and the call ends. - **Internal callers** are treated as busy, so the calling phone can fall back through its dial plan as if the queue were unreachable. `max_queue_length` is a hard rejection at queue entry — it is not the same as `timeout_seconds`. The `timeout` action only applies to callers who _waited_ past `timeout_seconds`, not to callers who were rejected because the queue was full. --- ## Outbound Caller ID Which number the far end sees when a call leaves the platform, and what your integration receives on the call events. ## How caller ID is decided There are three cases: | Situation | Caller ID presented | | -------------------------------------------------- | ----------------------------------- | | A user places a call | The user's resolved outbound number | | A user transfers a call themselves | The user's resolved outbound number | | An inbound call is forwarded to an external number | The original caller | The first two rows are one case underneath: a transfer resolves exactly as if the transferring user had placed the call, so both land on the same number. Forwarding is the case that differs. A call that arrives on one of the account's numbers and is then sent on to an external destination keeps the original caller, rather than presenting one of the account's own numbers. The catch is that a user transferring that inbound call is **not** forwarding it. That falls under [Calls a user transfers](#calls-a-user-transfers) instead, so the external phone shows the transferring user's number and the original caller never reaches it. Each case decides `from_number` as well as what rings. A forwarded call reports the original caller for its whole life, so an integration matching on `from_number` sees a customer's number rather than one of the account's own. In the first two cases `from_number` is the resolved outbound number. ## Calls a user places There is no external party to represent, so the call presents one of the account's own numbers, resolved in two steps: 1. Take the user's outbound caller ID override if one is set, otherwise the account's default outbound number. 2. Use that number only if it is active with outbound calling enabled. If it is not, or if neither is set, fall back to another active number on the account with outbound calling enabled. Note that step 2 does not retry step 1's other candidate. A user override that is inactive or not outbound-enabled falls through to another active number rather than to the account default, so the call can present a number you did not expect. The caller ID name is that number's configured caller ID name, falling back to the number itself when no name is set. This covers calls dialed from a device and calls started through [click-to-call](./click-to-call.md), which resolve identically. ## Calls a user transfers A transfer a user performs themselves resolves the same way as [a call the user places](#calls-a-user-places), landing on the same number, and never carries the original caller. This holds from any device — a desk phone, a softphone, or the web and SDK clients — and for both kinds of transfer. The reason differs between the two. **Warm (attended).** The user calls the target and speaks to them before completing the transfer. That consultation is an ordinary outbound call, so it presented the user's number while the target's phone was ringing, before any transfer existed. Completing the transfer merges the two existing calls rather than placing a new one. The consultation call is merged rather than ended, so it emits no `call.end`; its terminating event is a `call.transfer` linking the two call ids. **Blind.** A new outbound call is created at the moment of transfer and resolved against the transferring user, so `from_number` is that user's outbound number and the original caller is absent from the leg. :::note Blind transfer is the one case that is otherwise equivalent to forwarding but does not present the original caller. To reach an external destination showing the original caller, route the call with a dial plan, Find Me / Follow Me, or a ring group member rather than transferring it by hand. ::: ## Inbound calls forwarded to an external number A call that arrived on a number the account owns presents the **original caller**, not the number it arrived on. Someone calling from `+14155550101` into a main line that forwards to a mobile makes that mobile ring as `+14155550101`. The arrival number still travels with the call as the number that forwarded it, so the carrier attributes the outbound leg to it and the call stays traceable to the number it came in on. The caller's name carries across the same way, including the number's [caller ID prefix](/admin-guide/account-admin/phone-numbers#inbound) if one is configured, so a call arriving on a number prefixed `[Acme]` reaches the destination as `[Acme] John Smith`. Whether a name is displayed at all is up to the receiving carrier. This covers every path that forwards an inbound call onward: - A dial plan's `external_dial` node - Find Me / Follow Me, where a user's step is an external number - A ring group with an external number as a member - Ring All Users, where a user's Find Me / Follow Me reaches an external number - A call queue reaching an agent at an external number - A transfer issued through the Call Control API Ring groups, Ring All Users and call queues expand only the **first step** of a user's Find Me / Follow Me. An external number in a later step is reached only when a call goes to that user directly. A call qualifies only if it arrived on a number the account owns. An internal caller (an extension, or a WebRTC user) never qualifies, so a call that starts internally and is routed outward always presents an account number. One exception to the resolution order: when an internal call reaches a dial plan's `external_dial` node, it presents an active number with outbound calling enabled directly, consulting neither the user override nor the account default. ### Transfers through the Call Control API The transfer verb is the one transfer that forwards. When the call arrived on one of the account's numbers and carried a caller number, it behaves as above, presenting the original caller — unlike a transfer [a user performs](#calls-a-user-transfers). This path is stricter than the other forwarding paths above: an inbound call with no caller number falls back to the account number rather than forwarding. Otherwise it resolves as [a call a user places](#calls-a-user-places) does, but without the user override: the **account's default outbound number**, falling back to another active number with outbound calling enabled. Two differences from a call a user places: - The user's own override is never consulted, even when one is set. - If neither resolves, the transfer is **refused** rather than connected. A call is never sent out without an attributable number. The dial verb originates a fresh call rather than redirecting an existing one, so it always presents the account number and never forwards the inbound caller. ## Call forwarding set on a phone Forwarding configured on a desk phone is performed by the phone itself, not the platform, so it does not go through the resolution described here. A ring group can suppress it with `ignore_forwarding`, which treats a redirecting member as busy. See [Ring Groups](./ring-groups.md#ignore-forwarding). ## Related - [Webhook Events](./webhook-events.md) - [Dial Plans](./dial-plans.md) - [Ring Groups](./ring-groups.md) - [Call Queues](./call-queues.md) --- ## Click-to-Call Click-to-call enables your application to initiate phone calls on behalf of users. When triggered, the system first calls the user's registered devices, then connects them to the destination number. ## Overview Click-to-call allows you to: - **Add "Call" buttons** next to customer phone numbers in your application - **Enable one-click dialing** from CRM records, support tickets, or contact lists - **Track call activity** through the call logs API ## How It Works When you initiate a click-to-call: 1. **First leg**: All of the user's registered devices (desk phones, softphones) ring simultaneously 2. **Answer**: The user picks up any of their devices 3. **Second leg**: The system dials the destination number 4. **Bridge**: Both calls are connected together This "call-first" approach ensures the user is ready before the customer's phone rings, providing a professional experience. ## Initiating a Call ### Using the API Make a POST request to `/v1/calls` with the user ID and dial string: ```bash curl -X POST https://api.dialstack.ai/v1/calls \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "dial_string": "+15551234567" }' ``` The API returns `202 Accepted` immediately. The call is processed asynchronously. ### Using the SDK ```javascript import { loadDialStack } from '@dialstack/sdk-js'; const dialstack = await loadDialStack('pk_test_...', { fetchClientSecret: async () => { const response = await fetch('/api/dialstack/session'); return response.json(); }, }); // Initiate a call await dialstack.calls.create({ userId: 'user_01h2xcejqtf2nbrexx3vqjhp42', dialString: '+15551234567', }); ``` ## Request Parameters | Field | Type | Required | Description | | ------------- | ------ | -------- | -------------------------------------- | | `user_id` | string | Yes | The user whose devices will ring first | | `dial_string` | string | Yes | The number to dial (see formats below) | ### Dial String Formats The `dial_string` parameter accepts several formats: | Format | Example | Description | | --------- | -------------- | ---------------------------------- | | E.164 | `+15551234567` | International format (recommended) | | Local | `5551234567` | Normalized based on account region | | Extension | `1001` | Internal extension dialing | | Emergency | `911` | Emergency services | ## Server-Side Implementation Here's how to implement a click-to-call endpoint in your backend: ### Node.js / Express ```javascript import express from 'express'; const app = express(); app.use(express.json()); // Your platform's API key const DIALSTACK_API_KEY = process.env.DIALSTACK_API_KEY; app.post('/api/call', async (req, res) => { const { userId, phoneNumber, accountId } = req.body; try { const response = await fetch('https://api.dialstack.ai/v1/calls', { method: 'POST', headers: { Authorization: `Bearer ${DIALSTACK_API_KEY}`, 'DialStack-Account': accountId, 'Content-Type': 'application/json', }, body: JSON.stringify({ user_id: userId, dial_string: phoneNumber, }), }); if (response.status === 202) { res.json({ success: true, message: 'Call initiated' }); } else { const error = await response.json(); res.status(response.status).json(error); } } catch (error) { res.status(500).json({ error: 'Failed to initiate call' }); } }); ``` ### Python / Flask ```python from flask import Flask, request, jsonify import requests import os app = Flask(__name__) DIALSTACK_API_KEY = os.environ.get('DIALSTACK_API_KEY') @app.route('/api/call', methods=['POST']) def initiate_call(): data = request.json response = requests.post( 'https://api.dialstack.ai/v1/calls', headers={ 'Authorization': f'Bearer {DIALSTACK_API_KEY}', 'DialStack-Account': data['account_id'], 'Content-Type': 'application/json' }, json={ 'user_id': data['user_id'], 'dial_string': data['phone_number'] } ) if response.status_code == 202: return jsonify({'success': True, 'message': 'Call initiated'}) else: return jsonify(response.json()), response.status_code ``` ## Frontend Integration Add click-to-call buttons to your UI: ```jsx function CustomerCard({ customer, currentUser, accountId }) { const [calling, setCalling] = useState(false); async function handleCall() { setCalling(true); try { const response = await fetch('/api/call', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: currentUser.dialstackUserId, phoneNumber: customer.phone, accountId: accountId, }), }); if (response.ok) { // Show "Calling..." indicator showNotification('Calling ' + customer.name); } } finally { setCalling(false); } } return (

{customer.name}

{customer.phone}

); } ``` ## Error Handling | Status | Error | Description | | ------ | ------------------------- | ---------------------------------- | | 400 | `user_id is required` | Missing user_id in request | | 400 | `dial_string is required` | Missing dial_string in request | | 400 | `Invalid user_id format` | user_id is not a valid TypeID | | 401 | `Unauthorized` | Invalid or missing API key/session | | 404 | `User not found` | User doesn't exist in the account | ## Best Practices ### DO - Validate phone numbers before sending to the API - Show a loading state while the call is being initiated - Use E.164 format for phone numbers when possible - Log call attempts for troubleshooting ### DON'T - Don't expose your platform API key in client-side code - Don't allow arbitrary dial strings from user input without validation - Don't retry failed calls automatically (let the user decide) ## Monitoring Calls After initiating a call, you can track its progress: - Use the [Real-Time Events](./events.md) API to receive call status updates - Query the [Call Logs](/api#tag/Calls/operation/listCallLogs) API to retrieve call history ## Related Resources - [API Reference](/api#tag/Calls/operation/createCall) - Complete API documentation - [Real-Time Events](./events.md) - Receive call status updates - [Authentication](/guides/authentication) - Authentication guide --- ## Device Provisioning Manage physical phones through the API — add devices by MAC address, assign users, customize settings, and monitor provisioning status. ## Device Types Two categories of devices can be provisioned: - **Deskphones** — standalone desk phones with programmable keys, assigned directly to users - **DECT systems** — a base station with paired wireless handsets, where each handset is assigned to a user Both types follow the same provisioning flow: add the device, assign users, and the phone retrieves its configuration from the provisioning server. :::info[Billing is per user, not per device] The typical deployment is 1:1 user-to-device, but you can assign multiple users to a single device (shared reception phone, multi-line executive desk setup, etc.) or the same user to multiple devices. DialStack bills for each **user** provisioned on the system, regardless of how users and devices are paired. ::: ## How Provisioning Works 1. **Add the device** — create a device via the API with its MAC address and type. The vendor is automatically detected from the MAC. 2. **Assign users** — assign users to the device so it can make and receive calls. 3. **Device fetches config** — when the phone powers on (or is triggered via check-sync), it contacts the provisioning server and receives its configuration. 4. **Status updates** — the device status transitions from `pending-sync` to `provisioned` after its first successful configuration fetch. ### Settings Inheritance Device configuration follows a layered inheritance model: ``` Global defaults → Platform config → Account config → Device overrides ``` Each layer can override settings from the layer above. Omitted fields inherit from the parent, so you only need to specify what you want to change. ## Prerequisites - An API key with account access - The MAC address of the physical device - At least one user to assign to the device ## Provisioning a Deskphone ### Step 1: Create the deskphone Register the device with its MAC address: ```bash curl -X POST https://api.dialstack.ai/v1/devices \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" \ -H "Content-Type: application/json" \ -d '{ "type": "deskphone", "mac_address": "00:04:13:aa:bb:cc" }' ``` ```json { "id": "YOUR_DEVICE_ID", "type": "deskphone" } ``` The vendor is detected automatically based on the MAC address prefix (OUI). The model is detected when the device first contacts the provisioning server. Use `GET /v1/devices/YOUR_DEVICE_ID` to retrieve the full device details. ### Step 2: Assign a user ```bash curl -X POST https://api.dialstack.ai/v1/devices/YOUR_DEVICE_ID/users \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" \ -H "Content-Type: application/json" \ -d '{ "user_id": "YOUR_USER_ID" }' ``` ```json { "user_id": "YOUR_USER_ID", "device_id": "YOUR_DEVICE_ID", "created_at": "2025-10-18T10:01:00Z" } ``` Each user assignment consumes one **line appearance** on the phone — an individually-registered SIP account, shown as a distinct "line" on the phone's display. DialStack caps deskphones at 24 line appearances, but the practical limit is what the phone model itself supports (ranges from 2 lines on entry-level handsets to 16+ on high-end executive phones). Assignments beyond the phone's hardware capacity will be accepted by the API but not all will appear on the device. Consult the phone vendor's datasheet for the supported line count. ### Step 3: Trigger configuration reload If the phone is already powered on, send a check-sync to have it reload its configuration: ```bash curl -X POST https://api.dialstack.ai/v1/devices/YOUR_DEVICE_ID/status/check-sync \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" ``` ```json { "success": true, "lines_notified": 1, "lines": [ { "line_number": 1, "status": "delivered" } ] } ``` A `200` response means at least one line accepted the request. Each entry in `lines` carries a 1-indexed `line_number` and a `status` — one of `delivered`, `not_registered`, `unreachable`, or `error`. :::caution[What check-sync does (and doesn't do)] Trigger check-sync when you actually change configuration, not on every admin-UI interaction. A `200 OK` only means the phone accepted the request — what happens next depends on the phone, the firmware, and sometimes the call state at that exact moment. With `reboot: false` (the default), the typical behavior is: - The phone reloads configuration in place and stays registered, with a brief (usually under 5 seconds) re-registration if the SIP server or credentials changed. - Some phones **defer** applying new configuration while a call is in progress, so the change may not take effect until the call ends. - Some settings (firmware, certain network parameters) still require a reboot, which the phone performs on its next idle window. - For DECT, the request reaches the base no matter which ID you target (base or handset), and every handset paired with the base reloads. **`reboot: false` is not a guarantee that the phone keeps its calls.** Some models — particularly certain DECT bases — reboot on every `check-sync` regardless of the flag, drop active calls, and take 30–90 seconds to come back. Treat any check-sync as potentially disruptive, and only send it when configuration actually changed. With `reboot: true`: - The phone **reboots immediately**, dropping any active call. - Roughly 30–90 seconds offline. - Only use for recovery or for changes that explicitly require a reboot. ::: If the device could not be reached, the API returns `409 Conflict` with a `reason` field: - `not_registered` — the device has no active registration. It may be powered off, unable to reach the SIP server, or has never registered. - `unreachable` — the device didn't respond in time. It is likely offline or has lost its connection. ### Step 4: Verify provisioning ```bash curl https://api.dialstack.ai/v1/devices/YOUR_DEVICE_ID \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" ``` After the device fetches its configuration, `status` changes to `provisioned` and `last_provisioned_at` is populated. ## Provisioning a DECT System DECT systems have a two-level hierarchy: **base station** → **handsets**. Each handset is then assigned to one or more users. ### Step 1: Create the DECT base ```bash curl -X POST https://api.dialstack.ai/v1/devices \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" \ -H "Content-Type: application/json" \ -d '{ "type": "dect_base", "mac_address": "00:04:13:bb:cc:dd" }' ``` ```json { "id": "YOUR_BASE_ID", "type": "dect_base" } ``` ### Step 2: Add a handset Each handset is identified by its IPEI (a unique hardware identifier for wireless handsets, formatted as a hexadecimal string). Handsets are created as devices linked to their parent base. ```bash curl -X POST https://api.dialstack.ai/v1/devices \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" \ -H "Content-Type: application/json" \ -d '{ "type": "dect_handset", "base_id": "YOUR_BASE_ID", "ipei": "0328A0000F", "display_name": "Front Desk" }' ``` ```json { "id": "YOUR_HANDSET_ID", "type": "dect_handset" } ``` ### Step 3: Assign a user to the handset Users are assigned to individual handsets, not to the base station directly. ```bash curl -X POST https://api.dialstack.ai/v1/devices/YOUR_HANDSET_ID/users \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" \ -H "Content-Type: application/json" \ -d '{ "user_id": "YOUR_USER_ID" }' ``` ```json { "user_id": "YOUR_USER_ID", "device_id": "YOUR_HANDSET_ID", "created_at": "2025-10-18T10:03:00Z" } ``` ## Customizing Device Settings Use the `overrides` field to customize device behavior. Settings use a two-tier structure: - **`abstractions`** — vendor-agnostic settings that are translated to vendor-specific parameters automatically - **`vendor_overrides`** — raw vendor-specific key-value pairs for parameters not covered by abstractions ```bash curl -X POST https://api.dialstack.ai/v1/deskphones/YOUR_DEVICE_ID \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" \ -H "Content-Type: application/json" \ -d '{ "overrides": { "abstractions": { "audio": { "codecs": ["PCMU", "PCMA"], "echo_cancellation": true }, "display": { "time_format": "24h", "backlight_level": "medium" } } } }' ``` :::note The `abstractions` schema is actively expanding as new hardware capabilities are added. Use `vendor_overrides` for vendor-specific parameters not yet available as abstractions. ::: ## Multicell DECT Deployments For larger spaces requiring multiple base stations, use multicell roles to coordinate them: - **`data_master`** — the primary base that distributes configuration to other bases - **`secondary`** — subordinate bases that receive configuration from the data master ```bash curl -X POST https://api.dialstack.ai/v1/devices \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" \ -H "Content-Type: application/json" \ -d '{ "type": "dect_base", "mac_address": "00:04:13:bb:cc:ee", "multicell_role": "secondary" }' ``` ## Managing Devices ### Unified Device View List all devices (deskphones and DECT bases) in a single request: ```bash curl "https://api.dialstack.ai/v1/devices?type=deskphone" \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" ``` The `type` parameter is optional — omit it to list all device types. ### Listing Users on a Device ```bash curl https://api.dialstack.ai/v1/devices/YOUR_DEVICE_ID/users \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" ``` ### Removing a User from a Device ```bash curl -X DELETE https://api.dialstack.ai/v1/devices/YOUR_DEVICE_ID/users/YOUR_USER_ID \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" ``` ### Deleting a Device ```bash curl -X DELETE https://api.dialstack.ai/v1/devices/YOUR_DEVICE_ID \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "DialStack-Account: YOUR_ACCOUNT_ID" ``` Deleting a device removes all associated user assignments. Deleting a DECT base also removes all its handsets. --- ## Dial Plans(Guides) import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Dial Plans Build visual call routing flows with dial plans. ## Overview A dial plan defines how incoming calls are routed through a series of steps, represented as a flowchart. Each step in the flowchart is called a **node**. Nodes check conditions (like business hours) or ring extensions, then route to the next step based on the outcome. Think of it like a flowchart you'd draw on a whiteboard: ``` ┌───────────┐ │ Start │ └─────┬─────┘ │ ▼ ┌─────────────┐ open ┌───────────────┐ │ Check Hours │────────────▶│ Reception │ │ [Schedule] │ │ [InternalDial]│ └──────┬──────┘ └───────┬───────┘ │ │ │ closed timeout │ │ └───────────┬────────────────┘ │ ▼ ┌───────────┐ │ Voicemail │ └───────────┘ ``` In this example: 1. Every call starts at the "Check Hours" schedule node 2. If the schedule is open, the call rings the reception extension 3. If reception doesn't answer (timeout), or if the schedule is closed, the call routes to voicemail ## Node Types ### Schedule Node Routes calls based on whether a [schedule](/api#tag/Schedules) is currently open, closed, or on holiday. **Exits:** - `open` — Schedule is currently within business hours - `closed` — Schedule is outside business hours - `holiday` — Current date falls within a holiday range ### Internal Dial Node Dials a user or nested dial plan and routes based on the outcome. **Configuration:** - `target_id` — ID of the user, ring group, or dial plan to dial - `timeout` — How long to ring before giving up (0-300 seconds, default 30) **Exits:** - `next` — Ring timed out or user was busy (call continues to next node) - _(answered)_ — If answered, the dial plan ends and the call is connected ## Creating a Dial Plan ```javascript const dialPlan = await dialstack.dialPlans.create( { name: 'Main Line Routing', entry_node: 'check_hours', nodes: [ { id: 'check_hours', type: 'schedule', config: { schedule_id: 'sched_01h2xcejqtf2nbrexx3vqjhp50', open: 'reception', closed: 'voicemail', holiday: 'voicemail', }, }, { id: 'reception', type: 'internal_dial', config: { target_id: 'user_01h2xcejqtf2nbrexx3vqjhp45', timeout: 30, next: 'voicemail', }, }, { id: 'voicemail', type: 'internal_dial', config: { target_id: 'user_01h2xcejqtf2nbrexx3vqjhp99', }, }, ], }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/dial_plans \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "name": "Main Line Routing", "entry_node": "check_hours", "nodes": [ { "id": "check_hours", "type": "schedule", "config": { "schedule_id": "sched_01h2xcejqtf2nbrexx3vqjhp50", "open": "reception", "closed": "voicemail", "holiday": "voicemail" } }, { "id": "reception", "type": "internal_dial", "config": { "target_id": "user_01h2xcejqtf2nbrexx3vqjhp45", "timeout": 30, "next": "voicemail" } }, { "id": "voicemail", "type": "internal_dial", "config": { "target_id": "user_01h2xcejqtf2nbrexx3vqjhp99" } } ] }' ``` ## Key Concepts ### Entry Node The `entry_node` specifies which node executes first. Think of it as where the "Start" arrow points in your flowchart. ### Exit Targets Each node type has specific exits that route to other nodes. Set an exit to `null` (or omit it) to terminate the call at that point. ### Node IDs Node IDs are strings you define (like `"check_hours"` or `"reception"`). They must be unique within a dial plan and are used to wire nodes together. ### Visual Position Nodes can include optional `position` coordinates (`x`, `y`) for rendering in a visual editor. If omitted, nodes are auto-positioned. ## Examples ### After-Hours Voicemail Route calls to voicemail when closed: ``` Start → Check Hours [Schedule] → (open) → Ring Group [Internal Dial] → (closed/holiday) → Voicemail ``` ```json { "name": "After-Hours Voicemail", "entry_node": "check_hours", "nodes": [ { "id": "check_hours", "type": "schedule", "config": { "schedule_id": "sched_...", "open": "ring_group", "closed": "voicemail", "holiday": "voicemail" } }, { "id": "ring_group", "type": "internal_dial", "config": { "target_id": "user_...", "next": "voicemail" } }, { "id": "voicemail", "type": "internal_dial", "config": { "target_id": "user_..." } } ] } ``` ### Multi-Level Routing Chain multiple schedule checks: ``` Start → Check Emergency [Schedule] → (open) → Emergency Line [Internal Dial] → (closed/holiday) → Check Hours [Schedule] → (open) → Reception [Internal Dial] → (closed/holiday) → Voicemail ``` ```json { "name": "Multi-Level Routing", "entry_node": "check_emergency", "nodes": [ { "id": "check_emergency", "type": "schedule", "config": { "schedule_id": "sched_emergency_...", "open": "emergency_line", "closed": "check_hours", "holiday": "check_hours" } }, { "id": "emergency_line", "type": "internal_dial", "config": { "target_id": "user_emergency_..." } }, { "id": "check_hours", "type": "schedule", "config": { "schedule_id": "sched_business_...", "open": "reception", "closed": "voicemail", "holiday": "voicemail" } }, { "id": "reception", "type": "internal_dial", "config": { "target_id": "user_reception_...", "next": "voicemail" } }, { "id": "voicemail", "type": "internal_dial", "config": { "target_id": "user_voicemail_..." } } ] } ``` ### Fallback Chain Try multiple users before voicemail: ``` Start → Reception [Internal Dial] → (timeout 30s) → Manager [Internal Dial] → (timeout 20s) → Voicemail ``` ```json { "name": "Fallback Chain", "entry_node": "reception", "nodes": [ { "id": "reception", "type": "internal_dial", "config": { "target_id": "user_reception_...", "timeout": 30, "next": "manager" } }, { "id": "manager", "type": "internal_dial", "config": { "target_id": "user_manager_...", "timeout": 20, "next": "voicemail" } }, { "id": "voicemail", "type": "internal_dial", "config": { "target_id": "user_voicemail_..." } } ] } ``` ## API Reference - [Dial Plans](/api#tag/Dial-Plans) — Create, update, and delete dial plans - [Schedules](/api#tag/Schedules) — Define business hours for schedule nodes --- ## E911 Dispatch Addresses(Guides) When a user dials 911 from a DialStack device, the platform must tell the upstream provider **where the call originates** so emergency services can dispatch to the correct address. This guide explains the object model behind that lookup, how to provision and assign locations through the API, and what happens at call time. US federal law (Kari's Law and RAY BAUM's Act) makes this dispatch obligation strict: the address must be specific enough for first responders to find the caller, and 911 must be reachable without prefixes or unlocking. DialStack handles the SIP-side mechanics; **the account is responsible for keeping each device's location current** as people move desks, branches, or sites. ## Object model The dispatch lookup involves two kinds of resources: - **Location** — a physical site (`name`, structured `address`). Once registered with the upstream provider it gains an `e911_status` and the carrier-side ID that travels with each emergency call. - **Device** — a physical thing that sits at a site: - **Deskphone** — a desktop PoE/WiFi phone. Carries its own `location_id`. - **DECT base** — a box that hosts one or more wireless handsets. Carries its own `location_id`. Every handset paired with the base inherits it, because the base radio defines where the handset can physically reach. - **DECT handset** — a cordless handset paired with a base. Has no `location_id` of its own. Softphone and in-app SDK calls don't follow this model — they have no physical device. They use a per-user emergency-address flow described in [Emergency Calling (E911)](/webrtc/emergency). ```mermaid flowchart LR L[Location
address + carrier registration] D[Deskphone
location_id] B[DECT base
location_id] H[DECT handset
inherits from base] S[Softphone / SDK
no device → no location] H --paired with--> B B --dispatches via--> L D --dispatches via--> L S -. no dispatch address .-> L ``` ## Provisioning a location for E911 Create the location with its street address, then register it for E911. The carrier validates the address and assigns a registration ID; the location is unusable for emergency dispatch until that completes. ```ts const location = await dialstack.locations.create({ name: 'New York HQ', address: { address_number: '350', street: '5th Avenue', unit: 'Suite 4200', city: 'New York', state: 'NY', postal_code: '10118', country: 'US', }, }); await dialstack.locations.provisionE911(location.id); ``` The location moves through a status machine: | `e911_status` | Meaning | | ------------- | ---------------------------------------------------------------------------------------------------------------- | | `none` | No registration attempted yet. | | `pending` | Submitted to the carrier; waiting for a response. | | `binding` | Carrier accepted the address and is finalizing the dispatch record. | | `provisioned` | Ready. Emergency calls from devices assigned here will dispatch to this address. | | `failed` | Carrier rejected the registration (typically a non-deliverable address). Retry from the admin portal or the API. | Only `provisioned` locations are usable at call time. Assigning a device to a location that is still `pending` or `failed` is allowed (so admins can pre-stage during onboarding), but the call-time lookup treats it as unassigned. ## Assigning a device to a location Set `location_id` on the device itself, at create time or later. The unified `/v1/devices` endpoint covers all three device kinds: ```ts // Deskphone — register the MAC and bind it to a location in one call await dialstack.devices.create({ type: 'deskphone', mac_address: '00:04:13:aa:bb:cc', location_id: 'loc_01h…', }); // DECT base — shared by every handset paired with this base await dialstack.devices.create({ type: 'dect_base', mac_address: '00:04:13:bb:cc:dd', location_id: 'loc_01h…', }); // Move a base to a different site (e.g. branch office reshuffle) await dialstack.devices.update(baseId, { location_id: 'loc_01h…' }); // Clear the dispatch location (e.g. device returned to inventory) await dialstack.devices.update(deviceId, { location_id: null }); ``` `location_id` on update is tri-state: omit to leave unchanged, send a `loc_…` ID to set, send explicit JSON `null` to clear. DECT handsets have no `location_id` of their own — to change where a handset dispatches, change its paired base's `location_id`, or re-pair the handset to a base at the target site (`POST /v1/devices/:handset_id` with `base_id`). The Location ID must reference a location on the same account; cross-account assignment is rejected at the API layer. ## Call-time resolution When a user dials 911 (or another configured emergency number), DialStack resolves the dispatch address by walking from the calling user to the device they're assigned to: 1. **DECT handset** → paired DECT base → base's `location_id`. 2. **Deskphone** → deskphone's `location_id`. 3. **Softphone / SDK call** → no device. The call uses the user's per-call registered emergency address instead; see [Emergency Calling (E911)](/webrtc/emergency). For device-backed calls, each candidate is filtered for `e911_status = provisioned`, `status = active`, and a carrier-side registration ID before being chosen. If the base's location isn't dispatchable (still `pending`, deleted, etc.) the lookup falls through to the deskphone candidate; if neither is dispatchable, the call still completes but no dispatch address is sent (the `call.emergency` webhook reports `e911_provisioned: false`). When a dispatchable location is resolved, the carrier-side registration ID is attached to the outbound INVITE so the carrier can route the call to the correct emergency dispatch center — the public safety answering point (PSAP) — with the correct address on file. ## Webhook payload Every emergency call fires a `call.emergency` webhook before the call is bridged to the carrier. The payload includes: ```json { "event": "call.emergency", "data": { "call_id": "call_01h…", "from": "+12125550100", "to": "911", "endpoint_id": "ep_01h…", "user_id": "user_01h…", "location_id": "loc_01h…", "e911_provisioned": true } } ``` `e911_provisioned: false` means the platform completed the call but could not attach a dispatch address — first responders will fall back to whatever default the carrier has on file for the underlying number, which is rarely the actual caller location. Treat this as an actionable signal: alert the admin, the front desk, or whoever is best placed to clear the misconfiguration. When an emergency call is placed via a **transfer**, `e911_provisioned` is omitted from the payload entirely (not `false`). On a transfer the platform deliberately attaches no dispatch address — it cannot know whether the transferring or transferred party's location is correct — and lets emergency dispatch resolve it. The field's absence distinguishes this intentional case from an unprovisioned direct call. This webhook is the recommended hook for any in-building notification you need to layer on top — paging a security desk, flashing a kiosk, sending an SMS to the receptionist. See [Webhook Events](./webhook-events.md) for delivery semantics. ## Failure modes | Situation | What happens at call time | | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Device has no `location_id` | Call completes, `e911_provisioned: false`. No dispatch address sent. | | Assigned location is still `pending` / `binding` / `failed` | Same — treated as unassigned at call time. | | Location was assigned then deleted | `location_id` is cleared automatically (FK on delete). Same outcome as "no location assigned" until the device is reassigned. | | Call placed via a transfer | Call completes, no dispatch address attached by design; `e911_provisioned` is omitted from the webhook (emergency dispatch resolves the location). | The `call.emergency` webhook fires on every direct emergency call regardless of dispatchability, so an `e911_provisioned: false` payload is your signal to clear the misconfiguration after the fact — even if the moment of the call itself was missed. ## Testing in non-production environments Do not place real 911 calls to test. Dial **933** instead — the carrier's non-routable test number that exercises the full dispatch flow (carrier-side address lookup, `Geolocation` header on the INVITE, webhook, audit) and reads the registered address back to the caller, without contacting a real PSAP. In local dev, the emergency dial-plan branch routes 911 to the same test destination so the same verification works end-to-end. ## Related - **Admin portal:** [Locations](/admin-guide/account-admin/locations), [Devices](/admin-guide/account-admin/devices) - **API:** [Webhook Events](./webhook-events.md), [Device Provisioning](./device-provisioning.md) --- ## Error Codes Every non-2xx response from the DialStack REST API shares the same shape: ```json { "error": "Human-readable message describing what went wrong.", "code": "machine_readable_code", "details": { "field_name": "reason" } } ``` `error` is always present. `code` is present on errors that have a stable machine-readable identifier (listed below). `details` is optional and carries field-level validation context when applicable. :::info Branch on `code`, not on `error`. The `error` string may be rephrased without notice; `code` values are stable and versioned. When no `code` is present, fall back to the HTTP status. ::: ## HTTP status codes | Status | Meaning | Retry? | | ------ | ------------------------------------------------------------------ | ------------------------------- | | `400` | Malformed request — invalid JSON, missing required fields. | No — fix the request. | | `401` | Missing or invalid API key (`Authorization: Bearer ...`). | No — rotate / check the key. | | `403` | Valid key, but no access to this account or resource. | No — check `DialStack-Account`. | | `404` | Resource does not exist on this account. | No. | | `409` | Conflict — e.g., duplicate handle, already assigned. | No. | | `422` | Validation error — payload is well-formed but semantically wrong. | No — fix the payload. | | `429` | Rate limit exceeded. `Retry-After` header tells you when to retry. | Yes, after `Retry-After`. | | `5xx` | Server error. Transient unless documented otherwise. | Yes, with exponential backoff. | ## Stable error codes The following `code` values are stable and safe to branch on. Codes not listed here are either legacy or platform-internal; do not rely on them. | Code | HTTP | Meaning | | ------------------------------ | ----- | --------------------------------------------------------------------------- | | `validation_error` | `400` | The request body failed validation. See `details` for field-level reasons. | | `invalid_pagination` | `400` | The pagination cursor is malformed or expired. Restart from the first page. | | `invalid_sandbox_phone_number` | `400` | The supplied phone number is not a valid sandbox number for this platform. | | `sandbox_feature_unsupported` | `422` | The requested feature is not available in sandbox mode for this platform. | ### Phone numbers | Code | HTTP | Meaning | | ------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------- | | `phone_number_limit_exceeded` | `409` | The request would take the account past its phone number limit. Raise the limit and retry. | | `phone_numbers_already_claimed` | `409` | One or more of the requested numbers are already in service and cannot be ordered. The `error` string names them. | | `port_order_invalid_state` | `409` | The port order's current status does not allow the action. | `phone_number_limit_exceeded` is returned when buying numbers, when creating or editing a port order, and when recovering a released number, since each of those grows the account's count. Raising the account's limit — or releasing numbers it no longer needs — clears it. `phone_numbers_already_claimed` names the numbers that are unavailable — in the `error` string for a human, and in a `details` object for your code: ```json { "code": "phone_numbers_already_claimed", "error": "phone number(s) already claimed — already on this account: +17702126011", "details": { "already_on_account": ["+17702126011"], "in_service_elsewhere": [] } } ``` This is the only code that carries `details`, because which of your numbers conflicted is the one thing here you cannot get from any other request. Both keys are always present; an empty group is `[]`. The two are separated because they call for different actions: - **Already on your account.** The number is yours; it does not need to be ordered again. Take it off the request. - **Already in service elsewhere.** The number is live on another account with the same carrier, so there is no port to run — moving it is an intra-carrier transfer, which support arranges. Contact support rather than retrying. A number counts as claimed only while it is live — active or inactive — on any account. A number **you** released is not claimed, so it will not be reported here; request it back with `POST /v1/phone-numbers/{phone_number_id}` and `status: active` instead, which is the one case that can return `phone_number_limit_exceeded` on an update. Note that a conflict confirms the number is in service with this carrier. That is not treated as a disclosure boundary: you supplied the number in the request, so the response tells you nothing about it you did not already know, beyond whether it is available — which is the question you asked. Abuse of this endpoint to enumerate numbers is addressed by rate limiting, not by withholding the answer. `port_order_invalid_state` is returned when the action does not fit the order's current status — editing an order that has moved past the point where changes are accepted, cancelling one in a terminal status, submitting one that is not approved, approving one that is no longer a draft, or uploading a document to an order that is neither a draft nor in exception. The `error` message names both the status and the attempted action, so it reads as `port order is in "submitted" status, cannot update`. The `code` is the whole machine-readable contract on these responses. There is no `details` object — everything one could carry is either already known to the caller (the numbers they sent, the endpoint they called, their own cap) or not theirs to see. Read `error` for a human-readable description. Some conflicts on these endpoints carry no `code`, because there is no action a client can take automatically: a caller ID change already being processed, an account that already has a directory listing on another number, a number that is not in a recoverable state, a recovery the carrier declined, and a disconnect refused inside the 30-day minimum hold. Branch on the status and surface the `error` message for those. ### Device provisioning | Code | HTTP | Meaning | | ----------------------- | ----- | ------------------------------------------------------------ | | `unsupported_vendor` | `400` | The phone vendor/model is not supported for provisioning. | | `invalid_configuration` | `400` | The provisioning request is missing required device fields. | | `credentials_not_found` | `404` | No SIP credentials exist for this endpoint. | | `generation_failed` | `500` | The provisioning file could not be generated. Safe to retry. | ## Handling errors - **Treat missing `code` as an unstable error.** Log the `error` string for debugging, but make a business decision off HTTP status alone. - **Do not parse the `error` string.** It is subject to i18n and rephrasing. - **On `429`, respect `Retry-After`.** Typical values are small (≤ 60s). - **On `5xx`, use exponential backoff.** Start at 1s, double up to ~60s, cap at ~5 attempts. Every DialStack mutation is keyed by an operation ID so retries are safe — idempotency is built into the API. --- ## Real-Time Events DialStack provides real-time event streaming via Server-Sent Events (SSE), enabling your application to receive instant notifications about incoming calls and other account activity. Real-Time Events is the **browser transport** for call events. For the **backend transport**, see [Webhook Events](./webhook-events.md). For common patterns built on top, see [Screen Pop](./screen-pop.md) and [Activity Logging](./activity-logging.md). ## Overview Real-time events allow you to: - **Show screen pops** when calls arrive (caller ID, customer info) - **Update UI instantly** without polling the API - **Build responsive applications** that react to call activity in real-time :::tip[Looking for server-side webhooks?] For backend integrations (CRM sync, analytics, workflow automation), see [Webhook Events](./webhook-events.md) for HTTP POST delivery with signature verification. ::: :::note[The webhook transport uses a different shape] The same call events are also delivered to server-side [Webhook Events](./webhook-events.md), but in a different shape: each webhook is **wrapped in an envelope** (`{ id, type, created_at, account_id, data }`) and uses `type` where the SSE payloads shown here use `event`. Treat the two transports as separate schemas rather than assuming identical field names. ::: ## How It Works DialStack uses Server-Sent Events (SSE), a standard web technology for streaming events from server to client: 1. Your client opens a persistent HTTP connection to `/v1/events` 2. DialStack pushes events to your client as they occur 3. The connection stays open until your client disconnects SSE is supported in all modern browsers and has built-in reconnection handling. ## Connecting to the Event Stream ### Using the DialStack SDK (Recommended) The simplest way to receive events is using the DialStack SDK, which handles authentication and reconnection automatically: ```javascript import { loadDialStack } from '@dialstack/sdk-js'; // Initialize the SDK const dialstack = await loadDialStack('pk_test_...', { fetchClientSecret: async () => { const response = await fetch('/api/dialstack/session'); return response.json(); }, }); // Subscribe to call events dialstack.on('call.incoming', (event) => { console.log('Incoming call from:', event.from_number); showScreenPop(event); }); dialstack.on('call.answered', (event) => { console.log('Call answered:', event.call_id); }); dialstack.on('call.end', (event) => { console.log('Call ended:', event.call_id, event.status); }); // Later, unsubscribe when done dialstack.off('call.incoming'); ``` The SDK uses `fetch` with proper `Authorization` headers internally and handles reconnection with exponential backoff. ### Using fetch with ReadableStream For direct API access without the SDK, use `fetch` with a readable stream: ```javascript async function connectToEvents(clientSecret) { const response = await fetch('https://api.dialstack.ai/v1/events', { headers: { Authorization: `Bearer ${clientSecret}`, Accept: 'text/event-stream', }, }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { value, done } = await reader.read(); if (done) break; const text = decoder.decode(value); const lines = text.split('\n'); for (const line of lines) { if (line.startsWith('event: ')) { const eventType = line.slice(7); // Handle event type } else if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(6)); // Handle event data } } } } ``` ## Event Types ### `connected` Sent immediately when the connection is established. Use this to confirm the stream is working. ```json { "message": "Connected to event stream" } ``` ### `call.incoming` Sent when an incoming call arrives for the account. This is the primary event for implementing screen pops. ```json { "event": "call.incoming", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876" } ``` | Field | Type | Description | | ------------- | -------------- | ------------------------------------------- | | `event` | string | Always `"call.incoming"` | | `account_id` | string | Account receiving the call | | `from_number` | string | Caller's phone number (E.164 format) | | `from_name` | string \| null | Caller's name from caller ID (if available) | | `to_number` | string | Called phone number (E.164 format) | ### `call.initiated` Sent when an outbound call starts dialing. ```json { "event": "call.initiated", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "from_number": "+14155559876", "to_number": "+14155551234", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42" } ``` ### `call.answered` Sent when a call is answered. ```json { "event": "call.answered", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "from_number": "+14155551234", "to_number": "+14155559876", "direction": "inbound", "answered_at": "2026-01-15T14:30:05Z" } ``` ### `call.end` Sent when a call ends. The `status` field indicates the outcome. ```json { "event": "call.end", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "from_number": "+14155551234", "to_number": "+14155559876", "direction": "inbound", "status": "completed", "duration_seconds": 325, "ended_at": "2026-01-15T14:35:30Z" } ``` ### `call.transfer` Sent when a call is transferred. ```json { "event": "call.transfer", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "from_number": "+14155551234", "to_number": "+14155559876", "transferred_to": "1001" } ``` ## Screen Pop Example Here's a complete example of implementing a screen pop when calls arrive using the SDK: ```javascript import { loadDialStack } from '@dialstack/sdk-js'; class ScreenPopManager { constructor() { this.dialstack = null; } async connect() { // Initialize the SDK this.dialstack = await loadDialStack('pk_test_...', { fetchClientSecret: async () => { const response = await fetch('/api/dialstack/session'); return response.json(); }, }); // Subscribe to incoming calls this.dialstack.on('call.incoming', (call) => { this.handleIncomingCall(call); }); } async handleIncomingCall(call) { // Look up customer by phone number const customer = await this.lookupCustomer(call.from_number); // Show the screen pop this.showPopup({ title: call.from_name || 'Unknown Caller', phone: call.from_number, customer: customer, }); } async lookupCustomer(phoneNumber) { // Query your database for customer info const response = await fetch(`/api/customers?phone=${phoneNumber}`); return response.json(); } showPopup(data) { // Display the screen pop UI const popup = document.createElement('div'); popup.className = 'screen-pop'; popup.innerHTML = `

Incoming Call

${data.title}

${data.phone}

${data.customer ? `

Customer: ${data.customer.name}

` : ''} `; document.body.appendChild(popup); } disconnect() { if (this.dialstack) { this.dialstack.logout(); this.dialstack = null; } } } // Usage const screenPop = new ScreenPopManager(); screenPop.connect(); ``` ## Connection Management ### Automatic Reconnection The DialStack SDK automatically handles reconnection with exponential backoff when the connection drops. No additional configuration is needed. ### Session Expiry Account sessions expire after 1 hour by default. The SDK's `fetchClientSecret` callback is called to refresh the session automatically. Ensure your callback fetches a fresh session from your server: ```javascript const dialstack = await loadDialStack('pk_test_...', { fetchClientSecret: async () => { // This is called on init and when the session needs refreshing const response = await fetch('/api/dialstack/session'); const { client_secret, expires_at } = await response.json(); return { clientSecret: client_secret, expiresAt: expires_at }; }, }); ``` ## Best Practices ### DO - Use the DialStack SDK for automatic reconnection and session management - Subscribe to events when your application loads - Look up additional customer data when calls arrive for richer screen pops - Call `dialstack.logout()` when the user logs out or navigates away ### DON'T - Don't poll the API instead of using events (inefficient and delayed) - Don't create multiple SDK instances for the same account - Don't store the client_secret long-term (it expires after 1 hour) ## Related Resources - [API Reference](/api) - Complete API documentation - [Authentication](/guides/authentication) - Authentication guide - [Sessions](/sdks/server/sessions) - Creating account sessions --- ## Guides Comprehensive guides to help you integrate and configure DialStack. Working with AI tools? See [`/llms.txt`](pathname:///llms.txt) for an agent-friendly index and [`/llms-full.txt`](pathname:///llms-full.txt) for the full-corpus markdown dump. ## Available Guides - [Quickstart](./quickstart.md) - Get your first phone system running in 5 minutes - [Authentication](./authentication.md) - API keys and security - [Click-to-Call](./click-to-call.md) - Initiate outbound calls from your application - [Real-Time Events](./events.md) - Receive instant notifications for incoming calls and screen pops - [Webhook Events](./webhook-events.md) - Receive call, recording, and voicemail notifications via HTTP webhooks - [Number Porting](./number-porting.md) - Transfer existing phone numbers from another carrier - [Pagination](./pagination.md) - Use URL-based pagination for list endpoints - [Voice Apps](./voice-apps.md) - Build programmable voice applications with AI and real-time audio - [Dial Plans](./dial-plans.md) - Build visual call routing flows with flowchart-style logic - [Ring Groups](./ring-groups.md) - Route calls to multiple destinations simultaneously - [Device Provisioning](./device-provisioning.md) - Provision and manage deskphones and DECT wireless systems - [E911 Dispatch Addresses](./e911.md) - Assign devices to physical locations so emergency calls dispatch to the right address --- ## New Numbers Search live carrier inventory and order new phone numbers onto an account. Use this flow when you need a fresh number (new location, new line, overflow capacity). To bring an existing number in from another carrier, use [Number Porting](./number-porting.md) instead. ## Overview Ordering a new number is a three-step flow: 1. **Search** — find available numbers using filters (area code, state, city + state, ZIP, or the six-digit prefix). 2. **Order** — submit one or more numbers from the search results. 3. **Track** — the order typically completes within seconds; poll the order ID or wait for a terminal status. ``` search → order (pending) → complete → partial (some numbers unavailable — only with partial_allowed) → failed (no numbers acquired) ``` ## Step 1: Search Available Numbers ```bash curl "https://api.dialstack.ai/v1/available-phone-numbers?area_code=919&quantity=10" \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` At least one filter is required. Supported filters: | Query param | Format | Notes | | ----------- | ----------------------------- | -------------------------- | | `area_code` | 3 digits | | | `state` | 2-letter uppercase (`NC`) | | | `city` | Free text | Requires `state` | | `zip` | 5 digits | | | `npa_nxx` | 6 digits (area code + prefix) | | | `quantity` | 1–100, default 10 | Caps the number of results | Filters may be combined to narrow results. **Response:** ```json { "data": [ { "phone_number": "+19195551234", "city": "RALEIGH", "state": "NC", "rate_center": "RALEIGH", "lata": "422" }, { "phone_number": "+19195555678", "city": "RALEIGH", "state": "NC", "rate_center": "RALEIGH", "lata": "422" } ] } ``` Search results come from live carrier inventory — they are **not reserved**. Another buyer can take the number between your search and your order, so place the order promptly and handle the `409 Conflict` case if a number is no longer available at order time. ## Step 2: Place the Order ```bash curl -X POST https://api.dialstack.ai/v1/phone-number-orders \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "phone_numbers": ["+19195551234", "+19195555678"] }' ``` Up to 100 numbers per order. Numbers must be US non-toll-free; the API rejects toll-free numbers and non-US numbers at validation. The account also has a cap on how many numbers it can hold in total, set by the platform administrator (`max_phone_numbers` on the account's `config`, default 25). An order that would take the account past the cap is rejected with `409 Conflict` and `"code": "phone_number_limit_exceeded"`, naming the current count, the cap, and how many the order tried to add — distinct from the `409` you get when a requested number is already claimed — by another account or your own — which carries no code. The same cap applies to [porting numbers in](./number-porting.md), and numbers reserved by a port order in progress count toward it — so a port in flight reduces how many you can buy. By default the entire order fails if any single number can't be acquired. Pass `"partial_allowed": true` to accept whatever subset the carrier is able to fill: ```json { "phone_numbers": ["+19195551234", "+19195555678"], "partial_allowed": true } ``` **Response (201 Created):** ```json { "id": "YOUR_ORDER_ID", "order_type": "purchase", "status": "pending", "phone_numbers": ["+19195551234", "+19195555678"], "completed_numbers": [], "failed_numbers": [], "error_message": null, "created_at": "2026-04-21T14:00:00Z", "updated_at": "2026-04-21T14:00:00Z" } ``` ## Step 3: Track the Order Orders typically reach a terminal status within seconds. Fetching a pending order refreshes its status from the upstream provider: ```bash curl https://api.dialstack.ai/v1/phone-number-orders/YOUR_ORDER_ID \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` You can also list all orders on the account, optionally filtered by status or type (`purchase` / `disconnect`): ```bash curl "https://api.dialstack.ai/v1/phone-number-orders?status=complete&order_type=purchase" \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` ### Status Reference | Status | Description | | ---------- | -------------------------------------------------------------------------------------- | | `pending` | Submitted; awaiting upstream confirmation | | `complete` | All requested numbers were acquired and are active on the account | | `partial` | Some numbers acquired, others unavailable (only possible with `partial_allowed: true`) | | `failed` | No numbers were acquired | On a terminal status, `completed_numbers` lists the E.164 numbers that were acquired and `failed_numbers` lists those that couldn't be. Numbers in `completed_numbers` appear under `GET /v1/phone-numbers` immediately and can be [routed](/api#tag/Phone-Numbers) to an extension, dial plan, voice app, or ring group. ## Related Resources - [API Reference — Phone Numbers](/api#tag/Phone-Numbers) — Complete endpoint documentation - [Number Porting](./number-porting.md) — Transfer existing numbers from another carrier --- ## Number Porting Number porting lets you transfer existing phone numbers from another carrier to DialStack. This guide walks through the full port-in workflow: checking eligibility, creating a port order, approving the order, and tracking it through completion. ## Overview Porting follows a draft-based workflow: 1. **Check eligibility** — verify numbers can be ported 2. **Create a draft** — provide subscriber details and your requested date and time 3. **Approve** — customer approves the port order with their electronic signature 4. **Submit** — send the order to the carrier for processing 5. **Track status** — monitor until the port completes ``` draft → approved → submitted → foc (scheduled) → complete → exception → (fix + resubmit) → submitted ``` ## Prerequisites - DialStack API key (`sk_live_*`) - Phone numbers currently active with another US carrier - Subscriber details matching the losing carrier's records (name, service address, account number, and PIN as listed on the current carrier's records) ## Step 1: Check Eligibility Before creating a port order, verify that the numbers can be ported. This endpoint is platform-scoped — no `DialStack-Account` header is needed. ```bash curl -X POST https://api.dialstack.ai/v1/port-in-eligibility \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_numbers": ["+12025551234", "+12025555678"] }' ``` **Response:** ```json { "portable_numbers": [ { "phone_number": "+12025551234", "losing_carrier_name": "Verizon Wireless", "losing_carrier_spid": "6006", "is_wireless": true, "account_number_required": true } ], "non_portable_numbers": [ { "phone_number": "+12025555678", "rate_center": "WASHINGT DC", "city": "Washington", "state": "DC" } ] } ``` Only numbers listed in `portable_numbers` can be included in a port order. Check `account_number_required` — when `true`, you'll need to include the subscriber's `account_number` and `pin` in the port order. ## Step 2: Create a Draft Port Order Create a draft with the subscriber details, phone numbers, and your requested porting date and time. You can control exactly when your numbers transfer by setting `requested_foc_date` and optionally `requested_foc_time` (Eastern Time, between 08:00 and 20:00). The date must be at least **5 business days** and no more than **30 calendar days** from today. Once the carrier confirms the order, the port will complete at the date and time you requested — so you can plan your cutover with confidence. ```bash curl -X POST https://api.dialstack.ai/v1/port-orders \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "phone_numbers": ["+12025551234"], "subscriber": { "btn": "+12025551234", "business_name": "Smith Consulting", "approver_name": "Jane Smith", "account_number": "123456789", "pin": "1234", "address": { "house_number": "123", "street_name": "Main St", "city": "Washington", "state": "DC", "zip": "20001" } }, "requested_foc_date": "2026-03-15", "requested_foc_time": "10:00" }' ``` **Response:** ```json { "id": "port_01jkx...", "status": "draft", "details": { "phone_numbers": ["+12025551234"], "subscriber": { "btn": "+12025551234", "business_name": "Smith Consulting", "approver_name": "Jane Smith", "account_number": "123456789", "pin": "1234", "address": { "house_number": "123", "street_name": "Main St", "city": "Washington", "state": "DC", "zip": "20001" } }, "requested_foc_date": "2026-03-15", "requested_foc_time": "10:00" }, "submitted_at": null, "created_at": "2026-02-11T14:00:00Z", "updated_at": "2026-02-11T14:00:00Z" } ``` :::info[Subscriber Details] All subscriber fields must match the records on file with the losing carrier. Mismatches are the most common reason for port rejections. - **BTN** (Billing Telephone Number) — the main number on the account - **Approver name** — the name of the person authorized to approve the port - **Service address** — split into `house_number` and `street_name` to match the carrier's format - **Account number** and **PIN** — required for wireless ports (check `account_number_required` in the eligibility response) ::: ## Step 3: Approve the Port Order Have the customer approve the port order by providing their electronic signature and IP address. This authorizes the transfer of their phone numbers. ```bash curl -X POST https://api.dialstack.ai/v1/port-orders/port_01jkx.../approve \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "signature": "Jane Smith", "ip": "203.0.113.42" }' ``` **Response:** ```json { "id": "port_01jkx...", "status": "approved", "details": { "phone_numbers": ["+12025551234"], "subscriber": { "...": "..." }, "approval": { "signature": "Jane Smith", "ip": "203.0.113.42", "timestamp": "2026-02-11T14:02:00Z" }, "requested_foc_date": "2026-03-15", "requested_foc_time": "10:00" }, "submitted_at": null, "created_at": "2026-02-11T14:00:00Z", "updated_at": "2026-02-11T14:02:00Z" } ``` The status transitions from `draft` to `approved`. If the order details are updated after approval, the approval is cleared and the status reverts to `draft` — the customer must approve again. ## Step 4: Submit the Order Once the order is approved, submit it to begin the porting process: ```bash curl -X POST https://api.dialstack.ai/v1/port-orders/port_01jkx.../submit \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` **Response:** ```json { "id": "port_01jkx...", "status": "submitted", "details": { "...": "..." }, "submitted_at": "2026-02-11T14:05:00Z", "created_at": "2026-02-11T14:00:00Z", "updated_at": "2026-02-11T14:05:00Z" } ``` The status transitions from `approved` to `submitted`. The necessary authorization documents are generated and forwarded to the carrier automatically. ## Step 5: Track the Order Poll the order to check its status. Non-terminal orders are automatically refreshed from the carrier: ```bash curl https://api.dialstack.ai/v1/port-orders/port_01jkx... \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` You can also list all port orders, optionally filtering by status: ```bash curl "https://api.dialstack.ai/v1/port-orders?status=submitted" \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` ### Status Reference | Status | Description | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `draft` | Order created, not yet approved | | `approved` | Customer has approved the order, ready to submit | | `submitted` | Sent to the carrier, awaiting processing | | `exception` | Rejected by the carrier — can be corrected and resubmitted | | `foc` | Scheduled — the carrier has confirmed a completion date and time (in telecom, this confirmation is called a Firm Order Commitment, abbreviated FOC). Your numbers will port at the confirmed date and time. | | `complete` | Numbers successfully ported and active in DialStack | | `cancelled` | Order was cancelled | ### Viewing the Audit Trail Every status change is recorded. View the full history: ```bash curl https://api.dialstack.ai/v1/port-orders/port_01jkx.../events \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` ## Handling Rejections If the carrier rejects the port (status becomes `exception`), the rejection details appear in the order: ```json { "status": "exception", "details": { "rejection": { "code": "address_mismatch", "message": "Service address mismatch" } } } ``` To fix and resubmit, update the order with corrected information: ```bash curl -X POST https://api.dialstack.ai/v1/port-orders/port_01jkx... \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "subscriber": { "btn": "+12025551234", "business_name": "Smith Consulting", "approver_name": "Jane Smith", "address": { "house_number": "456", "street_name": "Oak Ave", "city": "Washington", "state": "DC", "zip": "20002" } } }' ``` ## Changing the Port Date For an order in `submitted` or `foc`, send only the new port date and time: ```bash curl -X POST https://api.dialstack.ai/v1/port-orders/port_01jkx... \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "requested_foc_date": "2026-09-02", "requested_foc_time": "08:00" }' ``` The order keeps its current status. Other fields cannot be changed at this stage and result in a `409` response with `code: port_order_invalid_state`. ## Cancelling an Order Cancel a port order that hasn't reached a terminal state: ```bash curl -X POST https://api.dialstack.ai/v1/port-orders/port_01jkx.../cancel \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` Orders in `complete` or `cancelled` status cannot be cancelled. ## Common Rejection Reasons | Code | Reason | How to Fix | | ------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `address_mismatch` | Service address mismatch | Update the address to match the losing carrier's records | | `account_number_invalid` | Account number required | Contact the losing carrier for your account number | | `name_mismatch` | Approver name mismatch | Use the exact name on file with the losing carrier | | `business_name_mismatch` | Business name mismatch | Use the business name from the carrier's Customer Service Record (CSR), which may differ from the name on the bill | | `btn_mismatch` | BTN mismatch | Verify the billing telephone number with the losing carrier | | `pin_required` | PIN required | Contact the losing carrier to get the PIN or passcode for the account, then include it in the `pin` field | | `pin_invalid` | PIN invalid | The PIN provided doesn't match the carrier's records — contact the losing carrier to verify the correct PIN and update the order | ## Error Handling | Status | Error | Description | | ------ | -------------------- | ----------------------------------------------------------------------- | | 400 | Validation errors | Invalid phone numbers, bad date format, etc. | | 404 | Port order not found | Order doesn't exist or belongs to a different account | | 409 | Invalid state | Operation not allowed in the order's current status | | 409 | Number limit reached | The port would take the account past its phone number limit — see below | | 422 | Approval required | Must approve the order before submitting | ### Phone number limit Each account has a cap on how many phone numbers it can hold, set by the platform administrator (`max_phone_numbers` on the account's `config`, default 25). Porting counts against it just as buying numbers does, so creating a port order — or editing one to add numbers — returns `409 Conflict` when the resulting total would exceed the cap. The response says how many numbers the account holds, what the cap is, and how many the request tried to add. The count includes numbers reserved by port orders already in flight, so two concurrent ports draw on the same allowance. Released numbers don't count. To clear the rejection, either have the platform administrator raise the account's limit, or release numbers the account no longer needs — the count is evaluated live at the time of the request. This response carries `"code": "phone_number_limit_exceeded"`, so you can tell it apart from the other `409`s without reading the message — see [Error codes](./error-codes.md). ## Related Resources - [API Reference — Number Porting](/api#tag/Number-Porting) — Complete endpoint documentation - [Phone Numbers](/api#tag/Phone-Numbers) — Managing active phone numbers --- ## Pagination DialStack uses URL-based pagination for all list endpoints, providing efficient and consistent pagination across the API. ## Overview URL-based pagination uses opaque page tokens embedded in URLs to navigate through large datasets. This approach: - **Simplifies client code** (no need to extract IDs or manage cursors) - **Performs better** at scale (no need to scan and skip records) - **Handles real-time data** correctly (new items don't shift pages) - **Provides stable results** (consistent ordering) ## List Response Format All list endpoints return a consistent response structure: ```json { "object": "list", "url": "/v1/accounts", "next_page_url": "/v1/accounts?page=abc123", "previous_page_url": null, "data": [...] } ``` | Field | Type | Description | | ------------------- | -------------- | ---------------------------------------------------------- | | `object` | string | Always `"list"` | | `url` | string | The API endpoint for this list | | `next_page_url` | string \| null | URL to fetch the next page, or `null` if no more items | | `previous_page_url` | string \| null | URL to fetch the previous page, or `null` if on first page | | `data` | array | The actual list of items | ## Pagination Parameters ### `limit` Controls the number of items to return (default: 10, max: 100). ```bash curl https://api.dialstack.ai/v1/call-logs?limit=50 ``` :::note The pagination system uses opaque page tokens embedded in URLs. Always use the complete URLs provided in `next_page_url` and `previous_page_url` from the API response. Do not construct URLs manually. ::: ## Pagination Example Here's a complete example of paginating through call logs: ```javascript const { DialStack } = require('@dialstack/sdk-server'); const dialstack = new DialStack('your-api-key'); async function listAllCallLogs() { const allCallLogs = []; let nextPageUrl = null; // Fetch first page let response = await dialstack.callLogs.list({ limit: 100 }); allCallLogs.push(...response.data); nextPageUrl = response.next_page_url; // Fetch remaining pages while (nextPageUrl) { // The SDK accepts the full URL and extracts the path response = await dialstack.request('GET', nextPageUrl); allCallLogs.push(...response.data); nextPageUrl = response.next_page_url; } return allCallLogs; } ``` ## Auto-Pagination with SDK The server SDK provides automatic pagination using async iterators: ```javascript // Automatically iterate through all pages for await (const callLog of dialstack.callLogs.list().autoPagingEach()) { console.log(callLog.id); } // Or collect all items into an array const allCallLogs = await dialstack.callLogs.list().autoPagingToArray({ limit: 1000, // Maximum items to collect }); ``` The SDK handles all pagination automatically: - Follows `next_page_url` from each response - Stops when no more pages are available - Handles errors and retries ## Backward Pagination To paginate backward (e.g., for a "Previous Page" button): ```javascript // Get the first page const page1 = await dialstack.callLogs.list({ limit: 20 }); // Later, when user clicks "Next" const page2 = await dialstack.request('GET', page1.next_page_url); // When user clicks "Previous" from page 2 const backToPage1 = await dialstack.request('GET', page2.previous_page_url); // Results are returned in the correct order (newest first) ``` ## Using with Web Components Our web components and React components handle pagination automatically. You don't need to manage page tokens or URLs manually: ```html ``` ```jsx { /* React Component - pagination handled internally */ } ; ``` The components automatically: - Load the initial page - Fetch more items when scrolling or clicking "Load More" - Handle page URLs internally - Show loading indicators during pagination - Support both forward and backward navigation ## Ordering All list endpoints return items in descending order by creation time (newest first). This ordering is stable and efficient for pagination. For endpoints with custom sorting (like call logs sorted by start time), the ordering is clearly documented in the API reference. ## Best Practices ### ✅ DO - Use reasonable page sizes (10-100 items) - Follow the URLs from `next_page_url` and `previous_page_url` - Check `next_page_url` for `null` before requesting the next page - Use web components or SDK auto-pagination for automatic handling ### ❌ DON'T - Don't try to construct page tokens yourself (they're opaque) - Don't manually build URLs with page parameters - Don't exceed the maximum limit of 100 items - Don't rely on page token format (it may change) - Don't store page URLs long-term (they may expire) ## Related Resources - [API Reference](/api) - Complete API documentation - [Web Components](/sdks/web-components) - Auto-pagination components - [React Components](/sdks/react) - React-specific components --- ## Quickstart Guide Get your first phone system running in 5 minutes. ## Prerequisites - DialStack API key (`sk_live_*`) - `curl` or any HTTP client ## Step 1: Create an Account Accounts represent your customers' organizations (e.g., "Jones Chiropractic"). ```bash curl -X POST https://api.dialstack.ai/v1/accounts \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "contact@joneschiro.com" }' ``` **Response:** ```json { "id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "email": "contact@joneschiro.com", "created_at": "2025-10-18T10:00:00Z", "updated_at": "2025-10-18T10:00:00Z" } ``` Save the `id` as your account ID for the next steps. ## Step 2: Create a User Users represent people who will have phone service. ```bash curl -X POST https://api.dialstack.ai/v1/users \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "name": "Dr. Alice Smith", "email": "alice@joneschiro.com" }' ``` **Response:** ```json { "id": "user_01h2xcejqtf2nbrexx3vqjhp42", "name": "Dr. Alice Smith", "email": "alice@joneschiro.com", "created_at": "2025-10-17T14:30:00Z", "updated_at": "2025-10-17T14:30:00Z" } ``` Save the `user_id` for the next step. ## Step 3: Assign a Phone Number Assign a phone number to the user so they can make and receive calls. See the [Phone Numbers guide](/api) for ordering and assignment. ## Step 4: Make a Test Call Once a phone number is assigned, you can: - Provision a desk phone via the admin panel (devices are auto-configured for the user) - Build a softphone using the [WebRTC API](/webrtc) for browser or mobile calling - Use [click-to-call](/guides/click-to-call) to initiate calls from your backend ## Next Steps - [Authentication Guide](/guides/authentication) - Learn about API keys and security - [API Reference](/api) - Full API documentation ## Need Help? - **Email**: api@dialstack.ai - **GitHub Issues**: [github.com/dialstack/dialstack/issues](https://github.com/dialstack/dialstack/issues) --- ## Ring Groups(Guides) import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Ring Groups Route calls to multiple destinations simultaneously. ## Overview A ring group dials multiple members in parallel — all phones ring at once, and the first person to answer wins. Other ringing channels are automatically cancelled. Use cases: - **Sales teams** — Route inbound leads to all sales reps simultaneously - **Support queues** — Ring all available agents at once - **Failover routing** — Include external phone numbers as backup ``` ┌─────────────┐ │ Incoming │ │ Call │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ Ring Group │ │ "Sales Team"│ └──────┬──────┘ │ ┌────────────┼────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Alice │ │ Bob │ │ +1 415 │ │ (user) │ │ (user) │ │ 555-1234│ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ └────────────┼────────────┘ │ ▼ First answer wins ``` ## Creating a Ring Group ```javascript const ringGroup = await dialstack.ringGroups.create( { name: 'Sales Team', timeout_seconds: 30, ignore_forwarding: false, }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); console.log(ringGroup.id); // rg_01h2xcejqtf2nbrexx3vqjhp51 ``` ```bash curl -X POST https://api.dialstack.ai/v1/ring_groups \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "name": "Sales Team", "timeout_seconds": 30, "ignore_forwarding": false }' ``` ## Adding Members Members can be either extensions (users, dial plans, voice apps) or phone numbers. ### Extension Members Add a user to ring when the group is called: ```javascript await dialstack.ringGroups.addMember( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { extension: 'user_01h2xcejqtf2nbrexx3vqjhp42' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/ring_groups/rg_01h2xcejqtf2nbrexx3vqjhp51/members \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{"extension": "user_01h2xcejqtf2nbrexx3vqjhp42"}' ``` ### Member Fan-Out What an extension member actually rings depends on that user's Find Me / Follow Me configuration: - **No FMFM configured** — the group rings the user's SIP endpoints plus their WebRTC softphone. - **FMFM configured** — the group rings the targets of **step 1 only**, and those targets _replace_ the user's endpoints rather than ringing alongside them. An external number in step 1 rings as part of the group. Later FMFM steps and the FMFM fallback are never run inside a ring group. When the ring window closes, the group's own [Timeout Action](#timeout-action) applies instead. This matters for a common configuration: a user whose step 1 rings their devices and step 2 rings their cell. Dialing that user directly ladders through to the cell, but a call to the ring group rings only the devices. Put the number in step 1, or add it to the group as a phone number member, to have it ring as part of the group. ### Phone Number Members Add an external phone number as a member: ```javascript await dialstack.ringGroups.addMember( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { phone_number: '+14155551234' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/ring_groups/rg_01h2xcejqtf2nbrexx3vqjhp51/members \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{"phone_number": "+14155551234"}' ``` ## Settings ### Timeout The `timeout_seconds` setting (5-300, default 20) controls how long members ring before the call is considered unanswered. ```javascript await dialstack.ringGroups.update( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { timeout_seconds: 45 }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### Ignore Forwarding When `ignore_forwarding` is `true`, SIP 302 redirects (call forwarding) from devices are ignored. The device is treated as busy instead of following the forward, and the group keeps ringing its other members. This is useful when: - Devices have call forwarding configured but you want the ring group to control routing - You want to prevent calls from being forwarded outside the ring group This setting reaches only forwarding the phone performs itself. It does not disable a member's Find Me / Follow Me or change which targets that produces, but it does apply to every leg the group dials, including a phone reached through an FMFM step-1 target. See [Member Fan-Out](#member-fan-out) for how a member's own routing is expanded. ```javascript await dialstack.ringGroups.update( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { ignore_forwarding: true }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### Confirm External When `confirm_external` is `true`, external destinations must press 1 before being connected to the caller. This prevents external voicemail systems from picking up the call instead of a real person. This applies to phone number members and an extension member's Find Me / Follow Me external targets. Extension members' own devices ring normally. The prompt plays as soon as the external destination answers and repeats every few seconds. The person has 15 seconds to press 1: if they do, they are connected to the caller; if they don't, that leg is dropped and the rest of the group keeps ringing (and the group's timeout action still applies). The 15 seconds run from the moment that destination answers, independently of `timeout_seconds` — a destination answering near the end of the ring window keeps the group open until it confirms or the window expires. ```javascript await dialstack.ringGroups.update( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { confirm_external: true }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### Timeout Action The `timeout_action` and `timeout_target` settings control what happens when no member answers within `timeout_seconds`. Without these, the call simply ends when the timeout expires. Three actions are available: - `"ring_user"` — Ring a specific user after the timeout. `timeout_target` is the user's ID. - `"voicemail"` — Send the caller to voicemail. `timeout_target` is either a user (delivers to their personal voicemail) or a shared voicemail box. - `"queue"` — Overflow the caller into a call queue. `timeout_target` is the queue's ID. Both `timeout_action` and `timeout_target` must be set together. ```javascript // Send to voicemail after timeout await dialstack.ringGroups.update( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { timeout_action: 'voicemail', timeout_target: 'user_01h2xcejqtf2nbrexx3vqjhp42', }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ```bash curl -X PATCH https://api.dialstack.ai/v1/ring_groups/rg_01h2xcejqtf2nbrexx3vqjhp51 \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "timeout_action": "voicemail", "timeout_target": "user_01h2xcejqtf2nbrexx3vqjhp42" }' ``` To clear the timeout behavior, set both fields to `null`: ```javascript await dialstack.ringGroups.update( 'rg_01h2xcejqtf2nbrexx3vqjhp51', { timeout_action: null, timeout_target: null }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ## Routing to a Ring Group To route calls to a ring group, create an extension that targets it: ```javascript // Create extension 200 that routes to the ring group await dialstack.extensions.create( { number: '200', target: 'rg_01h2xcejqtf2nbrexx3vqjhp51', }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/extensions \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "number": "200", "target": "rg_01h2xcejqtf2nbrexx3vqjhp51" }' ``` Then assign that extension to a phone number, or use it in a dial plan. ## Using in Dial Plans Ring groups can be used as targets in dial plan internal dial nodes: ```json { "id": "sales", "type": "internal_dial", "config": { "target_id": "rg_01h2xcejqtf2nbrexx3vqjhp51", "timeout": 30, "next": "voicemail" } } ``` If no one answers within the timeout, the dial plan continues to the next node. ## Example: Sales Team Complete example creating a sales team ring group: ```javascript // Account context for all operations const acct = { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }; // 1. Create the ring group (confirm_external prevents external voicemail pickup) const ringGroup = await dialstack.ringGroups.create( { name: 'Sales Team', timeout_seconds: 30, confirm_external: true }, acct ); // 2. Add team members await dialstack.ringGroups.addMember(ringGroup.id, { extension: 'user_alice_...' }, acct); await dialstack.ringGroups.addMember(ringGroup.id, { extension: 'user_bob_...' }, acct); // 3. Add external cell phone as backup await dialstack.ringGroups.addMember(ringGroup.id, { phone_number: '+14155551234' }, acct); // 4. Create extension to route calls to the ring group await dialstack.extensions.create({ number: '200', target: ringGroup.id }, acct); // 5. Assign to a phone number await dialstack.phoneNumbers.update('pn_main_line_...', { extension: '200' }, acct); ``` ## Loop Prevention Ring groups can contain extensions that target other ring groups or dial plans, enabling complex routing scenarios. However, circular references are prohibited to prevent infinite loops. ### Prohibited Configurations **Direct loop** — A ring group cannot contain itself as a member: ``` RG1 → ext_a → RG1 ❌ Direct loop ``` **Indirect loop** — Ring groups cannot form a circular chain: ``` RG1 → ext_a → RG2 → ext_b → RG1 ❌ Indirect loop ``` ### Error Responses When a loop is detected, the API returns a `422 Unprocessable Entity` status: ```json { "error": "ring group cannot contain itself as a member" } ``` For indirect loops, the error shows the circular path: ```json { "error": "rg_01xxx → dp_02yyy → rg_01xxx" } ``` ### Allowed Configurations **Multiple members targeting the same ring group** — Parallel references are allowed: ``` RG1 → ext_a → RG2 RG1 → ext_b → RG2 ✓ Same target, no loop ``` **Chains without cycles** — Linear chains are allowed: ``` RG1 → ext_a → RG2 → ext_b → RG3 ✓ No circular reference ``` **Mixed chains** — Ring groups and dial plans can reference each other as long as there's no cycle: ``` RG1 → ext_a (dial plan) → DP1 → internal_dial → RG2 ✓ No circular reference ``` ### Depth Limit Routing nesting is limited to a maximum depth of 20 levels. Configurations exceeding this limit are rejected to prevent performance issues: ```json { "error": "routing nesting exceeds maximum depth of 20" } ``` In practice, most deployments use 2-3 levels of nesting at most. ## API Reference - [Ring Groups](/api#tag/Ring-Groups) — Create, update, and manage ring groups - [Extensions](/api#tag/Extensions) — Assign ring groups to extension numbers - [Dial Plans](/api#tag/Dial-Plans) — Use ring groups in call routing flows --- ## Screen Pop Open the customer record in your app on the very first ring — before anyone says hello. Two event surfaces can drive a Screen Pop; pick the one that matches your architecture. ## Option 1: frontend via Server-Sent Events (simplest for embedded integrations) If the customer record already lives in a browser tab, let the browser itself subscribe to DialStack events and react. No backend round-trip. DialStack pushes real-time events to the browser over [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events) (SSE) — a long-lived HTTP connection that streams events one-way from server to client. It's simpler than WebSockets and reconnects automatically. Use the SDK to open an SSE stream with a session token: ```tsx import { loadDialStack } from '@dialstack/sdk-js'; const dialstack = await loadDialStack('pk_live_YOUR_KEY', { fetchClientSecret: async () => { const res = await fetch('/api/dialstack/session'); return res.json(); }, }); dialstack.on('call.incoming', async (event) => { const customer = await lookupByPhone(event.from_number); if (customer) openCustomerRecord(customer.id); }); ``` Latency: sub-second from the first ring to the record opening. Connection stays alive as long as the browser tab is open. SSE reconnects automatically. See [Real-Time Events](./events.md) for the full SSE API, authentication, and reconnection behavior. ## Option 2: backend webhook → your own realtime channel If the customer lookup requires your backend (to cross a firewall, join a DB, call a CRM), handle the Screen Pop server-side and push to the frontend over your own channel: ```ts // POST /webhooks/dialstack — configured as your platform's webhook_url app.post('/webhooks/dialstack', express.raw({ type: 'application/json' }), async (req, res) => { verifySignature(req); // see webhook-events.md const event = JSON.parse(req.body); if (event.type === 'call.incoming') { const customer = await db.customers.findOne({ phone: event.data.from_number }); if (customer) { // push to the user's open session via YOUR realtime channel pusher.trigger(`user-${event.data.user_id}`, 'screen-pop', { customerId: customer.id, callId: event.data.call_id, }); } } res.status(200).end(); }); ``` Webhooks are **fire-and-forget** — respond `200` quickly. Don't do the customer lookup on the webhook's critical path if it can be slow; queue it. ## What makes a good Screen Pop - **Speed over completeness.** Pop the record in < 500ms. Enrich later. - **Degrade gracefully.** Unknown caller? Show the unknown-caller state, don't hide the Screen Pop entirely. - **Idempotent.** The same `event.id` can be delivered more than once. De-dupe by `event.id`. - **No blocking UI.** If lookup fails, log it and continue — never block the ring. ## Where the data comes from The `call.incoming` event carries: | Field | Type | Use for | | ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `from_number` | string | Match to a customer record (E.164) | | `from_name` | string \| null | Fall-back display name when you have no match | | `to_number` | string | Which of your numbers was dialed (useful for multi-location routing) | | `user_id` | string \| null | The user the call is routed to (when known) | | `call_id` | string | Stable call identifier — use to attach the opened record to your activity log, and later to fetch call details, recording, or transcript via the API | See [Webhook Events](./webhook-events.md) for the full `call.incoming` payload, envelope, and signature verification. ## See also - [Real-Time Events (SSE)](./events.md) — account-scoped event stream for frontends. - [Webhook Events](./webhook-events.md) — platform-scoped webhook firehose. - [Activity Logging](./activity-logging.md) — the other half of the event loop: write the call to your system of record. --- ## Testing your integration A sandbox secret key (`sk_test_...`) is a full API key for a sandbox account: everything you can do in live — create users, order numbers, assign devices, build dial plans — you can do there, without touching your live account. Calling is where sandbox differs, and the distinction that matters is between a real call and a simulated one: - **Real calls.** A sandbox account is a real account: its users, devices, and softphones register and ring for real, and calls between them run the live code path. Placing one works however you place one in live — a deskphone or softphone dialing an extension, [click-to-call](./click-to-call.md), a ring group, a queue. The single thing sandbox withholds is the public telephone network. - **Simulated calls.** `POST /v1/test_helpers/calls` fabricates a call and drives its whole lifecycle on timers, with no devices involved. This is how you exercise your webhook handling — including outcomes that are hard to produce on demand, like a busy far end or a voicemail deposit — and it's the only path that needs a sandbox-specific endpoint. Simulated events are fabricated, but everything downstream of them is the production path. Webhooks arrive over the same signed, retried, ordered delivery your live traffic uses. The `/v1/events` SSE feed mirrors most of these, but a couple of events — `call.ringing` and `call.emergency` — are delivered only by webhook. Every webhook event DialStack emits in production can be triggered in sandbox: most fire automatically as a call plays out, a few are triggered by [dialing a magic number](#scenario-magic-numbers), and the rest are sent [on demand](#standalone-events-fax-queue-and-more). ## What you need A sandbox account, and a sandbox key (`sk_test_...`) for it. Point your requests at the same base URL as production, pass the key, and name the account: ``` Authorization: Bearer sk_test_YOUR_SECRET_KEY DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT ``` That is the whole setup for real calls. Two users with a registered device or softphone each can dial one another's extensions with nothing else provisioned — no phone number involved, since an internal call never needs one. Simulating calls needs one more thing: a phone number on the sandbox account. A simulated call fabricates a call record, and the record needs a number to report as the account side, so the simulator uses the account's first phone number — `POST /v1/test_helpers/calls` returns `400` when there is none. Order one on the sandbox account itself; sandbox numbers order instantly, and they cost nothing. That number is a real, listable resource, and the simulator treats it as one: outbound simulations set the call record's `from_number` from it, inbound simulations target it, and it appears in `GET /v1/phone-numbers` like any other number. A simulated call never references a number that doesn't exist. Every simulated event carries the sandbox `account_id`, so you can always tell sandbox traffic from live. ### Receiving webhooks locally Simulated events go to your platform's **sandbox** webhook endpoint — the one you configure separately from your live endpoint, so sandbox traffic never reaches your production webhook. Point that endpoint wherever your code runs. To catch events on your own machine during development, expose your local server with an HTTP tunnel and set the sandbox webhook URL to the address it returns: ```bash cloudflared tunnel --url http://localhost:3000 # → https://random-name.trycloudflare.com (set this as your sandbox webhook URL) ``` `ngrok` and other tunnels work the same way. If you only need the core `call.*` lifecycle, you can read events from the `/v1/events` SSE feed instead of standing up a tunnel. Note that `call.ringing` and `call.emergency` are webhook-only, so a harness that waits for them on SSE will hang — use webhooks if your flow depends on either. ## Outbound calls ### Real calls A sandbox account places real calls. Its phones really register, really ring, and really connect to each other — a user picking up a deskphone and dialing an extension, or your integration calling `POST /v1/calls`, gets a genuine call, not a mock of one. The destination is the only thing sandbox constrains: internal destinations connect, the public network is refused. `POST /v1/calls` ([click-to-call](./click-to-call.md)) is the one a test can drive, so it's the one worth showing: ```bash curl -X POST https://api.dialstack.ai/v1/calls \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \ -H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \ -H "Content-Type: application/json" \ -d '{"user": "user_01...", "dial_string": "1002"}' ``` The response is `202 Accepted` with no body, exactly as in production. What happens next depends on where you dialed: - **An internal destination** — another user's extension, a ring group, a queue, a voice app. The call connects for real, end to end, and produces the full event lifecycle and call record that a live call would. Use this to validate your integration against real device behavior. - **A phone number on the public network.** Sandbox accounts have no access to it. The caller hears an announcement explaining that the destination was not dialed, and the call ends there — for a click-to-call, that means their devices still ring and the announcement plays when they answer. Because the far end was never reached, no call was ever placed — so no `call.*` webhooks are emitted for it. To exercise your webhook handling for an outbound call, simulate one instead. If the user has no registered device, nothing rings and nothing happens — the same as in live. Standing up devices is not a prerequisite for testing your webhooks; use the simulated path below. ### Simulated outbound calls `POST /v1/test_helpers/calls` with `direction: "outbound"` fabricates an outbound call and runs its whole lifecycle on timers, with no devices involved. This is the path to use when what you are testing is your webhook handling. It returns `200` with the started call's `id` and `scenario`, so a test harness can assert on the response directly. ```bash curl -X POST https://api.dialstack.ai/v1/test_helpers/calls \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \ -H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \ -H "Content-Type: application/json" \ -d '{"user": "user_01...", "direction": "outbound", "to_number": "+15005550100"}' ``` `user` is the sandbox user the call is attributed to — a `user_...` value from `GET /v1/users`. `to_number` is the destination; its [magic-number](#scenario-magic-numbers) value picks the outcome, or pass a [`scenario`](#custom-scenarios) object to script it explicitly. For the answered scenario above, the events arrive in order: - `call.initiated` → `call.ringing` → `call.answered` → `call.end` - then, a few seconds later: `recording.available` → `recording.transcription.complete` → `recording.summary.complete` > **During the call:** while a call is in flight, `GET /v1/calls/{id}` returns `200` with a sparse live projection. `status` is `null`, and fields known only after the call ends (such as duration and the recording) are absent until the final record lands after `call.end`. This is the same behavior as a live call. ### Scenario magic numbers The **destination** you pass as `to_number` scripts the outcome of a simulated outbound call, the same way Stripe uses test cards and Twilio uses test numbers: | Number dialed | What it simulates | | ---------------- | ----------------------------------------------------------------------------------------------------------- | | `+15005550100` | Answered after 5s, ~30s of talk time, recording produced | | `+15005550101` | Rings for 20s, no answer | | `+15005550102` | Busy | | `+15005550103` | The far end's answering machine picks up — recorded as a normal answered call ([why](#a-note-on-voicemail)) | | `+15005550104` | Answered, then transferred mid-call (`call.transfer`) | | `933` | Answered, and additionally emits `call.emergency` | | any other number | Default: answered, short call, recording produced | A few things to know: - A simulated call dials nothing, so these numbers never ring anyone. They're also in the reserved `555-01xx` range that carriers never assign to a subscriber, so a magic number that slips into your **production** config dials a dead number instead of ringing someone. - Matching is on the digits, so `+15005550100`, `15005550100`, and `500-555-0100` all resolve to the same scenario. - `933` (the emergency self-test number) also emits `call.emergency` so you can exercise your emergency-call handling — no real emergency call is placed. - `911` is intentionally not a sandbox trigger. - Magic numbers apply to the simulated paths only. Passing one to `POST /v1/calls` dials nothing special — it is a public-network destination like any other, so sandbox denies it. ### A note on voicemail On an outbound call there is no reliable signal for whether a person or an answering machine answered; the two are indistinguishable. So `+15005550103` — and the `voicemail` outcome in an [inbound custom scenario](#custom-scenarios) when it's applied to an outbound call — is recorded as a normal answered call. Your integration won't see a distinct "went to voicemail" status on an outbound call, because production can't produce one. A true voicemail deposit is an **inbound** event; see [Custom scenarios](#custom-scenarios) under Inbound calls. ## Inbound calls An inbound call can't be triggered by dialing a number, so inbound simulations use a dedicated sandbox-only endpoint, `POST /v1/test_helpers/calls`. It drives `call.incoming` and `call.ringing` so you can test screen pop or other inbound workflows. Set `from_number` to a known contact's number to exercise your caller-ID matching. The endpoint returns `200` with the started call's `id` and `scenario`, so a test harness can assert on the response. ```bash curl -X POST https://api.dialstack.ai/v1/test_helpers/calls \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \ -H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \ -H "Content-Type: application/json" \ -d '{"user": "user_01...", "from_number": "+15551234567"}' ``` `user` is the target user for the call — a `user_...` id from your sandbox account — and it's currently required: the simulator delivers the call directly to that user rather than running your account's inbound routing (routing simulation isn't supported yet). This endpoint is sandbox-only; a live key receives a `400`. ### Custom scenarios Inbound calls have no magic numbers, so the `scenario` object is how you script an inbound call's outcome (it can also override the outcome of a [simulated outbound](#simulated-outbound-calls) call). Pass it to `POST /v1/test_helpers/calls`: ```bash curl -X POST https://api.dialstack.ai/v1/test_helpers/calls \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \ -H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \ -H "Content-Type: application/json" \ -d '{ "user": "user_01...", "from_number": "+15551234567", "from_name": "Sarah Johnson", "scenario": { "outcome": "voicemail", "ring_seconds": 15, "voicemail_seconds": 12 } }' ``` `from_name` is the simulated caller-ID name: it rides the call's webhook events as `from_name` and, once the call completes, is what `from_label` reports on the call log. Scenario fields — any field you omit keeps its default: | Field | Notes | | ------------------- | ----------------------------------------------------------------------- | | `outcome` | `answered`, `no-answer`, `busy`, or `voicemail` (anything else → `400`) | | `ring_seconds` | Ring duration, clamped to 120s | | `talk_seconds` | Talk duration, clamped to 120s | | `voicemail_seconds` | Voicemail length, clamped to 120s | | `recording` | Whether a recording is produced | `from_number` must be in E.164 format. The `voicemail` outcome deposits a real voicemail only on an inbound call landing in one of your users' mailboxes — it drops a voicemail there and fires `voicemail.new` followed by `voicemail.transcription.complete`. (On an outbound call, `voicemail` collapses to a normal answered call — see [A note on voicemail](#a-note-on-voicemail).) ## Standalone events (fax, queue, and more) The call lifecycle, recordings, and voicemail play out as part of a simulated call. The remaining webhook events — faxes, call queues, and a few one-off notifications — aren't tied to a call, so sandbox sends them on demand. `POST /v1/test_helpers/events` sends a single event of the type you name, with a representative sample payload, over the same webhook delivery path, and returns `200` echoing the event it sent. ```bash curl -X POST https://api.dialstack.ai/v1/test_helpers/events \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \ -H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \ -H "Content-Type: application/json" \ -d '{"event": "queue.call.answered"}' ``` You can send any of these: - **Fax:** `fax.delivered`, `fax.failed`, `fax.received` - **Queue:** `queue.call.queued`, `queue.call.dispatched`, `queue.call.answered`, `queue.call.abandoned`, `queue.call.timed_out`, `queue.call.completed`, `queue.call.callback_requested`, `queue.call.callback_attempted`, `queue.call.callback_failed` - **Device provisioning:** `device.provisioned`, `device.config_fetched`, `device.provisioning_failed` — nothing in a simulated call fetches a device configuration, so these are sent on demand. (A real phone provisioned against a sandbox account emits them for real, on the same delivery path.) - **Billable count changes:** `user.created`, `user.deleted`, `phone_number.activated`, `phone_number.disconnected` — sandbox users and numbers are never billed, so these are sent on demand with illustrative counts rather than being driven by sandbox provisioning. - **Other notifications** (not part of any call, fax, or queue flow): `recording.failed`, `call.mobile_push_wakeup` An unrecognized type returns `400` with the current list of supported types. Like the other test endpoints, this is sandbox-only; a live key receives a `400`. ## Recordings and transcripts Answered simulated calls produce a sample recording, and the full recording → transcript → summary → sentiment path runs end to end on sample content: - `recording.available` fires, and the signed download URL from `GET /v1/calls/{id}/recording` serves real audio bytes. - `recording.transcription.complete` and `recording.summary.complete` follow. `GET /v1/calls/{id}/transcript` returns a completed transcript with verbatim text, and the call record's `summary` field (also on `GET /v1/calls/{id}`) carries the summary. - The transcript and the call record also carry a `sentiment` object, with a per-speaker `local` and `remote` breakdown. The sample pool is mostly neutral — matching how real business calls tend to score — with a positive, a negative, and a polarized case (near-zero score, high magnitude) so you can exercise every rendering path. ## Related - [Webhook Events](./webhook-events.md) for the full event catalog and payload shapes. - [Activity Logging](./activity-logging.md) for the receive, dedupe, and write pattern. - [Screen Pop](./screen-pop.md) for handling inbound calls. - [Click to Call](./click-to-call.md) for the outbound flow. --- ## Voice Apps(Guides) import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Voice Apps Build programmable voice applications with webhooks and real-time audio streaming. ## Overview Voice apps let you handle calls programmatically. DialStack notifies your server via webhook, and you decide what happens next. Voice apps support two modes: **Call Control** — Your server takes ownership of the call. Connect bidirectional audio for AI voice assistants, transfer calls to extensions, or build IVR systems. **Call Listening** — Stream real-time audio from calls without affecting them. Use this for live monitoring, real-time transcription, or analytics. Both modes start with a webhook notification to your server. The webhook's `event` field tells you which mode triggered it. ## Installation Install the DialStack server SDK for Node.js: ```bash npm install @dialstack/sdk-server ``` Initialize the client with your API key: ```javascript import { DialStack } from '@dialstack/sdk-server'; const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); ``` ## Creating a Voice App ```javascript const voiceApp = await dialstack.voiceApps.create( { name: 'AI Receptionist', url: 'https://your-server.example.com/voice/webhook' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/voice-apps \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "name": "AI Receptionist", "url": "https://your-server.example.com/voice/webhook" }' ``` Response: ```json { "id": "va_01h2xcejqtf2nbrexx3vqjhp49", "name": "AI Receptionist", "url": "https://your-server.example.com/voice/webhook", "status": "active", "secret": "whsec_abc123def456...", "created_at": "2025-10-18T10:00:00Z", "updated_at": "2025-10-18T10:00:00Z" } ``` **Important:** Save the `secret` value - you'll need it to verify webhook signatures. ## Webhook Notifications When a call reaches your voice app, DialStack sends an HTTP POST to your webhook URL. The same voice app can receive both event types — the `event` field tells you which one. ### Webhook Events | Event | Description | Trigger | | --------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------- | | `call.received` | A call has been routed to this voice app for handling | Voice app is the call destination (extension or dial plan) | | `call.notify` | A call is passing through a Voice App (Notify) node in a dial plan | Voice App (Notify) node in a dial plan references this voice app | For `call.received`, your server takes control of the call — use the Update Call API to attach audio, transfer, etc. For `call.notify`, the call continues routing normally — use the Listeners API to stream audio if desired. ### Webhook Payload ```http POST /voice/webhook HTTP/1.1 Host: your-server.example.com Content-Type: application/json X-DialStack-Signature: t=1697634600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd { "event": "call.received", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "voice_app_id": "va_01h2xcejqtf2nbrexx3vqjhp49", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876" } ``` Both `call.received` and `call.notify` use the same payload shape. The `event` field is the only difference. ### Verifying Signatures Verify webhook signatures using the voice app's `secret` to ensure requests are from DialStack: ```javascript const event = dialstack.webhooks.constructEvent( req.rawBody, req.headers['x-dialstack-signature'], process.env.VOICE_APP_SECRET ); // event contains: event, call_id, account_id, voice_app_id, from_number, from_name, to_number ``` ```javascript const crypto = require('crypto'); function verifySignature(payload, signature, secret) { const [tPart, v1Part] = signature.split(','); const timestamp = tPart.split('=')[1]; const expectedSig = v1Part.split('=')[1]; // Reject old timestamps (replay protection) const age = Date.now() / 1000 - parseInt(timestamp); if (age > 300) { // 5 minutes return false; } // Compute expected signature const signedPayload = `${timestamp}.${payload}`; const computedSig = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); // Constant-time comparison return crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(computedSig)); } ``` ### Webhook Response Return `200 OK` to acknowledge receipt. The response body is ignored. ```javascript app.post('/voice/webhook', (req, res) => { const { event, call_id } = req.body; // Acknowledge immediately res.sendStatus(200); // Handle based on event type if (event === 'call.received') { handleCallControl(call_id); } else if (event === 'call.notify') { handleCallNotify(call_id); } }); ``` ## Voice App Dial Plan Nodes Voice apps can be used in dial plans in two modes, selected by the `mode` field on the `voice_app` node: - **Control mode** — shipping today. Appears as the **Voice App** node in the editor. - **Notify mode** — planned (see [Coming soon](#voice-app-notify) below). In the target design, the editor will split these into two distinct palette entries (**Voice App (Control)** and **Voice App (Notify)**); today there is a single Voice App node that operates in control mode. ### Voice App (Control) Routes the call to the voice app. Your server receives a `call.received` webhook and takes ownership of the call — attaching audio, transferring, etc. ```json { "id": "ai_receptionist", "type": "voice_app", "config": { "voice_app_id": "va_01h2xcejqtf2nbrexx3vqjhp49", "mode": "control", "next": "voicemail" } } ``` ### Voice App (Notify) :::info[Coming soon] Voice App (Notify) mode is currently undergoing implementation and will be available shortly. The surface documented below reflects the target design; specifics may change before release. ::: Sends a fire-and-forget notification to the voice app as the call passes through, without interrupting call routing. This is useful for triggering external actions (real-time transcription, call analytics, CRM logging) alongside normal call handling. ``` ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌─────────┐ │ Schedule │────▶│ Voice App │────▶│ Dial │────▶│Voicemail│ │ Node │ │ (Notify) │ │ User │ │ │ └──────────┘ └──────┬───────┘ └──────────┘ └─────────┘ │ │ POST (fire-and-forget) ▼ ┌─────────────┐ │ Your Server │ └─────────────┘ ``` The Voice App (Notify) node: - Sends an HTTP POST to the voice app's URL with `"event": "call.notify"` - Immediately continues to the next node — it does **not** wait for a response - Does **not** answer or interrupt the call - Uses the same signature verification as `call.received` webhooks ```json { "id": "notify_transcription", "type": "voice_app", "config": { "voice_app_id": "va_01h2xcejqtf2nbrexx3vqjhp49", "mode": "notify", "next": "dial_reception" } } ``` ## Call Control When your voice app receives a `call.received` webhook, your server takes ownership of the call and controls it via the Update Call API. ``` ┌─────────┐ ┌───────────┐ ┌─────────────┐ │ Caller │ │ DialStack │ │ Your Server │ └────┬────┘ └─────┬─────┘ └──────┬──────┘ │ │ │ │ 1. Call arrives │ │ │────────────────────────▶│ │ │ │ │ │ │ 2. Webhook POST │ │ │─────────────────────────▶│ │ │ │ │ │ 3. POST /v1/calls/{id} │ │ │◀─────────────────────────│ │ │ (attach audio) │ │ │ │ │ 4. Bidirectional audio │ │ │◀───────────────────────▶│◀────────────────────────▶│ │ (WebSocket) │ (WebSocket) │ │ │ │ ``` ### Actions Use the Update Call API to send actions. Actions are processed sequentially. #### Attach Audio Stream Connect bidirectional audio to your WebSocket server (see [WebSocket API](/websocket-api) for the message protocol): ```javascript await dialstack.calls.update( callId, { actions: [{ type: 'attach', url: 'wss://your-server.example.com/voice/stream' }] }, { dialstackAccount: accountId } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/calls/call_01h2xcejqtf2nbrexx3vqjhp45 \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "actions": [ {"type": "attach", "url": "wss://your-server.example.com/voice/stream"} ] }' ``` The `attach` action blocks until the WebSocket disconnects, then processing continues with the next action. #### Transfer Transfer the caller to an extension, an E.164 phone number, or a SIP address: ```javascript await dialstack.calls.update( callId, { actions: [{ type: 'transfer', target: '100' }] }, { dialstackAccount: accountId } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/calls/call_01h2xcejqtf2nbrexx3vqjhp45 \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "actions": [ {"type": "transfer", "target": "100"} ] }' ``` If the transfer target answers or the caller hangs up, processing stops. If the transfer fails (no answer, busy), processing continues with the next action. ##### Transfer to a SIP address (e.g. an AI voice agent) The `target` can also be a `sip:` URI, which routes the live call to an external SIP endpoint such as an AI voice agent. Your server provides the full address — for example, after registering the call with your agent provider and receiving an identifier to dial: ```javascript await dialstack.calls.update( callId, { actions: [{ type: 'transfer', target: 'sip:agent-7f3c@sip.example-ai.com' }] }, { dialstackAccount: accountId } ); ``` The audio is connected over plain RTP. You can append `;transport=tcp` or `;transport=udp` to the URI to select the signaling transport. #### Combining Actions Chain actions for fallback behavior: ```json { "actions": [ { "type": "attach", "url": "wss://ai.example.com/voice" }, { "type": "transfer", "target": "100" } ] } ``` This connects to your AI voice assistant first. When the WebSocket disconnects (e.g., AI hands off), the call transfers to extension 100. #### Replacing Actions Sending a new update replaces all pending actions immediately. The current action is interrupted, and processing starts from the first action in the new list. ```javascript // AI decides to transfer the call await dialstack.calls.update( callId, { actions: [{ type: 'transfer', target: '100' }] }, { dialstackAccount: accountId } ); ``` ```javascript // AI decides to transfer the call await fetch(`https://api.dialstack.ai/v1/calls/${callId}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'DialStack-Account': accountId, 'Content-Type': 'application/json', }, body: JSON.stringify({ actions: [{ type: 'transfer', target: '100' }], }), }); ``` ### WebSocket Audio Streaming When DialStack executes an `attach` action, it connects to your WebSocket URL and streams audio bidirectionally. For the complete protocol specification, see the [WebSocket API](/websocket-api) reference. #### Audio Format | Property | Value | | ----------- | ------------------------------- | | Encoding | μ-law (G.711) | | Sample rate | 8000 Hz | | Channels | 1 (mono) | | Chunk size | ~20ms (160 bytes before base64) | | Bandwidth | ~8 KB/second | #### Messages from DialStack **Begin** — Sent when connection is established: ```json { "event": "begin", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "audio_format": { "encoding": "audio/x-mulaw", "sample_rate": 8000, "channels": 1 } } ``` **Audio** — Caller's audio (sent continuously): ```json { "event": "audio", "timestamp": 1234, "payload": "base64-encoded-mulaw-audio" } ``` #### Messages to DialStack **Audio** — Audio to play to the caller: ```json { "event": "audio", "payload": "base64-encoded-mulaw-audio" } ``` #### Ending the Session Either side can close the WebSocket to end the audio session. When closed, DialStack continues processing with the next action (if any). #### Using MediaStream (SDK) The SDK provides a `MediaStream` class that handles WebSocket message parsing and provides a clean event-based API: ```javascript import { MediaStream } from '@dialstack/sdk-server'; import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', (ws) => { const stream = new MediaStream(ws); stream.on('begin', (event) => { console.log('Call started:', event.call_id); console.log('Audio format:', event.audio_format); // Send greeting audio stream.sendAudio(greetingAudioBase64); }); stream.on('audio', (event) => { // event.payload contains base64-encoded μ-law audio // event.timestamp contains the audio timestamp // Process with your AI pipeline and respond const responseAudio = processAudio(event.payload); stream.sendAudio(responseAudio); // Or send raw Buffer (auto base64-encoded) stream.sendAudioBuffer(audioBuffer); }); stream.on('close', (event) => { console.log('Call ended:', event.code, event.reason); }); stream.on('error', (event) => { console.error('Stream error:', event.error); }); }); ``` ### Complete Example: AI Voice Assistant ```javascript import express from 'express'; import { WebSocketServer } from 'ws'; import { DialStack, MediaStream } from '@dialstack/sdk-server'; const app = express(); app.use( express.json({ verify: (req, res, buf) => { req.rawBody = buf; }, }) ); const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); const VOICE_APP_SECRET = process.env.VOICE_APP_SECRET; // Webhook endpoint app.post('/voice/webhook', async (req, res) => { let event; try { event = dialstack.webhooks.constructEvent( req.rawBody, req.headers['x-dialstack-signature'], VOICE_APP_SECRET ); } catch (err) { return res.sendStatus(401); } const { call_id, account_id, from_number, from_name } = event; console.log(`Incoming call from ${from_name || from_number}`); res.sendStatus(200); // Attach audio stream with fallback transfer await dialstack.calls.update( call_id, { actions: [ { type: 'attach', url: 'wss://your-server.example.com/voice/stream' }, { type: 'transfer', target: '100' }, ], }, { dialstackAccount: account_id } ); }); // WebSocket server for audio streaming const wss = new WebSocketServer({ noServer: true }); wss.on('connection', (ws) => { const stream = new MediaStream(ws); stream.on('begin', (event) => { console.log(`Audio stream started for call ${stream.callId}`); stream.sendAudio(generateGreetingAudio()); }); stream.on('audio', (event) => { const audioBuffer = Buffer.from(event.payload, 'base64'); processAudioWithAI(audioBuffer, (responseAudio) => { stream.sendAudio(responseAudio); }); }); stream.on('close', () => { console.log(`Audio stream ended for call ${stream.callId}`); }); }); const server = app.listen(3000); server.on('upgrade', (request, socket, head) => { if (request.url === '/voice/stream') { wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); } else { socket.destroy(); } }); ``` ```javascript const express = require('express'); const WebSocket = require('ws'); const crypto = require('crypto'); const app = express(); app.use( express.json({ verify: (req, res, buf) => { req.rawBody = buf; }, }) ); const API_KEY = process.env.DIALSTACK_API_KEY; const VOICE_APP_SECRET = process.env.VOICE_APP_SECRET; const ACCOUNT_ID = process.env.DIALSTACK_ACCOUNT_ID; // Webhook endpoint app.post('/voice/webhook', async (req, res) => { const signature = req.headers['x-dialstack-signature']; if (!verifySignature(req.rawBody.toString(), signature, VOICE_APP_SECRET)) { return res.sendStatus(401); } const { call_id, from_number, from_name } = req.body; console.log(`Incoming call from ${from_name || from_number}`); res.sendStatus(200); await fetch(`https://api.dialstack.ai/v1/calls/${call_id}`, { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'DialStack-Account': ACCOUNT_ID, 'Content-Type': 'application/json', }, body: JSON.stringify({ actions: [ { type: 'attach', url: 'wss://your-server.example.com/voice/stream' }, { type: 'transfer', target: '100' }, ], }), }); }); // WebSocket server for audio streaming const wss = new WebSocket.Server({ noServer: true }); wss.on('connection', (ws) => { let callId; ws.on('message', (data) => { const message = JSON.parse(data); switch (message.event) { case 'begin': callId = message.call_id; console.log(`Audio stream started for call ${callId}`); ws.send(JSON.stringify({ event: 'audio', payload: generateGreetingAudio() })); break; case 'audio': const audioBuffer = Buffer.from(message.payload, 'base64'); processAudioWithAI(audioBuffer, (responseAudio) => { ws.send(JSON.stringify({ event: 'audio', payload: responseAudio })); }); break; } }); ws.on('close', () => { console.log(`Audio stream ended for call ${callId}`); }); }); const server = app.listen(3000); server.on('upgrade', (request, socket, head) => { if (request.url === '/voice/stream') { wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); } else { socket.destroy(); } }); function verifySignature(payload, signature, secret) { const [tPart, v1Part] = signature.split(','); const timestamp = tPart.split('=')[1]; const expectedSig = v1Part.split('=')[1]; const age = Date.now() / 1000 - parseInt(timestamp); if (age > 300) return false; const signedPayload = `${timestamp}.${payload}`; const computedSig = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(computedSig)); } ``` ## Listeners When your voice app receives a `call.notify` webhook, you can create a listener to stream real-time audio from the call. Audio flows one way only — from DialStack to your server; the listener is passive and does not inject audio or alter the call. Neither party hears a tone or indication that a listener is attached, so **you are responsible for obtaining appropriate consent** from the parties on the call in accordance with applicable law (recording-consent requirements vary by jurisdiction). ``` ┌────────┐ ┌───────────┐ ┌─────────────┐ │ Caller │ │ DialStack │ │ Your Server │ └───┬────┘ └─────┬─────┘ └──────┬──────┘ │ │ │ │ Normal two-party call │ │ │◀─────────────────────────▶│ │ │ │ │ │ │ 1. Webhook (call.notify) │ │ │──────────────────────────▶│ │ │ │ │ │ 2. POST /v1/calls/{id}/ │ │ │ listeners │ │ │◀──────────────────────────│ │ │ │ │ Call continues normally │ 3. Audio (one-way WSS) │ │◀─────────────────────────▶│──────────────────────────▶│ │ │ │ ``` 1. A Voice App (Notify) node notifies your server that a call has started 2. Your server creates a listener on that call 3. DialStack opens a WebSocket to your server and streams audio ### Creating a Listener ```javascript const listener = await dialstack.calls.createListener( callId, { url: 'wss://your-server.example.com/audio', channel: 'both', metadata: { agent_id: 'user_123', queue: 'support' }, }, { dialstackAccount: accountId } ); ``` ```bash curl -X POST https://api.dialstack.ai/v1/calls/call_01h2xcejqtf2nbrexx3vqjhp45/listeners \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \ -H "Content-Type: application/json" \ -d '{ "url": "wss://your-server.example.com/audio", "channel": "both", "metadata": {"agent_id": "user_123", "queue": "support"} }' ``` ### Channel Selection | Channel | Audio received | | -------- | ---------------------------------------------------- | | `caller` | Audio from the party that initiated the call | | `callee` | Audio from the party that received the call | | `both` | Both channels, delivered as separate tagged messages | ### Stopping a Listener Listeners stop automatically when the call ends. To stop early: ```javascript await dialstack.calls.deleteListener(callId, listenerId, { dialstackAccount: accountId, }); ``` ```bash curl -X DELETE https://api.dialstack.ai/v1/calls/call_.../listeners/lstn_... \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" ``` ### Listener WebSocket Protocol The listener WebSocket protocol extends the voice app protocol with channel tagging and an end message. See the [WebSocket API](/websocket-api) for the full specification. **Begin** — Sent when connection is established: ```json { "event": "begin", "listener_id": "lstn_01h2xcejqtf2nbrexx3vqjhp50", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "channel": "both", "metadata": { "agent_id": "user_123", "queue": "support" }, "audio_format": { "encoding": "audio/x-mulaw", "sample_rate": 8000, "channels": 1 } } ``` The `listener_id` field distinguishes listener sessions from voice app sessions, allowing the same server to handle both. **Audio** — Call audio, tagged by channel: ```json { "event": "audio", "channel": "caller", "timestamp": 1234, "payload": "base64-encoded-mulaw-audio" } ``` **End** — Sent when the listener stops: ```json { "event": "end", "listener_id": "lstn_01h2xcejqtf2nbrexx3vqjhp50", "reason": "call_ended" } ``` Reasons: `call_ended`, `deleted` (stopped via API), `error`. ### Complete Example: Real-Time Transcription ```javascript import express from 'express'; import { WebSocketServer } from 'ws'; import { DialStack } from '@dialstack/sdk-server'; const app = express(); app.use( express.json({ verify: (req, res, buf) => { req.rawBody = buf; }, }) ); const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); const VOICE_APP_SECRET = process.env.VOICE_APP_SECRET; // Webhook endpoint — receives call.notify from Voice App (Notify) dial plan node app.post('/voice/webhook', async (req, res) => { let event; try { event = dialstack.webhooks.constructEvent( req.rawBody, req.headers['x-dialstack-signature'], VOICE_APP_SECRET ); } catch (err) { return res.sendStatus(401); } res.sendStatus(200); if (event.event === 'call.notify') { // Create a listener to stream audio for transcription await dialstack.calls.createListener( event.call_id, { url: 'wss://your-server.example.com/audio', channel: 'both', metadata: { from: event.from_number, to: event.to_number }, }, { dialstackAccount: event.account_id } ); } }); // WebSocket server for receiving listener audio const wss = new WebSocketServer({ noServer: true }); wss.on('connection', (ws) => { let listenerId; ws.on('message', (data) => { const message = JSON.parse(data); switch (message.event) { case 'begin': listenerId = message.listener_id; console.log(`Listening to call ${message.call_id} (${message.channel})`); break; case 'audio': // Send to your speech-to-text service transcribe(message.payload, message.channel); break; case 'end': console.log(`Listener ${listenerId} stopped: ${message.reason}`); break; } }); }); const server = app.listen(3000); server.on('upgrade', (request, socket, head) => { if (request.url === '/audio') { wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); } else { socket.destroy(); } }); ``` ## API Reference - [Voice Apps](/api#tag/Voice-Apps) — Create and manage voice apps - [Update Call](/api#tag/Calls/operation/updateCall) — Control active calls with actions - [Listeners](/api#tag/Calls/operation/createListener) — Stream real-time audio from active calls - [WebSocket API](/websocket-api) — Audio streaming protocol (voice apps and listeners) --- ## BYO VoiceAI DialStack is agent-agnostic. Bring your own AI stack and plug it into the call. Two integration patterns cover every BYO scenario. ## Two patterns | Pattern | What your AI does | Primitive | | -------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- | | **BYO Receptionist** | Answers the call, talks with the caller, decides what happens next | **Voice App (Control)** mode + `attach` action | | **BYO Observer** | Listens to the call for transcription / coaching / analytics, doesn't interrupt it | **Voice App (Notify)** mode + **Listeners API** | Full Voice App semantics: [Voice Apps](/guides/voice-apps). ## Pattern 1: BYO Receptionist (bidirectional) The AI answers the call and talks with the caller. You run the prompt, the tools, and the transcription; DialStack handles telephony. ### Flow ```mermaid sequenceDiagram participant Caller participant DS as DialStack participant You as Your server participant AI as Your AI (WS) Caller->>DS: Incoming call hits a Voice App (Control) node DS->>You: Webhook: call.received You->>DS: POST /v1/calls/:id { actions: [{ type: 'attach', url: 'wss://ai.example.com/stream' }] } DS->>AI: Opens WebSocket — caller audio frames AI->>DS: Agent audio frames Note over Caller,AI: Bidirectional conversation AI-->>DS: (optional) close socket or send transfer via a new actions update You->>DS: POST /v1/calls/:id { actions: [{ type: 'transfer', target: '100' }] } DS->>Caller: Transfer to human ``` ### Create the Voice App ```bash curl -X POST https://api.dialstack.ai/v1/voice-apps \ -H 'Authorization: Bearer sk_live_YOUR_KEY' \ -H 'DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41' \ -H 'Content-Type: application/json' \ -d '{ "name": "BYO Receptionist", "url": "https://your-server.example.com/voice/webhook" }' ``` ### Handle the webhook and attach audio ```ts // POST /voice/webhook app.post('/voice/webhook', express.raw({ type: 'application/json' }), async (req, res) => { verifySignature(req); // see voice-apps.md const event = JSON.parse(req.body); if (event.event === 'call.received') { // Open the WebSocket for this call await ds.calls.update( event.call.id, { actions: [{ type: 'attach', url: 'wss://your-server.example.com/voice/stream' }], }, { dialstackAccount: event.account_id } ); } res.status(200).end(); }); ``` ### The audio WebSocket Your service accepts a WebSocket at the URL you passed to `attach`. DialStack streams caller audio as μ-law 8 kHz frames (~20 ms each, base64-encoded JSON messages). You send agent audio back in the same format on the same socket. Full protocol — framing, control messages, error handling: [WebSocket API](/websocket-api). ### Worked example: ElevenLabs Conversational AI A bridge server sits between the two WebSockets and handles audio transcoding. Sketch: ```ts import WebSocket from 'ws'; // DialStack opens this socket when your webhook responds with `attach`. wss.on('connection', async (dsSocket) => { // 1. Open the ElevenLabs Conversational AI WebSocket. const elSocket = new WebSocket( `wss://api.elevenlabs.io/v1/convai/conversation?agent_id=${process.env.ELEVENLABS_AGENT_ID}`, { headers: { 'xi-api-key': process.env.XI_API_KEY } } ); // 2. Caller audio → ElevenLabs: μ-law 8 kHz → PCM 16 kHz. dsSocket.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type !== 'audio') return; const mulaw = Buffer.from(msg.payload, 'base64'); const pcm16 = upsample8kTo16k(mulawToPcm(mulaw)); elSocket.send(JSON.stringify({ user_audio_chunk: pcm16.toString('base64') })); }); // 3. ElevenLabs audio → caller: PCM 16 kHz → μ-law 8 kHz. elSocket.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type !== 'audio') return; const pcm16 = Buffer.from(msg.audio_event.audio_base_64, 'base64'); const mulaw = pcmToMulaw(downsample16kTo8k(pcm16)); dsSocket.send(JSON.stringify({ type: 'audio', payload: mulaw.toString('base64') })); }); dsSocket.on('close', () => elSocket.close()); elSocket.on('close', () => dsSocket.close()); }); ``` Transcoding helpers (`mulawToPcm`, `pcmToMulaw`, `upsample8kTo16k`, `downsample16kTo8k`) are short — under 50 lines of DSP. Any Node audio library (e.g., `alawmulaw`, `node-libsamplerate`) will do. ElevenLabs tool-use webhooks (for database lookups, appointment booking, etc.) are plain HTTPS endpoints configured on the agent — unrelated to the DialStack WebSocket. Configure them once when you create the agent via ElevenLabs' REST API. The same shape works for OpenAI Realtime, Vapi, Retell, or a self-hosted model — swap the outbound WebSocket and the frame format. ### Routing Drop a Voice App (Control) node into any dial plan: ```ts { id: 'ai_reception', type: 'internal_dial', config: { target_id: 'va_01h2xcejqtf2nbrexx3vqjhp49' }, // your voice app ID } ``` ## Pattern 2: BYO Observer (one-way) The AI listens to calls for real-time transcription, coaching, or analytics. It never talks to the caller and doesn't affect call routing. :::note Notify nodes must reference a plain webhook voice app. The voice app managed by an AI Agent resource (`/v1/ai-agents`) is built to drive calls and cannot be used as a Notify target — dial-plan create/update will return 422. ::: ### Flow ```mermaid sequenceDiagram participant Caller participant DS as DialStack participant You as Your server participant AI as Your AI (WS) Caller->>DS: Call passes through a Voice App (Notify) node DS->>You: Webhook: call.notify (fire-and-forget) You->>DS: POST /v1/calls/:id/listeners { url: 'wss://ai.example.com/listen' } DS->>AI: Opens WebSocket — caller audio frames (one-way) Note over Caller,DS: Call continues routing normally to its real destination ``` ### Create the listener ```ts if (event.event === 'call.notify') { await ds.calls.createListener( event.call.id, { url: 'wss://your-server.example.com/voice/listen' }, { dialstackAccount: event.account_id } ); } ``` Both parties on the call are unaware that a listener is attached. Listeners stop automatically when the call ends. ### Routing Use a Voice App (Notify) node alongside the real routing: ```ts { id: 'transcribe', type: 'internal_dial', config: { target_id: 'va_notify_id', mode: 'notify' }, } ``` ## Choosing between them - **Your AI should talk** → Pattern 1 (Control mode + `attach`). - **Your AI should listen** → Pattern 2 (Notify mode + Listeners API). - **Both** → a dial plan can route through a Notify node first (transcription) and then to a Control node (AI agent). Or the Control-mode AI can itself open a sidecar listener. ## Compared to DialStack-managed AI Agents DialStack ships a first-party AI receptionist via [AI Scheduling](/guides/voiceai-scheduling) / the [AI Agents API](/api#tag/AI-Agents). Reach for BYO when: - You already run an AI stack your team knows. - You need a specific model, voice, or data-control guarantee. - You want full control over the prompt, tools, and transcripts. ## See also - [Voice Apps](/guides/voice-apps) — full Voice App documentation including actions (`attach`, `transfer`, combined/sequenced). - [Dial Plans](/guides/dial-plans) — how Voice App nodes slot into call routing. - [WebSocket API](/websocket-api) — media-streaming protocol for both `attach` and Listeners. - [AI Scheduling](/guides/voiceai-scheduling) — the native alternative, when you don't need to bring your own. --- ## AI Scheduling Let callers book appointments over the phone without talking to a human. DialStack handles the voice conversation — greeting, slot negotiation, callback collection — and calls your system to read availability and write the booking. AI Scheduling is powered by the [AI Agents API](/api#tag/AI-Agents). Each AI agent auto-provisions a voice app and an extension, has a configurable persona and FAQ, and optionally hands off call handling to your scheduling endpoints. ## What you build Three HTTPS endpoints under a base URL you give us: | Path | Purpose | | --------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `POST {base}/customers/lookup` | Match the caller's phone number to an existing customer record (optional — used to personalize the greeting). | | `POST {base}/availability/search` | Return open slots for a requested date. | | `POST {base}/bookings` | Create a booking for the slot the caller picked. | DialStack relays each request synchronously: your response shape is what the AI presents to the caller. Full request / response payload definitions: [Appointment Webhooks](/guides/appointment-webhooks). ## Create the agent Configure the agent once via `POST /v1/ai-agents`. Point `scheduling.webhook_url` at the base URL that hosts the three paths above. ```bash curl -X POST https://api.dialstack.ai/v1/ai-agents \ -H 'Authorization: Bearer sk_live_YOUR_KEY' \ -H 'DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41' \ -H 'Content-Type: application/json' \ -d '{ "name": "Front Desk Receptionist", "extension_number": "200", "persona_name": "Tony", "greeting_name": "Jones Family Dental", "instructions": "You are the receptionist. Be warm and professional. Offer to book an appointment if the caller does not specify a reason.", "faq_responses": [ { "question": "What are your business hours?", "answer": "We are open Monday through Friday, 9 AM to 5 PM." }, { "question": "Do you take walk-ins?", "answer": "Walk-ins are welcome, but appointments are prioritized." } ], "scheduling": { "webhook_url": "https://api.clinic.io/dialstack" } }' ``` The response includes `id` (`aia_...`) and `voice_app_id` — the voice app is managed automatically. Extension `200` now routes callers to the agent. ## How a booking call plays out ```mermaid sequenceDiagram participant Caller participant DS as DialStack participant You as Your API Caller->>DS: Dials your number (reaches extension 200) DS->>You: POST /customers/lookup { phone: "+14155551234" } You-->>DS: { customer_id: "c_42", name: "Alex Kim" } DS->>Caller: "Hi Alex, thanks for calling Jones Family Dental. This is Tony. How can I help?" Caller->>DS: "I need an appointment next Tuesday afternoon." DS->>You: POST /availability/search { date: "2026-04-28", window: "afternoon" } You-->>DS: { slots: ["13:00", "14:30", "16:00"] } DS->>Caller: "I have 1 PM, 2:30 PM, or 4 PM available — which works?" Caller->>DS: "2:30 works." DS->>You: POST /bookings { customer_id: "c_42", slot: "2026-04-28T14:30:00Z" } You-->>DS: { booking_id: "b_7f3", confirmation_sent: true } DS->>Caller: "Booked. You'll get a confirmation text shortly." ``` The AI handles disambiguation ("which Tuesday?"), date math, and re-prompting. You only see the clean, resolved request. ## Route calls to the agent Drop the agent into any [dial plan](/guides/dial-plans) via its auto-managed extension — point an `internal_dial` node at the agent's voice app: ```ts { id: 'ai_receptionist', type: 'internal_dial', config: { target_id: 'va_01h2xcejqtf2nbrexx3vqjhp49' }, // from voice_app_id } ``` Typical patterns: - **After-hours fallback** — schedule node → `ring_all_users` during business hours, `internal_dial` to the agent when closed. - **Overflow** — `ring_all_users` with a short timeout, `next` pointing at the agent when no one picks up. - **Direct line** — an inbound number routed straight at the agent for a 24/7 booking line. The landing hero shows all three patterns in one dial plan. ## Customizing the agent at call time Agent config (persona, instructions, FAQs, scheduling URL) can be updated via `POST /v1/ai-agents/{id}`. Changes take effect on the next call. Per-call overrides (account-specific context, time-sensitive promotions) are applied on your side when the AI calls `/customers/lookup` — the response shape carries arbitrary metadata that the agent uses in the conversation. ## See also - [Appointment Webhooks](/guides/appointment-webhooks) — exact payload shapes for the three endpoints. - [Voice Apps](/guides/voice-apps) — the primitive AI Agents are built on. - [Dial Plans](/guides/dial-plans) — how to route calls to the agent. - [BYO VoiceAI](/guides/voiceai-byo) — if you want to run the conversation yourself instead of using DialStack's AI. - [AI Agents API](/api#tag/AI-Agents) — full REST surface. --- ## Webhook Events Receive real-time notifications about calls, recordings, voicemails, transcriptions, and faxes via HTTP webhooks. Webhook Events is the **backend transport** for call events. For the **browser transport**, see [Real-Time Events (SSE)](./events.md). For common patterns built on top, see [Screen Pop](./screen-pop.md) and [Activity Logging](./activity-logging.md). ## Overview Webhook events are push notifications that DialStack sends to your server as things happen in the system. Unlike [appointment webhooks](./appointment-webhooks.md) (which are request/response), event webhooks are fire-and-forget notifications — return `200` to acknowledge receipt. Webhook events are **platform-scoped**: register webhook endpoints on your platform, and DialStack delivers events for all accounts on that platform to the endpoints that match. For frontend real-time notifications (account-scoped), see [Real-Time Events (SSE)](./events.md). ## Configuration Register one or more **webhook endpoints** with the [Webhook Endpoints API](/api#tag/Webhook-Endpoints). Each endpoint has: - a `url` that receives event POST requests, - a `secret` used for [signature verification](#signature-verification) (generated by DialStack and returned **only** in the create response — store it then, it cannot be retrieved later), - `enabled_events`, the list of [event types](#event-types) it subscribes to (defaults to `["*"]`, all events), - a `status` (`enabled` or `disabled`). ```bash curl https://api.dialstack.ai/v1/webhook_endpoints \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks", "enabled_events": ["call.answered", "call.end"] }' ``` Or with the SDK: ```typescript const endpoint = await dialstack.webhookEndpoints.create({ url: 'https://example.com/webhooks', enabled_events: ['call.answered', 'call.end'], // omit for all events }); console.log(endpoint.secret); // shown only once — store it ``` ### Sandbox vs live keys Webhook endpoints are **environment-scoped**, like the rest of the API. For an overview of sandbox vs. live keys and how the two environments are isolated, see [Authentication](/guides/authentication#sandbox-vs-live-keys). The environment is determined by the key you create the endpoint with and is fixed for the life of the endpoint (reported as `livemode` on the object): - An endpoint created with a **live** key (`sk_live_...`) receives events only from your live accounts. - An endpoint created with a **sandbox** key (`sk_test_...`) receives events only from your sandbox accounts. This lets you point sandbox traffic at a separate backend: a sandbox account's events are **never** delivered to a live endpoint, and vice versa. If no endpoint matches an event's environment, the event is simply not delivered. Listing endpoints with a given key returns only that environment's endpoints. ## Event Types | Event | Description | | ---------------------------------- | ------------------------------------------------------------ | | `call.initiated` | Outbound call started | | `call.incoming` | Inbound call received | | `call.ringing` | A user's phones are about to ring | | `call.mobile_push_wakeup` | Send a push to wake the user's mobile app for this call | | `call.answered` | Call answered | | `call.end` | Call ended | | `call.transfer` | Call transferred | | `call.emergency` | An emergency number was dialed (911 or regional equivalent) | | `call.command_succeeded` | A call-control command completed successfully | | `call.command_failed` | A call-control command did not complete | | `queue.call.queued` | Caller entered a call queue | | `queue.call.dispatched` | Queue dispatched the caller to agent targets | | `queue.call.answered` | Queue caller was answered by an agent | | `queue.call.abandoned` | Caller hung up while waiting in queue | | `queue.call.timed_out` | Queue wait timed out before an agent answered | | `queue.call.completed` | Answered queue call completed | | `queue.call.callback_requested` | Caller requested a queue callback | | `queue.call.callback_attempted` | Queue callback attempt completed | | `queue.call.callback_failed` | Queue callback request reached a terminal failure | | `fax.received` | Inbound fax received | | `fax.delivered` | Outbound fax delivered successfully | | `fax.failed` | Outbound fax failed to send | | `voicemail.new` | New voicemail received | | `recording.available` | Call recording ready for download | | `recording.failed` | Call recording could not be prepared (e.g. redaction failed) | | `recording.transcription.complete` | Call transcript ready | | `recording.summary.complete` | AI call summary and sentiment ready | | `voicemail.transcription.complete` | Voicemail transcript ready | | `voicemail.summary.complete` | AI voicemail summary and sentiment ready | | `device.provisioned` | Deskphone or DECT base completed its first provisioning | | `device.config_fetched` | A device fetched its configuration (every boot / check-sync) | | `device.provisioning_failed` | A device's configuration could not be generated | | `user.created` | A user was created and is now billable | | `user.deleted` | A user was deleted and is no longer billable | | `phone_number.activated` | A phone number went into service and is now billable | | `phone_number.disconnected` | A phone number left service and is no longer billable | ## Event Envelope All webhook events share this envelope structure: ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v2", "type": "call.end", "created_at": "2026-01-15T14:35:30Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": {} } ``` | Field | Type | Description | | ------------ | ------ | ------------------------------------------------------- | | `id` | string | Unique event identifier. Use for idempotent processing. | | `type` | string | Event type (see table above). | | `created_at` | string | ISO 8601 timestamp when the event was created. | | `account_id` | string | Account where the event occurred. | | `data` | object | Event-specific payload (see below). | :::note[The SSE transport uses a different shape] The same call events are also delivered over the [Real-Time Events (SSE)](./events.md) stream, but **without this envelope**: SSE payloads are flat (no `id`/`created_at`/`data` wrapper) and name the event-type field `event` rather than `type`. Treat the two transports as separate schemas — see the [`GET /v1/events` reference](/api#tag/Events) for the authoritative SSE field names. ::: ## Event Payloads ### `call.initiated` Sent when an outbound call starts dialing. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v2", "type": "call.initiated", "created_at": "2026-01-15T14:30:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "direction": "outbound", "from_number": "+14155559876", "to_number": "+14155551234", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "from_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "started_at": "2026-01-15T14:30:00Z" } } ``` ### `call.incoming` Sent when an inbound call is received. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v3", "type": "call.incoming", "created_at": "2026-01-15T14:30:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "direction": "inbound", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "to_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "started_at": "2026-01-15T14:30:00Z" } } ``` | Field | Type | Description | | -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `call_id` | string | Call identifier | | `direction` | string | Always `"inbound"` | | `from_number` | string | Caller's phone number (E.164) | | `from_name` | string \| null | Caller's display name | | `to_number` | string | Called phone number (E.164) | | `user_id` | string \| null | User the call is routed to. Only set when the called number targets a single user directly; omitted when the call routes to a ring group, voice app, dial plan, or any other destination where a specific user is not known at this stage. | | `from_user_id` | string \| null | Calling user (DIA-801). See [User attribution](#user-attribution) below. | | `to_user_id` | string \| null | Called user (DIA-801). See [User attribution](#user-attribution) below. | | `started_at` | string | ISO 8601 timestamp | ### `call.ringing` Sent when a user's phones are about to ring. Fired once per (call, user) — a user with multiple phones (desk + mobile) produces a single event, not one per device. For a ring group or ring-all-users call, one event is emitted per user being rung, all sharing the same `call_id`. `user_id` always identifies the user whose phones are ringing, never the caller. Not emitted for the initiating user's own phone ringing during a click-to-call (leg A). ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v8", "type": "call.ringing", "created_at": "2026-01-15T14:30:01Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "direction": "inbound", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876", "ringing_at": "2026-01-15T14:30:01Z" } } ``` | Field | Type | Description | | ------------- | -------------- | --------------------------------------------------------------------------------------------------- | | `call_id` | string | Call identifier | | `user_id` | string | User whose phones are about to ring | | `direction` | string | `"inbound"`, `"outbound"` or `"internal"`. Absent if the platform could not determine the direction | | `from_number` | string | Caller's phone number (E.164) | | `from_name` | string \| null | Caller's display name | | `to_number` | string | Called phone number (E.164) | | `ringing_at` | string | ISO 8601 timestamp when the ring began | ### `call.mobile_push_wakeup` Sent when an incoming call is being delivered to the user's web/mobile calling session **and** the user has [`mobile_push_wakeup`](/webrtc/mobile#enabling-wake-up-hold) enabled. This is your cue to send the push notification that wakes the user's mobile app: when the app has no active session, the call is held for a wake-up window while your push → app wake-up → connect flow completes. Unlike `call.ringing` (which fires whenever a user is being reached, including when their calls forward to an external number), this event fires only when the user's own app session is actually being rung — every push it triggers corresponds to a real, answerable call. Use `call.ringing` for screen pop; use this event for mobile push delivery. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v9", "type": "call.mobile_push_wakeup", "created_at": "2026-01-15T14:30:01Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "direction": "inbound", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876", "ringing_at": "2026-01-15T14:30:01Z" } } ``` | Field | Type | Description | | ------------- | -------------- | --------------------------------------------------------------------------------------------------- | | `call_id` | string | Call identifier | | `user_id` | string | User whose app should be woken | | `direction` | string | `"inbound"`, `"outbound"` or `"internal"`. Absent if the platform could not determine the direction | | `from_number` | string | Caller's phone number (E.164) | | `from_name` | string \| null | Caller's display name | | `to_number` | string | Called phone number (E.164) | | `ringing_at` | string | ISO 8601 timestamp when delivery began | ### `call.answered` Sent when a call is answered. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v4", "type": "call.answered", "created_at": "2026-01-15T14:30:05Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "direction": "inbound", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "to_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "started_at": "2026-01-15T14:30:00Z", "answered_at": "2026-01-15T14:30:05Z", "connected_at": "2026-01-15T14:30:47Z" } } ``` `answered_at` is the signalling answer — a greeting, menu, or voice app answering the media path counts, so for an inbound call that plays one it is roughly `started_at`, not when a person picked up. `connected_at` is when the winning leg (a user's device or an external-number fallback) answered, i.e. when live conversation began; it is `null` when the call is answered by voicemail. For a direct call with no greeting the two coincide. ### `call.end` Sent when a call ends. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v5", "type": "call.end", "created_at": "2026-01-15T14:35:30Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "direction": "inbound", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "to_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "started_at": "2026-01-15T14:30:00Z", "answered_at": "2026-01-15T14:30:05Z", "connected_at": "2026-01-15T14:30:47Z", "ended_at": "2026-01-15T14:35:30Z", "duration_seconds": 325, "status": "completed" } } ``` `connected_at` is `null` when the call never connected to a person: the caller hung up during the greeting or while ringing, or the call was answered by voicemail (`status: "voicemail"`). This lets you distinguish a genuinely-handled call from one that only ever reached the platform. | `status` value | Description | | -------------- | -------------------------------------------- | | `completed` | Call was answered and ended normally | | `no-answer` | Call rang but was not answered | | `busy` | Callee was busy or rejected the call | | `failed` | Call failed due to a network or system error | | `voicemail` | Call went to voicemail | | `direction` value | Description | | ----------------- | -------------------------------------- | | `inbound` | Call from PSTN to a DID | | `outbound` | Call from an endpoint to PSTN | | `internal` | Extension-to-extension or feature code | `user_id` is populated with the user most closely associated with the call: the caller for `outbound`; for `inbound` and `internal`, the user who answered — or, when the call ended unanswered, the single user whose phones were being rung (so a missed call is attributed to the user who missed it). It is omitted when no single user was involved — for example, an unanswered inbound ring group call, a call handled entirely by a voice app, or a dial plan call answered by a forward to an external number. The generic `call.*` events are queue-agnostic; queue context is exposed via the dedicated `queue.call.*` events below. Subscribe to both streams and correlate by `call_id` when you need queue analytics alongside generic call state. ### Queue call lifecycle Queue lifecycle events are emitted in addition to the generic `call.*` lifecycle. Use them when you need queue-specific analytics or routing decisions without inferring queue state from `call.end`. Common fields: | Field | Type | Description | | ------------------- | -------------- | -------------------------------------------------------------------- | | `call_id` | string | Call identifier | | `queue_id` | string | Queue identifier | | `queue_name` | string | Queue display name | | `from_number` | string | Caller's phone number (E.164) | | `from_name` | string \| null | Caller's display name | | `to_number` | string | Called phone number (E.164) | | `position_at_admit` | number | Caller position when admitted to the queue, omitted when unavailable | | `wait_seconds` | number | Seconds spent waiting in queue before this outcome | Event-specific fields: | Event | Additional fields | | ------------------------------- | ------------------------------------------------------------------------- | | `queue.call.queued` | `queued_at` | | `queue.call.dispatched` | `agents_claimed`, `targets_dispatched`, `dispatched_at` | | `queue.call.answered` | `agent_user_id`, `agent_endpoint_id`, `answered_at` | | `queue.call.abandoned` | `abandoned_at` | | `queue.call.timed_out` | `timed_out_at` | | `queue.call.completed` | `agent_user_id`, `agent_endpoint_id`, `completed_at` | | `queue.call.callback_requested` | `callback_id`, `original_priority`, `original_entered_at`, `requested_at` | | `queue.call.callback_attempted` | `callback_id`, `attempt_number`, `result`, `next_attempt_at` | | `queue.call.callback_failed` | `callback_id`, `attempts`, `reason` | ### User attribution `call.initiated`, `call.incoming`, `call.answered`, and `call.end` also carry `from_user_id` and `to_user_id` so both sides of a call are visible — most usefully on internal user-to-user calls, where `user_id` alone only identifies the answerer. Both fields are omitted when the side in question is not a user on this account (for example, an external PSTN caller has no `from_user_id`). | Call type | `from_user_id` | `to_user_id` | | -------------------------------------------- | --------------------------- | ---------------------------------------------- | | Inbound PSTN → user | _omitted_ (external caller) | The routed user | | Inbound PSTN → ring group, answered | _omitted_ | The answering member | | Inbound PSTN → voice app or dial plan | _omitted_ | _omitted_ (until a user is rung — see below) | | Inbound PSTN → user with Find Me / Follow Me | _omitted_ | The Find Me / Follow Me owner | | Outbound from an endpoint | The endpoint's user | _omitted_ (external destination) | | Internal user-to-user | The calling user | The called user | | Click-to-call | The initiating user | The answering endpoint's user, when applicable | On a dial plan call, `to_user_id` (and `user_id`) track the user step currently being rung, exactly as if that user had been called directly: set while a user's phones are ringing — and kept if the caller hangs up during that ring, so the missed call is attributed to them — then replaced by the answering user when the call is answered, and cleared when the plan moves past an unanswered step or a forward to an external number answers the call. A dial plan call answered by an external number therefore carries no `user_id` or `to_user_id`. `user_id` is preserved with its original semantics as a convenience alias for existing integrations; new integrations should prefer `from_user_id` and `to_user_id`. ### `call.transfer` Sent when a call is transferred. A transfer always involves **two calls**, and each one receives its own webhooks, so you can track both sides. How the second call behaves depends on the transfer type: - **Blind (cold) transfer** — the caller is sent to a new destination without a consultation. This creates a brand-new call that runs its full lifecycle independently, including its own `call.end` when it finishes. You receive `call.transfer` on the original call plus a complete set of events for the new call. - **Warm (attended) transfer** — you first place a **consultation call** to the target, then complete the transfer. On completion the consultation call is **merged into** the primary call rather than hung up. Because it does not end on its own, the consultation call emits `call.initiated` and `call.answered` but **not** `call.end` — its `call.transfer` event is its final event. The primary call continues (now connected to the transfer target) and ends normally with its own `call.end`. Both calls also appear in your call logs. To relate the two calls in a transfer, use `related_call`, which references the other call in the pair (each call's event points at the other). It is populated whenever the two calls can be linked; if it is `null`, correlate them using the phone numbers and timestamps. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v6", "type": "call.transfer", "created_at": "2026-01-15T14:32:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "direction": "inbound", "from_number": "+14155551234", "to_number": "+14155559876", "transferred_to": "1001", "transferred_by": "user_01h2xcejqtf2nbrexx3vqjhp42", "related_call": "call_01h2xcejqtf2nbrexx3vqjhp47", "transferred_at": "2026-01-15T14:32:00Z" } } ``` ### `call.emergency` Sent when an emergency number is dialed from any endpoint on the account. Subscribers can route this event to a paging system, security desk, or any other on-call destination. The event also fires for carrier emergency test numbers (such as `933` in the US) so you can verify your wiring end-to-end without contacting an actual public safety answering point. `e911_provisioned` is `false` when the dialing endpoint had no provisioned dispatchable location at the time of the call. In that case `location_id` and `location_name` are omitted from the payload. Treat that combination as a signal that an emergency call went out without an accurate address — the account holder should provision the location before the next call. `e911_provisioned` is **omitted entirely** when the emergency call was placed via a transfer. On a transfer to an emergency number the platform deliberately attaches no dispatchable location — it cannot know whether the transferring or transferred party's address is correct — and lets the public safety answering point resolve the location. The field has no meaning in that case, so its absence (rather than `false`) distinguishes a transfer from an unprovisioned direct call. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v8", "type": "call.emergency", "created_at": "2026-05-03T18:30:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "to_number": "911", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp47", "device_id": "dev_01h2xcejqtf2nbrexx3vqjhp49", "extension": "1001", "location_id": "loc_01h2xcejqtf2nbrexx3vqjhp48", "location_name": "Headquarters", "location_address": "123 Main St, Springfield, IL 62701, US", "e911_provisioned": true, "placed_at": "2026-05-03T18:30:00Z" } } ``` ### `voicemail.new` Sent when a new voicemail is received. Use [`GET /v1/voicemails/{voicemail_id}`](/api#tag/Voicemails/operation/getVoicemail) to retrieve the full voicemail details and audio. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v7", "type": "voicemail.new", "created_at": "2026-01-15T14:36:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "voicemail_id": "vm_01h2xcejqtf2nbrexx3vqjhp46", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "from_number": "+14155551234", "from_name": "John Smith", "duration_seconds": 42, "created_at": "2026-01-15T14:36:00Z" } } ``` `user_id` is included when the voicemail belongs to a single user's mailbox and is omitted when it belongs to a shared voicemail box. `call_id` identifies the call that produced the voicemail — use it to correlate with [`GET /v1/calls/{call_id}`](/api#tag/Calls/operation/getCallLog) — and is omitted when the call could not be resolved. ### `recording.available` Sent when a call recording is ready for download. Use [`GET /v1/calls/{call_id}/recording`](/api#tag/Calls/operation/getCallRecording) to download the audio file. A recording covers the conversation, which does not always run to the end of the call — so this event can arrive before `call.end`, and you should not assume it means the call is over. The common example is a call that reaches voicemail: the recording stops at that point, and the voicemail message itself is delivered separately via `voicemail.new`. See [Ordering](#ordering). ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v8", "type": "recording.available", "created_at": "2026-01-15T14:35:35Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "duration_seconds": 325 } } ``` ### `recording.failed` Sent when a call recording could not be prepared and will never become available. On accounts with PII redaction enabled, `recording.available` is withheld until redaction succeeds; if redaction cannot complete, the audio is discarded and this event is sent instead so you are not left waiting for a recording that will never arrive. No recording is retrievable for the call. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w0", "type": "recording.failed", "created_at": "2026-01-15T14:35:40Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "error_code": "audio_redaction_failed", "reason": "audio could not be redacted" } } ``` Branch on `error_code`, not the free-form `reason`: | `error_code` | Meaning | | ------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `audio_redaction_failed` | The audio could not be redacted (unreadable recording, or sensitive speech that could not be located in the audio) | | `transcription_failed` | The call could not be transcribed, so sensitive speech could not be found | | `internal_error` | An internal error prevented the recording from being processed | `reason` is an optional human-readable sentence for logging; new codes may be added, so treat an unrecognized `error_code` as a generic failure. ### `recording.transcription.complete` Sent when a call transcription is ready. Use [`GET /v1/calls/{call_id}/transcript`](/api#tag/Calls/operation/getCallTranscript) to retrieve the full transcript text. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v9", "type": "recording.transcription.complete", "created_at": "2026-01-15T14:35:45Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "status": "completed" } } ``` ### `recording.summary.complete` Sent when the AI enrichment for a call transcript is ready. This covers both the summary and the `sentiment` object — they are derived from the same transcript and committed together, so one event signals both. Use [`GET /v1/calls/{call_id}/transcript`](/api#tag/Calls/operation/getCallTranscript) to retrieve them; `sentiment` also appears on the call record itself. Both are best-effort, so the event can fire with only one of the two present. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1va", "type": "recording.summary.complete", "created_at": "2026-01-15T14:36:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } } ``` ### `voicemail.transcription.complete` Sent when a voicemail transcription is ready. Use [`GET /v1/voicemails/{voicemail_id}/transcript`](/api#tag/Voicemails/operation/getVoicemailTranscript) to retrieve the transcript text. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vb", "type": "voicemail.transcription.complete", "created_at": "2026-01-15T14:36:10Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "voicemail_id": "vm_01h2xcejqtf2nbrexx3vqjhp46", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "status": "completed" } } ``` `call_id` identifies the call that produced the voicemail and is omitted when the call could not be resolved. ### `voicemail.summary.complete` Sent when the AI enrichment for a voicemail transcript is ready — both the summary and the `sentiment` object, which are derived from the same transcript and committed together. Use [`GET /v1/voicemails/{voicemail_id}`](/api#tag/Voicemails/operation/getVoicemail) to retrieve them. Both are best-effort, so the event can fire with only one of the two present. Voicemail audio is single-channel, so its sentiment carries no `local`/`remote` breakdown. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vc", "type": "voicemail.summary.complete", "created_at": "2026-01-15T14:36:20Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "voicemail_id": "vm_01h2xcejqtf2nbrexx3vqjhp46", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } } ``` `call_id` identifies the call that produced the voicemail and is omitted when the call could not be resolved. ### Fax events The three fax events share a payload shape so a single `fax.*` handler can process all of them. `fax.received` is an **inbound** event; `fax.delivered` and `fax.failed` are **outbound** outcomes. They are terminal — a fax produces exactly one of `fax.delivered` or `fax.failed`, never both, and there are no intermediate `queued`/`sending` events. Common fields: | Field | Type | Description | | ------------- | -------------- | ------------------------------------------------------------------------------- | | `fax_id` | string | Fax identifier. Use [`GET /v1/faxes/{fax_id}`](/api#tag/Faxes) for full details | | `from_number` | string | Sending fax number (E.164) | | `to_number` | string | Receiving fax number (E.164) | | `pages` | number \| null | Pages transmitted (see per-event notes) | | `transport` | string \| null | Negotiated fax transport: `"t38"` (fax over IP) or `"g711"` (fax over audio) | The `transport` value reflects how the fax was carried, not whether it succeeded — read `fax.delivered` vs `fax.failed` for the outcome. It is always present on `fax.received`; on the outbound events it is `null` when the call ended before a transport was negotiated. #### `fax.received` Sent when an inbound fax has been fully received and converted. `pages` and `transport` are always present. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vd", "type": "fax.received", "created_at": "2026-01-15T14:40:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "fax_id": "fax_01h2xcejqtf2nbrexx3vqjhp4a", "from_number": "+14155551234", "to_number": "+14155559876", "pages": 3, "transport": "t38" } } ``` #### `fax.delivered` Sent when an outbound fax completed successfully — the receiving machine confirmed the full document. `pages` is the number of pages confirmed delivered. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1ve", "type": "fax.delivered", "created_at": "2026-01-15T14:42:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "fax_id": "fax_01h2xcejqtf2nbrexx3vqjhp4b", "from_number": "+14155559876", "to_number": "+14155551234", "pages": 2, "transport": "t38" } } ``` #### `fax.failed` Sent when an outbound fax did not complete. Carries an `error_code` in addition to the common fields. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vf", "type": "fax.failed", "created_at": "2026-01-15T14:44:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "fax_id": "fax_01h2xcejqtf2nbrexx3vqjhp4c", "from_number": "+14155559876", "to_number": "+14155551234", "pages": null, "transport": null, "error_code": "max_attempts_exceeded" } } ``` | Field | Type | Description | | ------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `error_code` | string \| null | A short code for why the fax failed — e.g. `dial_busy`, `dial_no_answer`, `max_attempts_exceeded`, `fax_failed`. `null` when no code was determined | | `pages` | number \| null | `null` when the page count is unknown (the fax never began transmitting); `0` when it connected but failed before the first page; `N` when N pages were sent before failing | | `transport` | string \| null | `null` when the call failed before a transport was negotiated | `fax.delivered` and `fax.failed` are mutually exclusive and final. A `fax.delivered` means the receiving machine acknowledged the whole document; a `fax.failed` means it did not, after all automatic retries. ### Device provisioning events The three `device.*` events report on a physical phone fetching its configuration from the provisioning server. They share a payload shape so a single `device.*` handler can process all of them. Pick the event that matches what you are reacting to: - **`device.provisioned`** fires each time a device leaves the `pending-sync` state by successfully fetching its configuration — normally exactly once, at install. This is the "installation complete" signal — use it to kick off downstream provisioning workflows. A device you deliberately set back to `pending-sync` (`POST /v1/devices/:id`) emits again on its next fetch, so treat a re-provision as a legitimate repeat rather than assuming one event per device forever. - **`device.config_fetched`** fires on **every** successful fetch. A deployed phone re-reads its configuration on each reboot and each check-sync, so expect this event repeatedly over a device's life, not just at install. - **`device.provisioning_failed`** fires when a device was recognized but its configuration could not be generated. Common fields: | Field | Type | Description | | ------------- | -------------- | ---------------------------------------------------------------------------------------------------- | | `device` | string | Device identifier — `dev_…` for a deskphone, `dectb_…` for a DECT base | | `device_type` | string | `"deskphone"` or `"dect_base"` | | `mac_address` | string | The device's MAC address | | `vendor` | string | Device vendor, e.g. `"snom"`, `"yealink"`, `"polycom"` | | `model` | string \| null | Device model. `null` until the device reports a model we recognize — it is detected on first contact | | `ip_address` | string \| null | Source IP of the configuration request. `null` when it could not be determined | #### `device.provisioned` ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w1", "type": "device.provisioned", "created_at": "2026-01-15T14:50:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "device": "dev_01h2xcejqtf2nbrexx3vqjhp4d", "device_type": "deskphone", "mac_address": "000413AABBCC", "vendor": "snom", "model": "D785", "ip_address": "203.0.113.42", "provisioned_at": "2026-01-15T14:50:00Z" } } ``` #### `device.config_fetched` Same fields, with `fetched_at` in place of `provisioned_at`. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w2", "type": "device.config_fetched", "created_at": "2026-01-15T18:05:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "device": "dev_01h2xcejqtf2nbrexx3vqjhp4d", "device_type": "deskphone", "mac_address": "000413AABBCC", "vendor": "snom", "model": "D785", "ip_address": "203.0.113.42", "fetched_at": "2026-01-15T18:05:00Z" } } ``` #### `device.provisioning_failed` Carries an `error_code` instead of `model` and `ip_address`. ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w3", "type": "device.provisioning_failed", "created_at": "2026-01-15T14:52:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "device": "dev_01h2xcejqtf2nbrexx3vqjhp4d", "device_type": "deskphone", "mac_address": "000413AABBCC", "vendor": "snom", "error_code": "invalid_configuration", "failed_at": "2026-01-15T14:52:00Z" } } ``` | Field | Type | Description | | ------------ | ------ | ---------------------------------------------------------------------------------------------------------------- | | `error_code` | string | Why configuration could not be generated — `unsupported_vendor`, `invalid_configuration`, or `generation_failed` | A request that fails before the device is identified — an unknown provisioning URL, or an unrecognized device — produces no event, since it cannot be attributed to an account. ### Billable count changes Four events report changes to the counts that drive your bill, as they happen, so you can reconcile before an invoice arrives rather than after: - **`user.created`** / **`user.deleted`** — a user became or stopped being billable. - **`phone_number.activated`** / **`phone_number.disconnected`** — a number went into or out of service. Each event names the resource that changed and carries the billable count either side of the change, so you can follow the running total without recounting: ```json { "id": "evt_01h2xcejqtf2nbrexx3vqjhp4e", "type": "user.created", "created_at": "2026-01-15T14:50:00Z", "account": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "user": "user_01h2xcejqtf2nbrexx3vqjhp4f", "previous_billable_users": 11, "current_billable_users": 12, "request": "8f14e45fceea167a5a36dedd4bea2543", "changed_at": "2026-01-15T14:50:00Z" } } ``` The `phone_number.*` events use the same shape with `previous_billable_dids` / `current_billable_dids`, plus the number itself and the order it came from: ```json { "id": "evt_01h2xcejqtf2nbrexx3vqjhp4g", "type": "phone_number.activated", "created_at": "2026-01-15T14:51:00Z", "account": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "phone_number": "did_01h2xcejqtf2nbrexx3vqjhp4h", "number": "+14155550123", "order": "nord_01h2xcejqtf2nbrexx3vqjhp4i", "previous_billable_dids": 3, "current_billable_dids": 4, "changed_at": "2026-01-15T14:51:00Z" } } ``` | Field | Type | Description | | ---------------------------------------------------- | ------- | --------------------------------------------------------------------------------------- | | `user` / `phone_number` | string | The resource whose billable state changed | | `number` | string | The phone number in E.164 format (`phone_number.*` only) | | `order` | string | The number order or port order this change came from, when there was one | | `previous_billable_users` / `previous_billable_dids` | integer | The billable count immediately before this change | | `current_billable_users` / `current_billable_dids` | integer | The billable count immediately after this change | | `request` | string | Correlation ID for the API request that made the change, when there was one — see below | | `changed_at` | string | When the change took effect | #### These are resource counts, not invoice amounts The counts tell you how many billable resources are in service, **not** what the next invoice will charge. Billing is prorated: a user added halfway through a billing period is charged for the remainder of that period, not a full period. So a change of +1 in these counts is not a change of one full unit on your bill. Use these events to reconcile _what you have_, and the invoice for _what you owe_. A batch — one order activating several numbers — sends one event per number, each carrying its own step of the count, so the sequence reads as a running total. Reassigning a number between two of your accounts sends two events: a `phone_number.disconnected` on the account losing it and a `phone_number.activated` on the account gaining it. Both carry the same `request`, so you can pair them into one move from the payloads alone — but see below for which of the two you can look that id up against. #### What the running total does not cover Every change that moves a billable count sends an event, with two exceptions: - **Deleting an account.** Releasing its numbers and removing its users sends nothing — the account itself is gone, so there is nothing left to reconcile. - **Sandbox accounts.** Sandbox users and numbers are never billed, so provisioning one asserts no billable change. See [Testing your integration](./testing-your-integration.md) for how to trigger these events in sandbox on demand. #### These are a signal, not a ledger Read the count off `current_*` rather than accumulating your own tally: every event carries the account's real count at the instant it was taken, so the most recent event you have is the best number you have. Do not use the `previous_*`/`current_*` chain as a dropped-delivery check. Two changes committed at nearly the same moment each read the count after both have landed, so they can legitimately report the same pair — a chain that lines up is not proof you missed nothing, and a break in it is not proof you did. Delivery is best-effort besides: the event is sent after the change is committed, so the change can stand with no event behind it. When the count has to be exact — reconciling an invoice, answering an audit — recount from the API (`GET /v1/users`, `GET /v1/phone-numbers`) and use these events for what they are: prompt notice that something moved. #### Finding out who made the change These payloads deliberately carry no actor. Instead, every change is attributable through the audit log, which is a richer and permission-aware record. For `user.*`, take the `request` value from the event and look it up: ```bash curl "https://api.dialstack.ai/v1/audit-logs?request_id=8f14e45fceea167a5a36dedd4bea2543" \ -H "Authorization: Bearer sk_live_..." ``` The matching entry's `actor` identifies who performed the change. For a person, resolve it to a name and email: ```bash curl "https://api.dialstack.ai/v1/admin/users/{actor}" \ -H "Authorization: Bearer sk_live_..." ``` `phone_number.*` events split between the two join keys, and the reason is worth knowing. A number usually goes into service when the upstream provider confirms the order, not at the moment someone clicked, so there is no caller request to correlate — and the audit entry for the number itself records the platform, not a person. The human who requested the number is on the **order's** audit entry, which is why those events carry `order` and no `request`. Look that up instead: ```bash curl "https://api.dialstack.ai/v1/audit-logs?resource=nord_01h2xcejqtf2nbrexx3vqjhp4i" \ -H "Authorization: Bearer sk_live_..." ``` The other way round when the change came from a direct API call — activating or deactivating a number, reassigning one, recovering a released one, or keeping a temporary one. There is no order to point at, but there is a caller, so those events carry `request` and no `order`. Look them up the same way as `user.*`, with `?request_id=`. One exception on a reassignment: the audit entry is written under the account whose key made the call — the account losing the number. So `?request_id=` resolves the actor against the **source** account, and returns nothing against the target. On the target event, treat `request` as a correlation id that pairs it with its source event, not as something you can look up on that account. ## Webhook Protocol ### Request Format DialStack sends a `POST` request to each matching endpoint's `url` with the event as the JSON body. An event is delivered to an endpoint only when the endpoint's mode matches the originating account's mode and its `enabled_events` includes the event type (or is `["*"]`). **Headers:** | Header | Description | | ------------------------ | -------------------------------------- | | `Content-Type` | `application/json` | | `X-DialStack-Signature` | HMAC-SHA256 signature for verification | | `X-DialStack-Account-Id` | Account where the event occurred | | `User-Agent` | `DialStack-Webhook/1.0` | ### Signature Verification Every webhook request is signed using HMAC-SHA256 with the receiving endpoint's `secret`. Each endpoint has its own secret, so verify against the secret of the endpoint that received the request. The signature header format is: ``` X-DialStack-Signature: t=1705312530,v1=a1b2c3d4e5f6... ``` To verify: 1. Extract the timestamp (`t`) and signature (`v1`) from the header 2. Construct the signed payload: `{timestamp}.{request_body}` 3. Compute HMAC-SHA256 of the signed payload using your webhook secret 4. Compare with the provided signature For detailed verification examples in Node.js, Python, and Go, see [Appointment Webhooks — Signature Verification](./appointment-webhooks.md#signature-verification). ### Expected Response Return any `2xx` status code to acknowledge receipt. The response body is ignored. Non-`2xx` responses trigger a retry. ### Retry Behavior Failed deliveries (non-`2xx` response or timeout) are retried automatically: - Up to **5 retry attempts** at approximately **60-second intervals** - **30-second timeout** per delivery attempt - After all retries are exhausted, the event is moved to a dead letter queue for investigation When an account has multiple matching endpoints, a retry re-delivers the event to all of them, so an endpoint that already responded `2xx` may receive the event again. Combined with at-least-once delivery, this is why you should [handle events idempotently](#best-practices) using the `id` field. ### Ordering Delivery preserves the order in which we **publish** events for an account. Events for different accounts may be delivered in parallel — do not assume ordering across accounts. Publication order is not the same as the call's lifecycle order. `call.*` events are published by the call-processing tier as the call progresses, while `recording.*` and `voicemail.*` are published separately once the underlying media has been processed. The two families are **not ordered relative to each other**. In particular, `recording.available` can arrive **before** `call.end` for the same call, because a recording tracks the conversation rather than the call: whenever a call outlives its conversation, the recording is complete and delivered while the call is still up. The common example is voicemail — the recording ends when the call reaches the mailbox, and `call.end` follows only when the caller hangs up, potentially tens of seconds later. Treat this as the general rule rather than a voicemail special case; other flows can separate the two the same way. Correlate related events by `call_id` and treat each event as independently meaningful. Do not treat the arrival of one event type as evidence that another has already been delivered, and do not hold up processing of one family while waiting for the other. ## Timing | Event | Typical Latency | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `call.initiated` | < 1 second | | `call.incoming` | < 1 second | | `call.ringing` | < 1 second | | `call.mobile_push_wakeup` | < 1 second | | `call.answered` | < 1 second | | `call.end` | < 1 second | | `call.transfer` | < 1 second | | `queue.call.*` | < 1 second | | `voicemail.new` | < 2 seconds | | `recording.available` | < 5 seconds after the recording ends — the call ending, or the call reaching voicemail (see [Ordering](#ordering)); on redaction-enabled accounts, after redaction completes (transcription + audio rewrite) | | `recording.failed` | On redaction-enabled accounts, when redaction cannot complete | | `recording.transcription.complete` | ~10 seconds after recording | | `recording.summary.complete` | ~15 seconds after recording | | `voicemail.transcription.complete` | ~10 seconds after voicemail | | `voicemail.summary.complete` | ~15 seconds after voicemail | | `fax.received` | < 1 second after receipt completes | | `fax.delivered` | < 1 second after transmission completes | | `fax.failed` | < 1 second after the final attempt | ## Best Practices - **Verify signatures** on every request to prevent spoofing - **Return `200` quickly** and defer processing — long-running handlers risk timeouts - **Handle events idempotently** using the `id` field — events may be delivered more than once - **Don't rely on strict cross-account ordering** — events from different accounts may arrive in any order - **Correlate by `call_id`, not arrival order** — `recording.*` and `voicemail.*` are not ordered against `call.*`, so `recording.available` may precede `call.end` (see [Ordering](#ordering)) ## Related Resources - [Real-Time Events (SSE)](./events.md) — Frontend event streaming - [Appointment Webhooks](./appointment-webhooks.md) — Scheduling webhook protocol - [Voice Apps](./voice-apps.md) — Programmable voice webhooks --- ## Direct API **Raw power, 100% control.** Talk directly to the REST and WebSocket APIs, handle events yourself, build exactly the experience you want — no components, no portal, no assumptions. This is the tier when off-the-shelf components don't fit: AI call centers, bespoke dispatcher consoles, unusual call-routing logic, or any time you want to be close to the metal. ## Authentication modes Three token types, each with a different audience: | Token | Gets | Use for | | ------------------------------------ | --------------------------------------------- | ----------------------------------------------------------------------- | | **Platform API key** (`sk_live_...`) | Full platform access across all your accounts | Backend-to-backend: provisioning, management, activity logging | | **Session token** (JWT) | One account's scope, 1-hour TTL | Frontend components (see [Embedded](/integration-tiers/embedded)) | | **User token** (JWT) | One user's scope within an account | Softphone + user-facing apps (WebRTC signalling, per-user call history) | All three use `Authorization: Bearer `. Platform keys additionally require the `DialStack-Account` header when scoping to a specific customer; session and user tokens carry the account in their JWT claims. Full flow: [Authentication guide](/guides/authentication). ## Example: Click-to-call with activity logging This example demonstrates how to build a complete backend workflow for outbound sales or dispatching. It shows how to provision a new customer, trigger an outbound call via the API, and log the resulting call duration and status to your own database using a webhook handler — all in ~30 lines of code. ### 1. Provision a customer and user ```ts import { DialStack } from '@dialstack/sdk-server'; const ds = new DialStack(process.env.DIALSTACK_KEY); const account = await ds.accounts.create({ email: 'contact@joneschiro.com' }); const user = await ds.users.create({ name: 'Dr. Alice Smith' }, { dialstackAccount: account.id }); ``` ### 2. Trigger click-to-call Alice's endpoints ring first; when she answers, DialStack dials the prospect and bridges the audio. ```ts const call = await ds.calls.create( { from: user.id, to: '+16175551234' }, { dialstackAccount: account.id } ); // → { id: 'call_...', status: 'initiated', ... } ``` ### 3. Handle `call.end` webhooks Configure your platform's `webhook_url` once. DialStack delivers events for all accounts to that URL. ```ts app.post('/webhooks/dialstack', express.raw({ type: 'application/json' }), (req, res) => { verifySignature(req); // see webhook-events guide const event = JSON.parse(req.body); if (event.type === 'call.end') { db.calls.upsert({ id: event.data.call_id, duration: event.data.duration_seconds, status: event.data.status, }); } res.status(200).end(); }); ``` See [Activity Logging](/guides/activity-logging) for the full four-event pattern (`call.end`, `voicemail.new`, `recording.available`, `*.transcription.complete`). ## Advanced example: dial plan with AI overflow Everything you see in the landing hero is a single `ds.dialPlans.create` call. Real routing — business-hours schedule, team ring, AI receptionist fallback — expressed as a node graph: ```ts await ds.dialPlans.create( { name: 'Main number', entry_node: 'hours', nodes: [ { id: 'hours', type: 'schedule', config: { schedule_id: 'sch_business_hours', open: 'ring_team', closed: 'ai_reception' }, }, { id: 'ring_team', type: 'ring_all_users', config: { timeout: 25, next: 'ai_reception' } }, { id: 'ai_reception', type: 'internal_dial', config: { target_id: 'va_ai_receptionist' } }, ], }, { dialstackAccount: account.id } ); ``` Full node surface: [Dial Plans](/guides/dial-plans). AI agent setup: [AI Scheduling](/guides/voiceai-scheduling). ## Real-time and streaming - **Webhooks** — platform-scoped, fire-and-forget, for backend pipelines. See [Webhook Events](/guides/webhook-events). - **SSE (Server-Sent Events)** — account-scoped, account-ID stream for frontends (Screen Pop, live dashboards). See [Real-Time Events](/guides/events). - **WebSocket audio streaming** — bidirectional media for Voice Apps and one-way media for call listeners (transcription, monitoring). See [Voice Apps](/guides/voice-apps) and [BYO VoiceAI](/guides/voiceai-byo). ## API reference Every operation has curl and SDK samples at [`/api`](/api). The reference is generated from the same OpenAPI spec the SDK is generated from — what you see there is what the platform does. - Full endpoint catalog, grouped into: Accounts & Users / Calls / Routing / AI & Voice Apps / Devices & Hardware / Platform. - Download the spec at [`/api/openapi.yaml`](/api/openapi.yaml) to generate your own client in any language. ## When to pick this - You're building a unique voice experience (AI call center, bespoke dispatcher console, live agent copilot). - You need fine-grained control over call routing, media streaming, or event handling. - You have engineering capacity and want no abstraction between your code and the platform. ## When to pick something else - If you need a full phone-system portal without front-end work → [White Label](/integration-tiers/white-label). - If you want voice features inside your existing UI but don't need custom flows → [Embedded](/integration-tiers/embedded). ## See also - [Quickstart](/guides/quickstart) — the shortest path to a first API call. - [Architecture overview](/guides/architecture) — the mental model. - [Webhook Events](/guides/webhook-events) — every event shape. - [WebSocket API](/websocket-api) — real-time media protocol. --- ## Embedded **Native feel inside your app.** Drop React components and Web Components into your existing UI so voice feels like a built-in feature. Prebuilt components handle WebRTC signaling, device permissions, audio routing, and reconnection. You keep full control over layout, navigation, and identity. ## Component inventory ### React (`@dialstack/sdk-react`) Each component has its own import path, so your app only pulls in what it uses. The provider and hooks come from the package root. | Component | Import | Purpose | | ------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------- | | `` | `@dialstack/sdk-react` | Provides session and auth context for all child components. | | `useDialstack()` | `@dialstack/sdk-react` | Exposes a headless hook for fully custom UIs on top of the same data. | | `` | `@dialstack/sdk-react/call-logs` | Displays a call history table (date, direction, duration, status, quality). | | `` | `@dialstack/sdk-react/call-history` | Displays a compact recent-call list for sidebars and cards. | | `` | `@dialstack/sdk-react/voicemails` | Displays a voicemail list with audio playback, transcription, and delete. | | `` | `@dialstack/sdk-react/phone-numbers` | Manages phone numbers for an account. | | `` | `@dialstack/sdk-react/phone-number-ordering` | Searches and orders new phone numbers. | | `` | `@dialstack/sdk-react/dial-plan` | Renders a dial-plan graph (`mode="view"`, `"edit"`, or `"preview"`). | | `` | `@dialstack/sdk-react/onboarding` | Provides a guided first-time setup wizard. | ### Web Components (`@dialstack/sdk-js`) Framework-agnostic versions for use from Vue, Svelte, Angular, or plain HTML: | Component | Purpose | | ----------------------------------- | --------------------------------------------------------------------------- | | `` | Displays a call history table (date, direction, duration, status, quality). | | `` | Displays a compact recent-call list for sidebars and cards. | | `` | Displays a voicemail list with audio playback, transcription, and delete. | | `` | Manages phone numbers for an account. | | `` | Searches and orders new phone numbers. | ## Session token flow Your backend mints a session, your frontend drops it into the provider, components authenticate transparently. ```ts // Backend — /api/dialstack/session import { DialStack } from '@dialstack/sdk-server'; const ds = new DialStack(process.env.DIALSTACK_KEY); export default async function handler(req, res) { const accountId = getAccountForUser(req); // your app's logic const { client_secret } = await ds.accountSessions.create({ account: accountId, // A component is a capability. Enable what this user may do — at least one // is required. `agreement_acceptance` is the one to weigh: it lets the // session accept the subscription agreement, so enable it only for someone // entitled to sign for the account. See Account Sessions. components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); res.json({ client_secret }); } ``` ```tsx // Frontend import { loadDialStack } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { Voicemails } from '@dialstack/sdk-react/voicemails'; const dialstack = await loadDialStack('pk_live_YOUR_KEY', { fetchClientSecret: async () => { const res = await fetch('/api/dialstack/session'); return res.json(); }, }); export function VoiceInbox() { return ( ); } ``` The SDK refreshes the session token automatically before expiry. Session tokens are account-scoped and expire in one hour. See [Authentication](/sdks/authentication) for token lifecycle, revocation, and multi-account handling. ## Theming For a full list of CSS variables (colors, typography, spacing, shadows) and layout variants, see the [Theming guide](/sdks/theming). Pass an `appearance` block to `initialize()` / `loadDialStack()`: ```ts const dialstack = await loadDialStack('pk_live_YOUR_KEY', { fetchClientSecret, appearance: { theme: 'auto', // 'light' | 'dark' | 'auto' variables: { colorPrimary: '#6B2CFF', colorPrimaryHover: '#5A25D9', fontFamily: 'Inter, system-ui, sans-serif', borderRadius: '8px', }, }, }); ``` Per-component density: ```tsx ``` ## Component embedding example The `` handles the session token lifecycle. Any child components you nest inside it automatically inherit the authenticated context, fetch their own data, and render fully functional views. Here is an example composing a sidebar and a main content area: ```tsx import { loadDialStack } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallHistory } from '@dialstack/sdk-react/call-history'; import { PhoneNumbers } from '@dialstack/sdk-react/phone-numbers'; const dialstack = await loadDialStack('pk_live_YOUR_KEY', { fetchClientSecret: async () => { const res = await fetch('/api/dialstack/session'); const { client_secret } = await res.json(); return client_secret; }, }); export function AccountDashboard() { return (
{/* Sidebar for recent activity */} {/* Main area for management */}

Phone Numbers

); } ``` ## When to pick this - Voice lives inside a workflow your users already use daily (CRM, dispatcher, EHR). - You want to control layout and navigation but not rebuild softphone internals. - You have a React or modern front-end and want to ship in a few sprints. ## When to pick something else - If your customers prefer a dedicated phone portal (simpler onboarding, fewer surfaces) → [White Label](/integration-tiers/white-label). - If you're building a bespoke workflow that doesn't match any prebuilt component → [Direct API](/integration-tiers/direct-api). ## See also - [SDK overview](/sdks) — what each of the four packages covers. - [React components](/sdks/react) — per-component props and events. - [Web Components](/sdks/web-components) — framework-agnostic wrappers. - [Theming](/sdks/theming) — full appearance surface. --- ## White Label **Branded portal, zero UI dev.** Best for platforms that want a premium phone-system experience without building or maintaining a voice UI. Your customers log into a DialStack-hosted portal at `.netvoice.io` — our dedicated white-label domain. The admin console, call history, voicemail inbox, and device management are all provided — you own the branding. A custom domain like `phones.yourplatform.com` is also possible (see [Custom domain](#custom-domain) below). ## What you ship - A fully branded phone-system portal at `.netvoice.io`. - Administrative interfaces (e.g., for managing users, numbers, dial plans). - User features (e.g., call history, voicemail inbox, and device provisioning). ## Provisioning flow ```mermaid sequenceDiagram participant App as Your platform participant DS as DialStack participant User as Administrator's browser Note over App,DS: Customer signup App->>DS: POST /v1/accounts DS-->>App: { id: 'acct_...' } Note over App,DS: User onboarding App->>DS: POST /v1/users (DialStack-Account: acct_...) DS-->>App: { id: 'user_...' } Note over App,User: Administrator manages the phone system App-->>User: Link to yourplatform.netvoice.io User->>DS: Administrator signs in to the portal ``` Two API calls. That's it. ## Provision a new customer When your signup flow runs, create a DialStack account for the customer: ```ts import { DialStack } from '@dialstack/sdk-server'; const ds = new DialStack(process.env.DIALSTACK_KEY); const account = await ds.accounts.create({ email: 'contact@joneschiro.com', }); // → { id: 'acct_01h2xcejqtf2nbrexx3vqjhp41', ... } ``` Persist `account.id` against your customer record. All subsequent user / billing / config calls scope to this ID via the `DialStack-Account` header. ## Sync users Whenever your product creates or deletes a user on that customer's account, mirror it to DialStack: ```ts const user = await ds.users.create( { name: 'Dr. Alice Smith', email: 'alice@joneschiro.com' }, { dialstackAccount: account.id } ); // later, on offboard: await ds.users.del(user.id, { dialstackAccount: account.id }); ``` The user now shows up in the portal's admin console and can be assigned a phone number. ### Voice users are not portal administrators `POST /v1/users` creates a **voice** user — someone who gets phone service: an extension, devices, a number, a voicemail box. It does not grant access to the portal. Portal administrators are a separate population. [`GET /v1/admin/users`](/api) lists the people who can administer an account in the portal; an administrator need not have phone service, and a voice user is not an administrator by default. That collection is **read-only — roles are granted in the admin portal**, so there is no API call that creates a portal login. ## Portal access Your customers' portal administrators sign in at `https://.netvoice.io` with their own credentials. Administrators are invited, and roles granted, from the portal itself — see the [Users guide](/admin-guide/account-admin/users). A voice-only user has no portal login. Single sign-on from your own identity provider is supported, so administrators don't face a second login. Like a custom domain, it involves more than a config toggle — we set it up with you per platform. Reach out and we'll walk through it. :::info[Tell us what you need] There is no API today that hands a signed-in user of your app straight into the portal. If a programmatic handoff would fit your integration — and what it would need to carry — we'd love to hear about it at [api@dialstack.ai](mailto:api@dialstack.ai). ::: ## Theming Your logo and brand colors are configured once in the Platform Admin and applied across the portal and transactional email. No code, no CSS. ## Custom domain By default the portal lives at `.netvoice.io`. That's production-ready and branded — no DNS required. If you want the portal to live under your own domain (e.g., `phones.yourplatform.com`), that's supported too. It involves more than a simple CNAME — we coordinate DNS, TLS provisioning, and per-tenant routing together. Reach out and we'll walk through it. ## What you don't build - Call-logs list, voicemail inbox, device provisioning - Admin tools (users, dial plans, number ordering) - Authentication UX — sign-in, invitations, and password recovery ## What you do build - Account creation on customer signup (`ds.accounts.create`) - User sync on user onboard/offboard (`ds.users.create` / `ds.users.del`) - "Open phones" link in your app pointing at `https://.netvoice.io` ## When to pick this - You want voice live in weeks, not months. - You don't have front-end capacity for a voice surface. - Your customers are used to logging into vendor portals (payroll, scheduling, DMS tools). ## When to pick something else - If voice has to live inside your app's main workspace with no context switch → [Embedded](/integration-tiers/embedded). - If you're building a bespoke voice workflow (dispatcher console, agent cockpit) → [Direct API](/integration-tiers/direct-api). ## See also - [Quickstart](/guides/quickstart) — the `ds.accounts.create` / `ds.users.create` pair, then assigning a number. - [Authentication](/guides/authentication) — API keys, scoping requests with `DialStack-Account`. - [Admin guide](/admin-guide) — what your customers see and do once they're in the portal. --- ## Authentication(Sdks) The SDK uses account-scoped sessions for secure client-side authentication. This architecture keeps your API keys safe on the server while allowing embedded components to fetch data. ## How It Works ``` ┌─────────────┐ 1. Request session ┌─────────────────┐ │ Browser │ ─────────────────────────────▶│ Your Backend │ │ (SDK) │ │ │ │ │ ◀───────────────────────────── │ │ │ │ 4. Return client_secret │ │ └─────────────┘ └────────┬────────┘ │ │ │ 5. API calls with 2. Create session │ client_secret with API key │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ DialStack API │ └─────────────────────────────────────────────────────────────────┘ ``` 1. Your frontend requests a session from your backend 2. Your backend creates a session using the Server SDK with your API key 3. DialStack returns a `client_secret` scoped to a specific account 4. Your backend returns the `client_secret` to the frontend 5. The SDK uses the `client_secret` for all API calls ## Server-Side: Creating Sessions Use the Server SDK to create account sessions: ```typescript import { DialStack } from '@dialstack/sdk-server'; const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); // Express.js example app.post('/api/create-session', async (req, res) => { const { accountId } = req.body; const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); res.json({ client_secret: session.client_secret, expires_at: session.expires_at, }); }); ``` ### Session Response ```typescript interface AccountSessionCreateResponse { client_secret: string; // Token for client-side use expires_at: string; // ISO 8601 timestamp } ``` ## Client-Side: Using Sessions ### React ```tsx import { initialize } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { useEffect, useState } from 'react'; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', }); function App() { const [clientSecret, setClientSecret] = useState(null); useEffect(() => { fetch('/api/create-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }), }) .then((res) => res.json()) .then((data) => setClientSecret(data.client_secret)); }, []); if (!clientSecret) { return
Loading...
; } return ( ); } ``` ### Vanilla JavaScript ```javascript const dialstack = DialStack.initialize({ publishableKey: 'pk_live_YOUR_KEY', }); // Fetch session from your backend const response = await fetch('/api/create-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }), }); const { client_secret } = await response.json(); // Set on components const callLogs = document.querySelector('dialstack-call-logs'); callLogs.setClientSecret(client_secret); ``` ## Session Expiry Sessions expire after **1 hour** by default. The SDK handles token refresh automatically when you provide the full session response: ```tsx // Pass the full response object for automatic refresh handling ``` The SDK refreshes the session 5 minutes before expiry. If you only pass the `client_secret` string, you're responsible for refreshing expired sessions. ## Component Permissions Control which components a session can access: ```typescript const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: true }, voicemails: { enabled: false }, // Disabled }, }); ``` Components will show an error if the session doesn't have permission. ## Security Best Practices ### Keep API Keys Server-Side :::danger[Never expose API keys] API keys (`sk_live_*`) must never appear in client-side code. Only use `client_secret` tokens in the browser. ::: ```typescript // Server-side only const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); ``` ### Validate Account Access Before creating a session, verify the user has permission to access the requested account: ```typescript app.post('/api/create-session', async (req, res) => { const { accountId } = req.body; const userId = req.user.id; // From your auth middleware // Verify user has access to this account const hasAccess = await verifyAccountAccess(userId, accountId); if (!hasAccess) { return res.status(403).json({ error: 'Access denied' }); } const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); res.json(session); }); ``` ### Session Token Security - Session tokens cannot create new sessions (prevents escalation) - Tokens are scoped to a single account - Tokens expire after 1 hour - Tokens can only access enabled components ## Error Handling ### Invalid Session ```tsx { if (event.error.includes('session')) { // Refresh session and retry refreshSession(); } }} /> ``` ### Missing Provider If you forget the provider, you'll see: ``` Error: Could not find DialStack context; You need to wrap your app in a provider. ``` ## Next Steps - [React Components](/sdks/react) - Build with React - [Server SDK](/sdks/server) - Full server-side API - [Theming](/sdks/theming) - Customize appearance --- ## Internationalization (i18n) Customize all UI strings in DialStack components for localization or branding. ## Quick Start ```tsx import { en } from '@dialstack/sdk-js'; // Create French locale const fr = { ...en, common: { ...en.common, loading: 'Chargement...', error: 'Erreur', noResults: 'Aucun résultat', }, callLogs: { ...en.callLogs, title: 'Journal des appels', empty: 'Aucun appel trouvé', }, }; ; ``` ## Locale Structure The locale object has three main sections: ```typescript interface Locale { common: CommonStrings; voicemails: VoicemailStrings; callLogs: CallLogStrings; } ``` ### Common Strings Shared across all components: ```typescript interface CommonStrings { loading: string; // 'Loading...' error: string; // 'Error' noResults: string; // 'No results' delete: string; // 'Delete' call: string; // 'Call' cancel: string; // 'Cancel' confirm: string; // 'Confirm' previous: string; // 'Previous' next: string; // 'Next' perPage: string; // 'Per page' showing: string; // 'Showing {start}-{end} of {total}' play: string; // 'Play' pause: string; // 'Pause' } ``` ### Voicemail Strings ```typescript interface VoicemailStrings { title: string; // 'Voicemails' loading: string; // 'Loading voicemails...' empty: string; // 'No voicemails' noUserId: string; // 'Please set a user ID to load voicemails' transcription: string; // 'Transcription' deleteConfirm: string; // 'Delete this voicemail?' deleteTitle: string; // 'Delete Voicemail' progress: string; // 'Playback progress' } ``` ### Call Log Strings ```typescript interface CallLogStrings { title: string; // 'Call Logs' loading: string; // 'Loading call logs...' empty: string; // 'No call logs found' columns: { date: string; // 'Date' direction: string; // 'Direction' from: string; // 'From' to: string; // 'To' duration: string; // 'Duration' status: string; // 'Status' }; directions: { inbound: string; // 'Inbound' outbound: string; // 'Outbound' internal: string; // 'Internal' }; statuses: { completed: string; // 'Completed' noAnswer: string; // 'No Answer' busy: string; // 'Busy' failed: string; // 'Failed' voicemail: string; // 'Voicemail' }; } ``` ## Built-in Locales Currently, English is the only built-in locale: ```typescript import { en } from '@dialstack/sdk-js'; ``` ## Creating Custom Locales ### Full Custom Locale ```typescript import type { Locale } from '@dialstack/sdk-js'; const de: Locale = { common: { loading: 'Laden...', error: 'Fehler', noResults: 'Keine Ergebnisse', delete: 'Löschen', call: 'Anrufen', cancel: 'Abbrechen', confirm: 'Bestätigen', previous: 'Zurück', next: 'Weiter', perPage: 'Pro Seite', showing: 'Zeige {start}-{end} von {total}', play: 'Abspielen', pause: 'Pause', }, voicemails: { title: 'Sprachnachrichten', loading: 'Sprachnachrichten werden geladen...', empty: 'Keine Sprachnachrichten', noUserId: 'Bitte setzen Sie eine Benutzer-ID', transcription: 'Transkription', deleteConfirm: 'Diese Sprachnachricht löschen?', deleteTitle: 'Sprachnachricht löschen', progress: 'Wiedergabefortschritt', }, callLogs: { title: 'Anrufliste', loading: 'Anrufliste wird geladen...', empty: 'Keine Anrufe gefunden', columns: { date: 'Datum', direction: 'Richtung', from: 'Von', to: 'An', duration: 'Dauer', status: 'Status', }, directions: { inbound: 'Eingehend', outbound: 'Ausgehend', internal: 'Intern', }, statuses: { completed: 'Abgeschlossen', noAnswer: 'Keine Antwort', busy: 'Besetzt', failed: 'Fehlgeschlagen', voicemail: 'Sprachnachricht', }, }, }; ``` ### Extending English Locale Override only specific strings: ```typescript import { en } from '@dialstack/sdk-js'; const customEn = { ...en, common: { ...en.common, noResults: 'Nothing to show', }, callLogs: { ...en.callLogs, title: 'Call History', empty: 'No calls yet', }, }; ``` ### Partial Override Override only the section you need: ```typescript import { en } from '@dialstack/sdk-js'; const brandedLocale = { ...en, voicemails: { ...en.voicemails, title: 'Messages', empty: 'Your inbox is empty', }, }; ``` ## Using Locales ### Per-Component ```tsx ``` ### With Web Components ```javascript const callLogs = document.querySelector('dialstack-call-logs'); callLogs.setLocale(customLocale); ``` ## Dynamic Locale Switching Change locale at runtime: ```tsx function Dashboard() { const [locale, setLocale] = useState(en); return (
); } ``` ## Interpolation Some strings support placeholders: ```typescript // 'showing' uses {start}, {end}, {total} showing: 'Showing {start}-{end} of {total}'; // Result: "Showing 1-20 of 150" ``` ## Complete Example ```tsx import { useState } from 'react'; import { initialize, en, type Locale } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { Voicemails } from '@dialstack/sdk-react/voicemails'; // Define locales const locales: Record = { en, es: { common: { loading: 'Cargando...', error: 'Error', noResults: 'Sin resultados', delete: 'Eliminar', call: 'Llamar', cancel: 'Cancelar', confirm: 'Confirmar', previous: 'Anterior', next: 'Siguiente', perPage: 'Por página', showing: 'Mostrando {start}-{end} de {total}', play: 'Reproducir', pause: 'Pausar', }, voicemails: { title: 'Buzón de voz', loading: 'Cargando mensajes...', empty: 'No hay mensajes de voz', noUserId: 'Por favor establezca un ID de usuario', transcription: 'Transcripción', deleteConfirm: '¿Eliminar este mensaje de voz?', deleteTitle: 'Eliminar mensaje', progress: 'Progreso de reproducción', }, callLogs: { title: 'Registro de llamadas', loading: 'Cargando llamadas...', empty: 'No hay llamadas', columns: { date: 'Fecha', direction: 'Dirección', from: 'De', to: 'Para', duration: 'Duración', status: 'Estado', }, directions: { inbound: 'Entrante', outbound: 'Saliente', internal: 'Interno', }, statuses: { completed: 'Completada', noAnswer: 'Sin respuesta', busy: 'Ocupado', failed: 'Fallida', voicemail: 'Buzón de voz', }, }, }, }; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', }); function App() { const [currentLocale, setCurrentLocale] = useState('en'); return (

{locales[currentLocale].callLogs.title}

{locales[currentLocale].voicemails.title}

); } ``` ## TypeScript Import the `Locale` type for type safety: ```typescript import type { Locale } from '@dialstack/sdk-js'; const myLocale: Locale = { // TypeScript will enforce the correct structure }; ``` ## Next Steps - [Theming](/sdks/theming) - Customize appearance - [CallLogs](/sdks/react/call-logs) - CallLogs component - [Voicemails](/sdks/react/voicemails) - Voicemails component --- ## SDK # JavaScript SDK The official JavaScript SDK for DialStack lets you embed voice capabilities directly into your application with ready-to-use components for call logs, voicemails, and more. ## Packages The SDK ships as four packages. Install the ones your app needs: | Package | Use Case | | ----------------------- | ------------------------------------------------------------------------------ | | `@dialstack/sdk-js` | Core client, types, and the Web Components. Required by `@dialstack/sdk-react` | | `@dialstack/sdk-react` | React components and hooks | | `@dialstack/sdk-webrtc` | Headless calling client for building a softphone. Standalone | | `@dialstack/sdk-server` | Node.js backend for session creation and API calls | For vanilla JavaScript and Web Components, use `@dialstack/sdk-js` — importing it registers the custom elements for you. `@dialstack/sdk-js/pure` is the side-effect-free alternative for server-side rendering and tests, where you register the elements yourself. Each React component has its own import path, so your app only pulls in what it uses. The `@dialstack/sdk-react` root provides the shared provider and hooks. ## Installation ```bash npm install @dialstack/sdk-react @dialstack/sdk-js ``` ## Quick Example ```tsx import { initialize } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallLogs } from '@dialstack/sdk-react/call-logs'; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', }); function App() { return ( ); } ``` ## What's Included ### Embedded Components - **[CallHistory](/sdks/react/call-history)** - Display recent call history in a compact format - **[CallLogs](/sdks/react/call-logs)** - Display call history with filtering, pagination, and formatting - **[Voicemails](/sdks/react/voicemails)** - List voicemails with audio playback and transcription ### Server SDK - **[Accounts](/sdks/server/accounts)** - Create and manage customer accounts - **[Users](/sdks/server/users)** - Manage users within accounts - **[Sessions](/sdks/server/sessions)** - Create secure session tokens for embedded components ## Next Steps - [Installation](/sdks/installation) - Detailed installation instructions - [Authentication](/sdks/authentication) - Learn about session-based authentication - [React Components](/sdks/react) - Get started with React - [Web Components](/sdks/web-components) - Use with vanilla JavaScript - [Server SDK](/sdks/server) - Backend API integration --- ## Installation Install the DialStack SDK packages your app needs, using your preferred package manager. ## Package Managers For a React app: ```bash # npm npm install @dialstack/sdk-react @dialstack/sdk-js # yarn yarn add @dialstack/sdk-react @dialstack/sdk-js # pnpm pnpm add @dialstack/sdk-react @dialstack/sdk-js ``` `@dialstack/sdk-js` is required alongside `@dialstack/sdk-react` — it provides the core client, and importing it is what registers the underlying components. For a Node.js backend, install `@dialstack/sdk-server`. To build a softphone without React, install `@dialstack/sdk-webrtc`. See the [SDK overview](/sdks) for what each package covers. ## CDN (Vanilla JavaScript) For non-bundled applications, use the UMD build via CDN: ```html ``` ## Import Patterns ### React Applications ```typescript // Core client and types import { initialize } from '@dialstack/sdk-js'; // The shared provider and hooks import { DialstackComponentsProvider } from '@dialstack/sdk-react'; // Each component has its own import path import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { Voicemails } from '@dialstack/sdk-react/voicemails'; // Types import type { DialStackInstance, CallLog, AppearanceOptions } from '@dialstack/sdk-js'; ``` ### Vanilla JavaScript / Web Components ```typescript // Web Components, no React dependency. Importing this registers the custom elements import { loadDialstackAndInitialize } from '@dialstack/sdk-js'; const dialstack = await loadDialstackAndInitialize({ publishableKey: 'pk_live_YOUR_KEY', }); ``` ### Server-Side (Node.js) ```typescript // Server SDK for API calls import { DialStack } from '@dialstack/sdk-server'; const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); ``` ## TypeScript Configuration The SDK includes full TypeScript support. Ensure your `tsconfig.json` includes: ```json { "compilerOptions": { "moduleResolution": "bundler", "esModuleInterop": true } } ``` Each package declares its own type entry points, so subpath imports such as `@dialstack/sdk-react/call-logs` resolve under `"moduleResolution": "bundler"` or `"node16"`, and also under the legacy `"moduleResolution": "node"`. ## Peer Dependencies `@dialstack/sdk-js`, `@dialstack/sdk-webrtc`, and `@dialstack/sdk-server` have no peer dependencies. `@dialstack/sdk-react` expects these to be installed alongside it: - `@dialstack/sdk-js`, at the matching major version - `@xyflow/react` - `react` and `react-dom` Your package manager will report the exact ranges it expects if any are missing. Check `@dialstack/sdk-react`'s `peerDependencies` for the versions a given release supports. If you're not using React, use the Web Components in `@dialstack/sdk-js` instead: ```typescript import { loadDialstackAndInitialize } from '@dialstack/sdk-js'; ``` ## Browser Compatibility | Browser | Minimum Version | | ------- | --------------- | | Chrome | 80+ | | Firefox | 75+ | | Safari | 13.1+ | | Edge | 80+ | **Requirements:** - ES2015+ (ES6) support - Web Components v1 (Custom Elements, Shadow DOM) - Fetch API :::warning Internet Explorer is not supported. ::: ## Next Steps - [Authentication](/sdks/authentication) - Set up session-based authentication - [React Components](/sdks/react) - Start building with React - [Web Components](/sdks/web-components) - Use without React --- ## CallHistory Display a compact call history list for a specific phone number with direction indicators and AI-generated summaries. ## Basic Usage ```tsx import { CallHistory } from '@dialstack/sdk-react/call-history'; function PatientPanel({ phoneNumber }: { phoneNumber: string }) { return ; } ``` :::warning[Required Prop] The `phoneNumber` prop is required and must be in E.164 format (e.g., `+14155551234`). Without it, the component will display an error state. ::: ## Props | Prop | Type | Default | Description | | ---------------- | ----------------------------------------- | ------------ | ----------------------------------------- | | `phoneNumber` | `string` | **Required** | Phone number to fetch history for (E.164) | | `limit` | `number` | `5` | Maximum number of calls to display (1-20) | | `locale` | `Locale` | English | Custom locale for UI strings | | `formatting` | `FormattingOptions` | - | Date/phone formatting options | | `icons` | `ComponentIcons` | - | Custom SVG icons | | `layoutVariant` | `'compact' \| 'comfortable' \| 'default'` | `'default'` | Layout density | | `classes` | `CallHistoryClasses` | - | Custom CSS classes | | `displayOptions` | `CallHistoryDisplayOptions` | - | Show/hide UI elements | | `onLoaderStart` | `(event) => void` | - | Loading started callback | | `onLoadError` | `(event) => void` | - | Loading failed callback | ## Display Options Control which UI elements are visible: ```tsx ``` ### CallHistoryDisplayOptions | Option | Type | Default | Description | | ------------------- | --------- | ------- | ----------------------------- | | `showDuration` | `boolean` | `true` | Display call length | | `showRelativeTime` | `boolean` | `true` | Show relative timestamps | | `showDirectionIcon` | `boolean` | `true` | Show direction indicator icon | ## Call Types and Icons The component displays different icons based on call type: | Type | Icon Color | Description | | -------------------- | ---------- | ------------------------------------ | | Inbound (completed) | Green | Successfully answered incoming call | | Outbound (completed) | Blue | Successfully connected outgoing call | | Missed | Red | Unanswered incoming call | | Voicemail | Purple | Caller left a voicemail | ## AI Summaries For completed calls and voicemails, the component displays an AI-generated summary of the conversation. The summary appears below the call information with a distinctive "AI Summary" badge. ```tsx // AI summaries are shown automatically for: // - Completed inbound calls // - Completed outbound calls // - Voicemails ``` The summary provides a concise overview of the call content, making it easy to quickly understand what was discussed without listening to recordings. ## Limiting Results Control how many calls are displayed: ```tsx ``` ## Formatting Customize date and time formatting: ```tsx ``` ### FormattingOptions | Option | Type | Default | Description | | --------------- | --------- | --------- | -------------------------- | | `dateLocale` | `string` | `'en-US'` | Locale for date formatting | | `use24HourTime` | `boolean` | `false` | Use 24-hour time format | ## Custom Styling ### CSS Classes Apply custom classes for framework integration: ```tsx ``` ### CallHistoryClasses | Class | Default | Description | | --------------- | ------------------------------------------ | ----------------- | | `base` | `'dialstack-component'` | Container element | | `loading` | `'dialstack-component--loading'` | Loading state | | `error` | `'dialstack-component--error'` | Error state | | `empty` | `'dialstack-component--empty'` | No calls state | | `list` | `'dialstack-call-history-list'` | List container | | `item` | `'dialstack-call-history-item'` | Call item | | `itemInbound` | `'dialstack-call-history-item--inbound'` | Inbound call | | `itemOutbound` | `'dialstack-call-history-item--outbound'` | Outbound call | | `itemMissed` | `'dialstack-call-history-item--missed'` | Missed call | | `itemVoicemail` | `'dialstack-call-history-item--voicemail'` | Voicemail | | `icon` | `'dialstack-call-history-icon'` | Direction icon | | `time` | `'dialstack-call-history-time'` | Time display | | `duration` | `'dialstack-call-history-duration'` | Duration display | ### Layout Variants ```tsx ``` ## CallLog Data Model The component uses the `CallLog` type internally: ```typescript interface CallLog { id: string; user_id?: string; endpoint_id?: string; did_id?: string; direction: 'inbound' | 'outbound' | 'internal'; from_number: string; to_number: string; started_at: string; answered_at?: string; ended_at?: string; duration_seconds?: number; status: 'completed' | 'no-answer' | 'busy' | 'failed' | 'voicemail'; } ``` ## Complete Example ```tsx import { CallHistory } from '@dialstack/sdk-react/call-history'; function PatientDetailPanel({ patient }: { patient: Patient }) { if (!patient.phone) { return

No phone number on file

; } return (

Call History

{ console.error('Failed to load call history:', event.error); }} />
); } ``` ## Use Cases ### Customer Context Panel Display recent interactions when a customer calls in: ```tsx function ScreenPopPanel({ phoneNumber }: { phoneNumber: string }) { return (
); } ``` ### Patient Records Show call history in a healthcare patient record: ```tsx function PatientRecord({ patient }: { patient: Patient }) { return (

Recent Calls

); } ``` ## Next Steps - [CallLogs](/sdks/react/call-logs) - Full call logs table with pagination - [Voicemails](/sdks/react/voicemails) - Voicemail component with playback - [Theming](/sdks/theming) - Customize appearance - [i18n](/sdks/i18n) - Internationalization --- ## CallLogs Display a paginated table of call history with filtering, sorting, and customization options. ## Basic Usage ```tsx import { CallLogs } from '@dialstack/sdk-react/call-logs'; function Dashboard() { return ; } ``` :::info[Automatic Pagination] Pagination is handled automatically by the component using cursor-based pagination. You don't need to manage cursors or page state manually. The component automatically loads more data as users scroll or click "Load More". See the [Pagination Guide](/guides/pagination) for details. ::: ## Props | Prop | Type | Default | Description | | ------------------- | ----------------------------------------- | ----------- | ------------------------------- | | `dateRange` | `{ start?: string; end?: string }` | - | Filter by date range (ISO 8601) | | `locale` | `Locale` | English | Custom locale for UI strings | | `formatting` | `FormattingOptions` | - | Date/phone formatting options | | `icons` | `ComponentIcons` | - | Custom SVG icons | | `layoutVariant` | `'compact' \| 'comfortable' \| 'default'` | `'default'` | Layout density | | `classes` | `CallLogsClasses` | - | Custom CSS classes | | `displayOptions` | `CallLogDisplayOptions` | - | Show/hide columns | | `customRowRenderer` | `(call: CallLog) => string` | - | Custom row rendering | | `onLoaderStart` | `(event) => void` | - | Loading started callback | | `onLoadError` | `(event) => void` | - | Loading failed callback | | `onRowClick` | `(event) => void` | - | Row clicked callback | ## Date Range Filtering Filter calls by date range using ISO 8601 date strings: ```tsx ``` Filter from a specific date to now: ```tsx ``` ## Display Options Control which columns are visible: ```tsx ``` ### CallLogDisplayOptions | Option | Type | Default | Description | | --------------- | --------- | ------- | ------------------------ | | `showDate` | `boolean` | `true` | Show date/time column | | `showDirection` | `boolean` | `true` | Show direction indicator | | `showFrom` | `boolean` | `true` | Show from number | | `showTo` | `boolean` | `true` | Show to number | | `showDuration` | `boolean` | `true` | Show call duration | | `showStatus` | `boolean` | `true` | Show call status | ## Formatting Customize date and phone number formatting: ```tsx ``` ### FormattingOptions | Option | Type | Default | Description | | ---------------- | --------- | --------- | ---------------------------- | | `defaultCountry` | `string` | `'US'` | Country for phone formatting | | `dateLocale` | `string` | `'en-US'` | Locale for dates | | `use24HourTime` | `boolean` | `false` | Use 24-hour time format | | `showTimezone` | `boolean` | `true` | Show timezone in dates | ## Callbacks ### onRowClick Handle row clicks: ```tsx { console.log('Call ID:', event.callId); console.log('Full call data:', event.call); // Navigate to call detail navigate(`/calls/${event.callId}`); }} /> ``` ### onLoadError Handle errors: ```tsx { console.error('Load failed:', event.error); showToast('Failed to load call logs'); }} /> ``` ## Custom Styling ### CSS Classes Apply custom classes for framework integration: ```tsx ``` ### CallLogsClasses | Class | Default | Description | | ------------- | ------------------------------------- | ------------------- | | `base` | `'dialstack-component'` | Container element | | `loading` | `'dialstack-component--loading'` | Loading state | | `error` | `'dialstack-component--error'` | Error state | | `empty` | `'dialstack-component--empty'` | No data state | | `table` | `'dialstack-call-logs-table'` | Table element | | `header` | `'dialstack-call-logs-header'` | Table header | | `row` | `'dialstack-call-logs-row'` | Table row | | `rowInbound` | `'dialstack-call-logs-row--inbound'` | Inbound call row | | `rowOutbound` | `'dialstack-call-logs-row--outbound'` | Outbound call row | | `pagination` | `'dialstack-call-logs-pagination'` | Pagination controls | ### Layout Variants Adjust spacing density: ```tsx // Tighter spacing // Standard spacing // More breathing room ``` ## CallLog Data Model The `CallLog` type represents a single call record: ```typescript interface CallLog { id: string; user_id?: string; endpoint_id?: string; did_id?: string; direction: 'inbound' | 'outbound' | 'internal'; from_number: string; to_number: string; started_at: string; answered_at?: string; ended_at?: string; duration_seconds?: number; status: 'completed' | 'no-answer' | 'busy' | 'failed' | 'voicemail'; } ``` ## Complete Example ```tsx import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { useState } from 'react'; function CallLogsPage() { const [selectedCall, setSelectedCall] = useState(null); // Calculate date range (last 30 days) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); return (

Call History

{ setSelectedCall(event.callId); }} onLoadError={(event) => { console.error('Failed to load calls:', event.error); }} /> {selectedCall && ( setSelectedCall(null)} /> )}
); } ``` ## Next Steps - [Voicemails](/sdks/react/voicemails) - Voicemail component - [Theming](/sdks/theming) - Global appearance customization - [i18n](/sdks/i18n) - Internationalization --- ## DialPlan Embed the DialStack visual dial-plan editor directly in your app. One component, three display modes: read-only viewer, full editor, or static thumbnail. ## Basic Usage ```tsx import { DialPlan } from '@dialstack/sdk-react/dial-plan'; function Routing({ dialPlanId }: { dialPlanId: string }) { return ; } ``` The default mode is `view` — a read-only flow diagram with pan/zoom and controls. ## Modes ```tsx // Read-only diagram (default) // Full editor: node library, drag-and-drop, config panel, save // Static thumbnail: no controls, no background, no interaction // Create mode: no dialPlanId + mode="edit" starts a blank dial plan console.log('Created', dp.id)} /> ``` | Mode | Editable | Pan/zoom | Controls | Use case | | --------- | -------- | -------- | -------- | ----------------------------- | | `view` | No | Yes | Yes | Inspect an existing dial plan | | `edit` | Yes | Yes | Yes | Build or modify a dial plan | | `preview` | No | No | No | Thumbnail in a list or card | ## Props | Prop | Type | Default | Description | | ------------------ | -------------------------------------- | --------- | ---------------------------------------------------- | | `dialPlanId` | `string` | - | Dial plan to load. Omit in `edit` mode to create one | | `mode` | `'view' \| 'edit' \| 'preview'` | `'view'` | Display mode | | `locale` | `DialPlanLocale` | English | i18n strings (labels, exits, descriptions) | | `theme` | `'light' \| 'dark'` | `'light'` | Color theme | | `className` | `string` | - | Container class | | `style` | `React.CSSProperties` | - | Container inline style | | `onNodeClick` | `(nodeId, node) => void` | - | Node click callback (view mode) | | `onLoaderStart` | `() => void` | - | Fired when loading starts | | `onLoaderEnd` | `(dialPlan) => void` | - | Fired when loading finishes | | `onLoadError` | `(error) => void` | - | Fired on load failure | | `onSave` | `(dialPlan) => void` | - | Fired after a successful save (edit mode) | | `onDirtyChange` | `(isDirty) => void` | - | Fired when the editor's dirty state changes | | `onError` | `(error) => void` | - | Alias for `onLoadError`, used in edit mode | | `onCreateResource` | `(type) => Promise<{ id, name, ... }>` | - | Host hook to open a "create new …" modal | | `onOpenResource` | `(resourceId) => void` | - | Host hook to navigate to a resource detail page | ## Imperative Handle Pass a `ref` to trigger actions programmatically: ```tsx import { useRef } from 'react'; import { DialPlan, type DialPlanHandle } from '@dialstack/sdk-react/dial-plan'; function Editor() { const ref = useRef(null); return ( <> ); } ``` | Method | Returns | Description | | -------- | --------------- | -------------------------------------------- | | `save()` | `Promise` | Persist pending changes; resolves on success | ## Node Types The editor supports four node types. The `config` payload is type-specific — the shape is determined by `type`. | Type | Purpose | Exits | | ---------------- | -------------------------------------------------------------------------------------------- | ---------------- | | `schedule` | Route by schedule (open or closed; holidays count as closed) | `open`, `closed` | | `internal_dial` | Ring an internal target — user, ring group, nested dial plan, voice app, or shared voicemail | `next` | | `ring_all_users` | Ring every user in the account at the same time | `next` | | `external_dial` | Dial an external phone number over the PSTN | `next` | :::info[Editor aliases] The editor renders some `internal_dial` nodes as distinct visual tiles — **Voicemail** (for shared-voicemail targets or `timeout: 0`) and **Voice App** (for voice-app targets). The serialized node type stays `internal_dial`; only the on-canvas presentation differs. ::: ### `ScheduleNodeConfig` | Field | Type | Description | | ------------- | ---------------- | ---------------------------------------------------------- | | `schedule_id` | `string` | ID of the schedule to check (required) | | `open` | `string \| null` | Node to route to when the schedule is open | | `closed` | `string \| null` | Node to route to when the schedule is closed or on holiday | ### `InternalDialNodeConfig` | Field | Type | Description | | ----------- | ---------------- | -------------------------------------------------------------------------------------- | | `target_id` | `string` | ID of the target to dial — user, ring group, dial plan, voice app, or shared voicemail | | `timeout` | `number` | Ring timeout in seconds (0–300). `0` routes straight to voicemail | | `next` | `string \| null` | Node to route to on timeout or busy | ### `RingAllUsersNodeConfig` | Field | Type | Description | | --------- | ---------------- | ----------------------------------- | | `timeout` | `number` | Ring timeout in seconds (1–300) | | `next` | `string \| null` | Node to route to on timeout or busy | ### `ExternalDialNodeConfig` | Field | Type | Description | | -------------- | ---------------- | -------------------------------------------- | | `phone_number` | `string` | Destination in E.164 format (`+14155551234`) | | `timeout` | `number` | Ring timeout in seconds (1–120) | | `next` | `string \| null` | Node to route to on timeout or busy | ## Host Integration (`edit` mode) The editor lets users pick targets from your directory. Provide two host hooks so the UI can list resources and create new ones on the fly: ```tsx { // type: 'schedule' | 'user' | 'ring_group' | 'dial_plan' | 'voice_app' | 'shared_voicemail' const created = await openCreateModal(type); return created; // { id, name, extension_number? } }} onOpenResource={(resourceId) => { router.push(`/resources/${resourceId}`); }} /> ``` - `onCreateResource(type)` — called when the user selects "Create new …" in a target combobox. Return the new resource's `{ id, name }` to auto-select it. - `onOpenResource(id)` — called when the user clicks the "Open target details" link next to a selected target. Resource lists themselves come from the DialStack API; nothing extra to wire. ## Events ```tsx setLoading(true)} onLoaderEnd={(dp) => { setLoading(false); setName(dp.name); }} onDirtyChange={setUnsaved} onSave={(dp) => toast.success(`Saved ${dp.name}`)} onError={(err) => toast.error(err.message)} /> ``` ## Internationalization Supply a `locale` object to override every UI string. All fields are required on the type — see `DialPlanLocale` for the full shape. ```tsx ``` ## TypeScript ```tsx import type { DialPlanProps, DialPlanMode, DialPlanHandle, DialPlanData, DialPlanNode, DialPlanNodeType, ScheduleNode, InternalDialNode, RingAllUsersNode, ExternalDialNode, ScheduleNodeConfig, InternalDialNodeConfig, RingAllUsersNodeConfig, ExternalDialNodeConfig, ResourceType, } from '@dialstack/sdk-react/dial-plan'; ``` `DialPlanNode` is a discriminated union — narrow on `node.type` to get the typed `config`. ## Complete Example ```tsx import { useRef, useState } from 'react'; import { DialPlan, type DialPlanHandle } from '@dialstack/sdk-react/dial-plan'; export function DialPlanEditorPage({ id }: { id: string }) { const ref = useRef(null); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); async function handleSave() { setSaving(true); try { await ref.current?.save(); } finally { setSaving(false); } } return (

Edit dial plan

setDirty(false)} onError={(err) => console.error(err)} onCreateResource={async (type) => openCreateModal(type)} onOpenResource={(rid) => router.push(`/resources/${rid}`)} />
); } ``` ## Next Steps - [CallLogs](/sdks/react/call-logs) — Call history table - [Voicemails](/sdks/react/voicemails) — Voicemail component - [Theming](/sdks/theming) — Customize appearance - [i18n](/sdks/i18n) — Internationalization --- ## React SDK The React SDK provides type-safe components for embedding call logs and voicemails into your React application. ## Quick Start ```tsx import { initialize } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { Voicemails } from '@dialstack/sdk-react/voicemails'; // Initialize once at app startup const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', }); function VoiceDashboard({ clientSecret }: { clientSecret: string }) { return (

Voice Dashboard

); } ``` ## Setup ### 1. Initialize the SDK Initialize the SDK once at app startup, outside of any component: ```tsx import { initialize } from '@dialstack/sdk-js'; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'auto', // 'light' | 'dark' | 'auto' }, }); ``` ### 2. Wrap with Provider Wrap your app (or the part using DialStack components) with the provider: ```tsx import { DialstackComponentsProvider } from '@dialstack/sdk-react'; function App() { const [clientSecret, setClientSecret] = useState(null); useEffect(() => { // Fetch from your backend fetch('/api/dialstack/session', { method: 'POST' }) .then((res) => res.json()) .then((data) => setClientSecret(data.client_secret)); }, []); if (!clientSecret) return ; return ( ); } ``` ### 3. Use Components Components automatically connect to the provider: ```tsx import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { Voicemails } from '@dialstack/sdk-react/voicemails'; function Dashboard() { return (
); } ``` ## Available Components | Component | Description | Required Props | | ------------------------------------------------- | ----------------------------------------------------- | -------------- | | [CallHistory](/sdks/react/call-history) | Compact call history for a phone number | `phoneNumber` | | [CallLogs](/sdks/react/call-logs) | Call history table with filtering and pagination | None | | [DialPlan](/sdks/react/dial-plan) | Visual dial-plan viewer / editor | None | | [OnboardingPortal](/sdks/react/onboarding-portal) | Multi-step account onboarding wizard | None | | [Softphone](/sdks/react/softphone) | Embedded WebRTC phone (batteries-included or modular) | None | | [Voicemails](/sdks/react/voicemails) | Voicemail list with audio playback | `userId` | ## Hooks ### useDialstackComponents Access the DialStack instance inside the provider: ```tsx import { useDialstackComponents } from '@dialstack/sdk-react'; function CustomComponent() { const { dialstack, clientSecret } = useDialstackComponents(); // Use for custom integrations return
Connected: {!!clientSecret}
; } ``` ## TypeScript All components are fully typed. Import types as needed: ```tsx import type { DialStackInstance, CallLog, CallLogDisplayOptions, VoicemailDisplayOptions, AppearanceOptions, } from '@dialstack/sdk-js'; ``` ## Error Handling Handle component errors with callbacks: ```tsx console.log('Loading...')} onLoadError={(event) => { console.error('Failed to load:', event.error); // Show error UI or retry }} /> ``` ## Next Steps - [DialstackComponentsProvider](/sdks/react/provider) - Provider configuration - [CallHistory](/sdks/react/call-history) - Compact call history for a phone number - [CallLogs](/sdks/react/call-logs) - Call history table with pagination - [DialPlan](/sdks/react/dial-plan) - Visual dial-plan viewer / editor - [OnboardingPortal](/sdks/react/onboarding-portal) - Multi-step account onboarding wizard - [Softphone](/sdks/react/softphone) - Embedded WebRTC phone - [Voicemails](/sdks/react/voicemails) - Voicemail component - [Theming](/sdks/theming) - Customize appearance --- ## OnboardingPortal A full-screen, multi-step onboarding wizard that guides new accounts through business setup, phone number configuration, and hardware assignment. ## Basic Usage ```tsx import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { OnboardingPortal } from '@dialstack/sdk-react/onboarding'; function OnboardingPage({ dialstack, clientSecret }) { return ( ); } ``` :::info[Component and provider come from different paths] Each component has its own import path, so your app only pulls in what it uses. `OnboardingPortal` comes from `@dialstack/sdk-react/onboarding`, while the provider and hooks come from the `@dialstack/sdk-react` root. ::: :::info[Automatic Progress Saving] The portal automatically saves onboarding progress to the server. Users can leave and return at any time — the wizard resumes where they left off. ::: ## How It Works The onboarding portal presents three main steps, each with its own substeps: | Step | Description | Substeps | | ------------ | ---------------------------------- | ----------------------------------------------------------------------------------- | | **Account** | Business profile and team setup | Business Details, Team Members | | **Numbers** | Phone number ordering and porting | Number options, ordering/porting flow, primary number, caller ID, directory listing | | **Hardware** | Device assignment for team members | Device Assignment | The portal has three view modes that users progress through: 1. **Splash screen** — a welcome screen shown on first visit 2. **Overview** — a dashboard showing progress across all steps with status indicators 3. **Wizard** — the step-by-step form workflow When all steps are complete, a celebration screen is shown. After that, the overview screen serves as the landing page where users can review or revise any step. ## Props | Prop | Type | Default | Description | | ---------------------------- | ----------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- | | `locale` | `Locale` | English | Custom locale for UI strings | | `formatting` | `FormattingOptions` | - | Date/phone formatting options | | `icons` | `ComponentIcons` | - | Custom SVG icons | | `collectionOptions` | `OnboardingCollectionOptions` | - | Control which steps to show | | `theme` | `'light' \| 'dark'` | `'light'` | Color theme | | `appearance` | `{ variables?: { colorPrimary?: string; colorPrimaryHover?: string } }` | - | Brand color overrides | | `canAcceptAgreement` | `boolean` | `true` | Whether this session may accept the subscription agreement (see below) | | `logoHtml` | `string` | - | HTML string for sidebar logo | | `platformName` | `string` | - | Platform name shown in the UI | | `onBack` | `() => void` | - | Back button callback (shows button when provided) | | `backLabel` | `string` | - | Label for the back button | | `fullTermsOfServiceUrl` | `string` | - | Link to full terms of service | | `recipientTermsOfServiceUrl` | `string` | - | Link to recipient terms of service | | `privacyPolicyUrl` | `string` | - | Link to privacy policy | | `documentationUrl` | `string` | - | Link shown on the overview screen | | `onScheduleCall` | `() => void` | - | Callback for "Schedule a Call" on the overview screen | | `onHelpSupport` | `() => void` | - | Callback for "Help & Support" in the sidebar | | `onStepChange` | `(event: { step: AccountOnboardingStep }) => void` | - | Called when the active step changes | | `className` | `string` | - | Additional CSS class for the root element | | `style` | `React.CSSProperties` | - | Inline styles for the root element | ## The Subscription Agreement Gate Before an account can place calls it must accept the DialStack Service Subscription Agreement. The portal enforces this as a blocking screen: until the agreement is accepted, it replaces the whole portal. Accepting is a capability on the session, not something the API infers about the signed-in person. A session may accept only if you enabled the `agreement_acceptance` component when you minted it — see [Account Sessions](../server/sessions.md). `account_onboarding` alone lets the session _read_ the agreement and render the screen, but not sign it. Set `canAcceptAgreement` to the same condition you used at mint: ```tsx ``` When it is `false`, the screen still renders the full agreement — so the reader learns what is pending and that service is blocked — but the affirmation and the Accept button are replaced by a note asking the account owner to accept. The prop defaults to `true`, so an existing integration keeps working unchanged. It is a user-experience control, not a security boundary: if the session lacks the component, the submission is rejected regardless. Leaving it `true` for a session that cannot sign simply strands the user on a button that cannot succeed. ## Controlling Steps Use `collectionOptions` to show or hide specific steps. The final completion screen is always shown regardless of these options. ### Show only specific steps ```tsx ``` ### Exclude specific steps ```tsx ``` ### OnboardingCollectionOptions | Option | Type | Description | | --------------- | ------------------------- | --------------------- | | `steps.include` | `AccountOnboardingStep[]` | Show only these steps | | `steps.exclude` | `AccountOnboardingStep[]` | Hide these steps | When both `include` and `exclude` are set, `include` is applied first, then `exclude` removes from that result. ### AccountOnboardingStep The available step values are: | Value | Description | | ------------------ | ------------------------------------------------- | | `'account'` | Business details and team members | | `'numbers'` | Phone number ordering, porting, and configuration | | `'hardware'` | Device assignment | | `'final_complete'` | Completion screen (always shown) | ## White-Labeling Customize the portal's branding to match your platform: ```tsx Acme Telecom `} appearance={{ variables: { colorPrimary: '#00A67E', colorPrimaryHover: '#008F6D', }, }} /> ``` The `appearance.variables` colors are applied to the sidebar, buttons, and accent elements throughout the wizard. When not provided, the portal uses the colors set via `initialize()`. ## Dark Theme ```tsx ``` The theme can also be set globally via the `appearance.theme` option in `initialize()`. The component-level `theme` prop takes precedence. ## Navigation Callbacks ### onBack Provide an `onBack` callback to show a back button in the sidebar. Use this to let users navigate back to your application: ```tsx router.push('/dashboard')} backLabel="Back to Dashboard" /> ``` ### onStepChange Track which step the user is on: ```tsx { analytics.track('onboarding_step', { step: event.step }); }} /> ``` ## Help and Support The overview screen and sidebar can link to help resources: ```tsx { window.open('https://calendly.com/example/onboarding', '_blank'); }} onHelpSupport={() => { window.Intercom('showNewMessage', 'I need help with onboarding'); }} /> ``` - `documentationUrl` — shows a "View Documentation" link on the overview screen - `onScheduleCall` — shows a "Schedule a Call" button on the overview screen - `onHelpSupport` — shows a "Help & Support" link in the sidebar ## Legal Links Display terms and privacy links on the completion screen: ```tsx ``` ## Complete Example ```tsx import { initialize } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { OnboardingPortal } from '@dialstack/sdk-react/onboarding'; import { useEffect, useState } from 'react'; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'light', }, }); function OnboardingApp() { const [clientSecret, setClientSecret] = useState(null); useEffect(() => { fetch('/api/dialstack/session', { method: 'POST' }) .then((res) => res.json()) .then((data) => setClientSecret(data.client_secret)); }, []); if (!clientSecret) return
Loading...
; return ( Acme Telecom `} appearance={{ variables: { colorPrimary: '#6772E5', colorPrimaryHover: '#5469D4', }, }} collectionOptions={{ steps: { exclude: ['hardware'] }, }} onBack={() => (window.location.href = '/dashboard')} backLabel="Back to Dashboard" documentationUrl="https://docs.example.com/setup" onScheduleCall={() => window.open('https://calendly.com/example')} onHelpSupport={() => window.Intercom?.('show')} onStepChange={(event) => { console.log('Step changed:', event.step); }} fullTermsOfServiceUrl="https://example.com/terms" privacyPolicyUrl="https://example.com/privacy" /> ); } ``` ## Next Steps - [DialstackComponentsProvider](/sdks/react/provider) - Provider configuration - [Theming](/sdks/theming) - Global appearance customization - [i18n](/sdks/i18n) - Internationalization --- ## DialstackComponentsProvider The provider makes the DialStack instance and client secret available to all child components. ## Usage ```tsx import { initialize } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', }); function App() { return ( ); } ``` ## Props | Prop | Type | Required | Description | | -------------- | -------------------------------- | -------- | ------------------------------- | | `dialstack` | `DialStackInstance` | Yes | Instance from `initialize()` | | `clientSecret` | `string \| ClientSecretResponse` | Yes | Session token from your backend | | `children` | `ReactNode` | Yes | Child components | ### clientSecret The `clientSecret` prop accepts either a string or the full session response object: ```tsx // String only - you handle refresh // Full response - SDK handles refresh automatically ``` ## useDialstackComponents Hook Access the provider context in child components: ```tsx import { useDialstackComponents } from '@dialstack/sdk-react'; function StatusIndicator() { const { dialstack, clientSecret } = useDialstackComponents(); return
{clientSecret ? 'Connected' : 'Not connected'}
; } ``` ### Return Value ```typescript interface UseDialstackComponentsReturn { dialstack: DialStackInstance; clientSecret: string | ClientSecretResponse; } ``` ## Error: Missing Provider If you use a component outside the provider, you'll see this error: ``` Error: Could not find DialStack context; You need to wrap your app in a provider. See https://docs.dialstack.ai/sdks/react for setup instructions. ``` **Fix:** Wrap your component tree with the provider: ```tsx // Before (error) function App() { return ; } // After (working) function App() { return ( ); } ``` ## Multiple Providers You can use multiple providers for different accounts: ```tsx function MultiAccountDashboard() { return (

Account A

Account B

); } ``` ## Complete Example ```tsx import { useState, useEffect } from 'react'; import { initialize } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { Voicemails } from '@dialstack/sdk-react/voicemails'; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'auto', variables: { colorPrimary: '#6772E5', }, }, }); interface Session { client_secret: string; expires_at: string; } function App() { const [session, setSession] = useState(null); const [error, setError] = useState(null); useEffect(() => { async function fetchSession() { try { const response = await fetch('/api/dialstack/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: 'acct_123' }), }); if (!response.ok) { throw new Error('Failed to create session'); } const data = await response.json(); setSession(data); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } } fetchSession(); }, []); if (error) { return
Error: {error}
; } if (!session) { return
Loading...
; } return (

Voice Dashboard

); } export default App; ``` ## Handling API errors Calls made through the instance reject with an `ApiError`, which carries the HTTP `status` and, when the API returns one, a machine-readable `code`. Use `isApiError` to narrow an unknown error: ```tsx import { isApiError } from '@dialstack/sdk-js'; try { await dialstack.phoneNumbers.list({ limit: 20 }); } catch (err) { if (isApiError(err)) { if (err.status === 401) { // Session expired — mint a new client secret } console.error(err.status, err.code, err.message); } else { throw err; } } ``` Prefer `isApiError(err)` over `err instanceof ApiError`. The error is created inside `@dialstack/sdk-js` but caught in your code, and `instanceof` compares against one specific class, so it can return `false` even for a genuine API error — for example when the error crosses an iframe or worker boundary, or when more than one copy of the package ends up installed. `isApiError` checks the error's own fields instead, so it holds in those cases. The `ApiError` class is still exported if you need it for other purposes. ## Next Steps - [CallHistory](/sdks/react/call-history) - Compact call history for a phone number - [CallLogs](/sdks/react/call-logs) - Call history table with pagination - [Softphone](/sdks/react/softphone) - Embedded WebRTC phone - [Voicemails](/sdks/react/voicemails) - Voicemail component - [Authentication](/sdks/authentication) - Session management --- ## Softphone Embed a fully-functional WebRTC phone — dial pad, incoming-call answer/decline, in-call controls (mute, hold, transfer, add call, DTMF, microphone/speaker selection), ringtone, audio, and the E911 flow — into your React app. The softphone ships two ways to embed it, both driven by **one provider**: 1. **Batteries-included** — drop `` inside `` and you have a working phone. 2. **Modular** — compose the individual pieces (`DialPad`, `IncomingCall` / `IncomingStack`, `OngoingCall`, `EmergencyBanner`) and the state hooks under the **same** provider to build a bespoke layout. Everything below imports from `@dialstack/sdk-react/softphone`: ```tsx import { SoftphoneProvider, Softphone, DialPad, OngoingCall, IncomingStack, EmergencyBanner, useSoftphone, } from '@dialstack/sdk-react/softphone'; ``` :::info[Server SDK for tokens] The provider needs a short-lived session `token`, minted on your server with `@dialstack/sdk-server`. Your secret key never reaches the browser — see [Getting a token](#getting-a-token). ::: ## The provider `` owns everything the phone needs to stay alive: - the **connection** and its lifecycle (`connecting` → `connected` → `reconnecting` → …), - the **token** and its in-band refresh (via `onTokenExpiring`), - the remote **audio** sink and the incoming **ringtone**, - the **E911** (emergency address) flow. Because the connection lives in the provider — not in any UI component — the phone stays connected while the UI mounts and unmounts (for example, when the softphone lives inside a drawer). Mount the provider **once**, high in your tree. ```tsx import { SoftphoneProvider, Softphone } from '@dialstack/sdk-react/softphone'; const Phone: React.FC<{ token: string; apiBaseUrl: string }> = ({ token, apiBaseUrl }) => ( console.error(`${e.code} — ${e.message}`)} > ); ``` ### Props | Prop | Type | Required | Description | | ------------------------- | ----------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | | `token` | `string` | Yes | WebRTC user-session token. The provider connects with it; changing it reconnects with the new credentials. | | `apiBaseUrl` | `string` | No | API base URL. Defaults to the SDK's production endpoint. | | `onTokenExpiring` | `() => Promise` | No | Called ~60s before the token expires. Return a fresh token; the SDK delivers it in-band — no reconnect, no call disruption. | | `iceServers` | `RTCIceServer[]` | No | Override the ICE servers instead of fetching them from the API. | | `emergencyAddressId` | `string` | No | E911 address to present on connect. When supplied, **you** manage E911 and the built-in location prompt is disabled. | | `autoConnect` | `boolean` | No | Connect automatically once mounted. Defaults to `true`. Set `false` to render before the token is ready. | | `appearance` | `AppearanceOptions` | No | Theming — the same appearance surface used across the SDK (`theme`, `variables`). | | `locale` | `Locale` | No | Locale for the built-in UI strings. | | `formatting` | `FormattingOptions` | No | Display formatting (e.g. `defaultCountry` for phone-number rendering). | | `onConnectionStateChange` | `(event: { state: SoftphoneConnectionState }) => void` | No | Fired when the connection lifecycle changes. | | `onIncomingCall` | `(event: { from: string; fromName: string \| null }) => void` | No | Fired when an inbound call arrives. | | `onCallStarted` | `(event: { direction: 'inbound' \| 'outbound'; peer: string }) => void` | No | Fired when a call (in or out) becomes the foreground call. | | `onCallEnded` | `(event: { reason: CallEndReason }) => void` | No | Fired when the foreground call ends. | | `onError` | `(event: { code: string; message: string }) => void` | No | Fired on a non-fatal or fatal phone error. | | `children` | `ReactNode` | Yes | The softphone UI (or anything consuming the context). | `SoftphoneConnectionState` is one of `'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'`. ### Getting a token Mint the session **on your server** so your `sk_live_*` key never reaches the browser. Use the server SDK's `userSessions.create`: ```ts // app/api/session/route.ts (Next.js route handler) import { NextResponse } from 'next/server'; import { DialStack } from '@dialstack/sdk-server'; export async function POST() { const dialstack = new DialStack(process.env.DIALSTACK_SECRET_KEY!, { apiUrl: process.env.DIALSTACK_API_BASE_URL, }); const session = await dialstack.userSessions.create({ user: process.env.DIALSTACK_USER_ID! }); return NextResponse.json({ token: session.client_secret, apiBaseUrl: process.env.DIALSTACK_API_BASE_URL, }); } ``` Then fetch it in the browser and hand `token` + `apiBaseUrl` to the provider: ```tsx import { useState } from 'react'; import { SoftphoneProvider, Softphone } from '@dialstack/sdk-react/softphone'; type Session = { token: string; apiBaseUrl: string }; const PhoneApp: React.FC = () => { const [session, setSession] = useState(null); const connect = async () => { const resp = await fetch('/api/session', { method: 'POST' }); setSession((await resp.json()) as Session); }; if (!session) return ; return ( ); }; ``` The runnable [`web-softphone-example`](https://github.com/dialstack/dialstack-sdk/tree/main/examples/web-softphone-example) shows the full server-mint pattern, including the environment-pinned user. ## Batteries-included: `` The drop-in. It renders the whole phone and switches between the dial pad, incoming-call cards, and the in-call screen based on live call state. It is a **pure consumer** of the provider — it takes no `token`; render it inside ``: ```tsx import { SoftphoneProvider, Softphone } from '@dialstack/sdk-react/softphone'; const App: React.FC<{ token: string }> = ({ token }) => ( ); ``` | Prop | Type | Default | Description | | ---------------------- | --------- | ------- | ---------------------------------------------------------------------------- | | `autoFocusDestination` | `boolean` | `false` | Focus the dial pad's number field on mount (e.g. when it opens in a drawer). | Rendering `` outside a provider throws — the provider is where the connection and token live. ## Modular composition Reach for the modular flow when you want a **custom layout** — a two-column desk-phone, a docked call bar, the dial pad in one panel and the active call in another — while keeping the SDK's call logic. Compose the pieces under the same `` and add your own chrome around them. The composable UI pieces are all **pure consumers** of the provider and each renders its own scoped wrapper, so you can drop any one of them directly under ``: | Component | Renders | | ----------------- | ---------------------------------------------------------------------------------------------------- | | `DialPad` | The idle/outbound screen: status chip, number field, 12-key pad, call button. | | `IncomingCall` | The first ringing inbound call as a full-size card; nothing when none is ringing. | | `IncomingStack` | **All** ringing inbound calls as stacked answer/decline cards (compact when several). | | `OngoingCall` | The in-call screen: peer, live duration, mute/hold/keypad/transfer/add-call/audio controls, hang up. | | `EmergencyBanner` | The E911 "set your location" prompt. Self-hides when it doesn't apply. | ```tsx import { SoftphoneProvider, DialPad, IncomingStack, OngoingCall, EmergencyBanner, useSoftphone, useActiveCall, useIncomingCall, useCallDuration, } from '@dialstack/sdk-react/softphone'; const CustomSoftphone: React.FC = () => { const { connection } = useSoftphone(); const { activeCall } = useActiveCall(); const incoming = useIncomingCall(); const duration = useCallDuration(activeCall); return (
{/* Custom status header built from the hooks */}
{connection} {activeCall && {duration}}
{/* E911 prompt, hoisted to the top — decoupled from the dial pad. It self-hides once a location is bound (or when the host manages E911). */} {/* Left: the dial pad */}
{/* Right: whatever is "on the line" right now */}
{incoming && !activeCall ? ( ) : activeCall ? ( ) : (

No active call

)}
); }; const App: React.FC<{ token: string }> = ({ token }) => ( ); ``` `DialPad`, `OngoingCall`, `IncomingStack`, and `EmergencyBanner` all read from context — none take a `call` prop. `OngoingCall` and `IncomingStack` render nothing when there's no matching call, so an idle placeholder covers the empty case. ### Placing a call from anywhere Any component under the provider can place a call — you don't have to route through the dial pad. `useSoftphone().placeCall(destination)` starts an outbound call (it cleans the input and reports invalid/empty input via `onError`): ```tsx import { useSoftphone } from '@dialstack/sdk-react/softphone'; const CallButton: React.FC<{ number: string }> = ({ number }) => { const { placeCall } = useSoftphone(); return ; }; ``` ## Audio devices `` includes an **Audio** control for picking a microphone and speaker — for shared workstations, where someone sits down, chooses their headset, and it sticks. Both apply to the call already in progress, and switching the microphone preserves mute. Call audio, the incoming ringtone, and the outbound ringback all follow the chosen speaker. If the microphone is unplugged mid-call, the picker says so and offers a one-click re-pick. ## Hooks All hooks must be called inside a `` (they throw otherwise). If you want calling without the provider — or without React — use the headless core directly: `DialStackPhone` and `Call` from [`@dialstack/sdk-webrtc`](/webrtc/sdk) expose the whole call lifecycle, including the emergency-address methods. ### `useSoftphone()` The full context accessor — everything the UI pieces read. Returns: | Field | Type | Description | | --------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `connection` | `SoftphoneConnectionState` | Connection lifecycle state. | | `calls` | `Call[]` | Every live call leg (active, held, and ringing inbound). | | `activeCall` | `Call \| null` | The call the user is talking to, or `null`. | | `incomingCalls` | `Call[]` | Ringing inbound calls not yet answered. | | `heldCalls` | `Call[]` | Answered calls currently on hold. | | `answerCall` | `(call: Call) => void` | Answer a specific ringing call (auto-holds the active call). | | `switchToCall` | `(call: Call) => void` | Switch the active call to an already-answered held call. | | `actions` | `UseCallActions` | Action callbacks for the foreground call (see `useCallActions`). | | `duration` | `string` | Live `m:ss` duration of the active call. | | `placeCall` | `(destination: string) => Promise` | Place an outbound call. Cleans the input (trims/normalizes), reports invalid/empty input via `onError`, and resolves when the attempt settles. | | `lastError` | `{ code: string; message: string } \| null` | The last error surfaced to the user. | | `clearError` | `() => void` | Dismiss the current error. | | `displayNumber` | `(value: string) => string` | Pretty-print a number for display. | Plus the attended-transfer surface (`consultCall`, `transferOriginal`, `startAttendedTransfer`, `completeAttendedTransfer`, `cancelAttendedTransfer`) and the E911 binding (`emergency`). ```tsx import { useSoftphone } from '@dialstack/sdk-react/softphone'; const StatusBar: React.FC = () => { const { connection, activeCall, duration } = useSoftphone(); return (
{connection === 'connected' ? 'Online' : connection} {activeCall && ` · on a call (${duration})`}
); }; ``` ### `useActiveCall()` The foreground call and its actions — a focused slice of `useSoftphone()`. Returns `{ activeCall: Call | null; actions: UseCallActions }`. ```tsx import { useActiveCall } from '@dialstack/sdk-react/softphone'; const HangupButton: React.FC = () => { const { activeCall, actions } = useActiveCall(); if (!activeCall) return null; return ; }; ``` ### `useIncomingCall()` The single currently-ringing inbound call, or `null`. Useful for driving a custom ringing indicator; render the ringing calls themselves with `IncomingCall` / `IncomingStack`. ```tsx import { useIncomingCall } from '@dialstack/sdk-react/softphone'; const Ringing: React.FC = () => { const call = useIncomingCall(); return call ?

Incoming call…

: null; }; ``` ### `useCallActions(call, options?)` Imperative call-control callbacks for a given `call` (usually the active one). This is call control only — no view-state — so a custom layout gets the actions without any built-in-UI plumbing. Returns a `UseCallActions`: | Field | Type | Description | | ---------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `answer` | `() => void` | Answer the call. | | `reject` | `() => void` | Reject a ringing call. | | `hangup` | `() => void` | Hang up. | | `toggleMute` | `() => void` | Mute / unmute. | | `toggleHold` | `() => void` | Hold / resume. | | `sendDtmf` | `(digit: string) => void` | Send a DTMF digit. | | `transfer` | `(destination: string) => boolean` | **Blind**-transfer the call (hand off immediately). Returns `true` when the transfer was _initiated_ (so you can close your own transfer UI), `false` on empty input or a synchronous failure reported via `onError`. `true` means initiated, not confirmed — the outcome arrives later as the call ending (`transferred`) or an `onError`. | | `callActionsFor` | `(call: Call \| null) => CallActions` | Build the action subset for a _specific_ call. | A custom layout owns its own presentation state (which panels are open, its transfer input) and simply calls `sendDtmf` / `transfer` directly — closing its transfer UI when `transfer` returns `true`. Errors thrown by the core surface through `options.onError` rather than escaping to the caller. Inside a provider, the ready-bound actions are on `useSoftphone().actions` / `useActiveCall().actions` — you rarely call `useCallActions` yourself. ```tsx import { useActiveCall } from '@dialstack/sdk-react/softphone'; const CallControls: React.FC = () => { const { activeCall, actions } = useActiveCall(); if (!activeCall) return null; return (
); }; ``` ### `useCallDuration(call, intervalMs?)` The live `m:ss` duration string for `call`, re-rendering as the call runs (default tick `500ms`). Returns `'0:00'` when there is no active call. ```tsx import { useActiveCall, useCallDuration } from '@dialstack/sdk-react/softphone'; const Timer: React.FC = () => { const { activeCall } = useActiveCall(); const duration = useCallDuration(activeCall); return {duration}; }; ``` ### `useLastError(onError?)` Owns the "last error" state that drives the built-in error banner. Returns `{ lastError, handleError, clearError }`. The provider wires this internally and exposes the result as `lastError`/`clearError` on `useSoftphone()`, so reach for this hook only if you are driving error state yourself. ```tsx import { useSoftphone } from '@dialstack/sdk-react/softphone'; const ErrorBanner: React.FC = () => { const { lastError, clearError } = useSoftphone(); if (!lastError) return null; return (
{lastError.message}
); }; ``` ### `useDialInput(setValue)` Shared cleaning for a dial-string input. Returns `{ onType, onPasteText }`: wire `onType` to your field's change event (strips display separators only, so typing stays natural) and `onPasteText` to its paste event (fully normalizes a pasted number to E.164). Used to build a custom number field that behaves like the built-in dial pad. ```tsx import { useState } from 'react'; import { useDialInput, useSoftphone } from '@dialstack/sdk-react/softphone'; const CustomDialField: React.FC = () => { const { placeCall } = useSoftphone(); const [value, setValue] = useState(''); const { onType, onPasteText } = useDialInput(setValue); return ( onType(e.target.value)} onPaste={(e) => { e.preventDefault(); onPasteText(e.clipboardData.getData('text')); }} onKeyDown={(e) => { if (e.key === 'Enter') void placeCall(value); }} /> ); }; ``` ## Full working example The [`web-softphone-example`](https://github.com/dialstack/dialstack-sdk/tree/main/examples/web-softphone-example) is a runnable Next.js app that mints a session server-side and renders the softphone. It has a labeled toggle that switches between the batteries-included `` and a hand-composed modular layout under **one** `` — swapping the UI without ever reconnecting — so it's the complete, working version of both flows described here. ## Next steps - [Theming](/sdks/theming) — customize the softphone's appearance - [i18n](/sdks/i18n) — localize the built-in UI strings - [WebRTC: Calling](/webrtc/calling) — the underlying call model - [WebRTC: Emergency (E911)](/webrtc/emergency) — the emergency-address flow --- ## Voicemails Display a list of voicemails with audio playback, transcription, and action buttons. ## Basic Usage ```tsx import { Voicemails } from '@dialstack/sdk-react/voicemails'; function Dashboard() { return ; } ``` :::warning[Required Prop] The `userId` prop is required. Without it, the component will display an error state. ::: :::info[Automatic Pagination] Pagination is handled automatically by the component using cursor-based pagination. The component automatically loads more voicemails as users scroll or interact with the list. See the [Pagination Guide](/guides/pagination) for details. ::: ## Props | Prop | Type | Default | Description | | ------------------- | ----------------------------------------- | ------------ | --------------------------------- | | `userId` | `string` | **Required** | User ID to fetch voicemails for | | `locale` | `Locale` | English | Custom locale for UI strings | | `formatting` | `FormattingOptions` | - | Date/phone formatting options | | `icons` | `ComponentIcons` | - | Custom SVG icons | | `layoutVariant` | `'compact' \| 'comfortable' \| 'default'` | `'default'` | Layout density | | `classes` | `VoicemailsClasses` | - | Custom CSS classes | | `displayOptions` | `VoicemailDisplayOptions` | - | Show/hide UI elements | | `behaviorOptions` | `VoicemailBehaviorOptions` | - | Playback and interaction behavior | | `customRowRenderer` | `(voicemail) => string` | - | Custom row rendering | | `onLoaderStart` | `(event) => void` | - | Loading started callback | | `onLoadError` | `(event) => void` | - | Loading failed callback | | `onVoicemailSelect` | `(event) => void` | - | Voicemail selected callback | | `onVoicemailPlay` | `(event) => void` | - | Playback started callback | | `onVoicemailPause` | `(event) => void` | - | Playback paused callback | | `onVoicemailDelete` | `(event) => void` | - | Voicemail deleted callback | | `onCallBack` | `(event) => void` | - | Call back button clicked | | `onDeleteRequest` | `(id) => Promise` | - | Custom delete confirmation | ## Display Options Control which UI elements are visible: ```tsx ``` ### VoicemailDisplayOptions | Option | Type | Default | Description | | -------------------- | --------- | ------- | ------------------------ | | `showDuration` | `boolean` | `true` | Display voicemail length | | `showTranscription` | `boolean` | `true` | Show transcription text | | `showCallbackButton` | `boolean` | `true` | Enable call back action | | `showDeleteButton` | `boolean` | `true` | Enable delete action | | `showProgressBar` | `boolean` | `true` | Show audio progress bar | | `showTimestamp` | `boolean` | `true` | Display received time | ## Behavior Options Control playback and interaction behavior: ```tsx ``` ### VoicemailBehaviorOptions | Option | Type | Default | Description | | --------------------- | --------- | ------- | ------------------------------ | | `autoPlayOnExpand` | `boolean` | `true` | Start playback when expanding | | `confirmBeforeDelete` | `boolean` | `true` | Show confirmation dialog | | `markAsReadOnPlay` | `boolean` | `true` | Mark as read on first play | | `allowSeeking` | `boolean` | `true` | Enable seeking in progress bar | ## Callbacks ### onVoicemailSelect Triggered when a voicemail is expanded: ```tsx { console.log('Selected voicemail:', event.voicemailId); }} /> ``` ### onVoicemailPlay / onVoicemailPause Track playback state: ```tsx { analytics.track('voicemail_played', { id: event.voicemailId }); }} onVoicemailPause={(event) => { analytics.track('voicemail_paused', { id: event.voicemailId }); }} /> ``` ### onCallBack Handle call back button clicks: ```tsx { // Initiate call to the number initiateCall(event.phoneNumber); }} /> ``` ### onVoicemailDelete Triggered after a voicemail is deleted: ```tsx { showToast('Voicemail deleted'); }} /> ``` ### onDeleteRequest Custom delete confirmation logic: ```tsx { // Show custom confirmation dialog const confirmed = await showConfirmDialog({ title: 'Delete Voicemail', message: 'Are you sure? This cannot be undone.', }); return confirmed; // Return true to proceed, false to cancel }} /> ``` ## Custom Styling ### CSS Classes Apply custom classes: ```tsx ``` ### VoicemailsClasses | Class | Default | Description | | -------------- | -------------------------------------- | ------------------- | | `base` | `'dialstack-component'` | Container element | | `loading` | `'dialstack-component--loading'` | Loading state | | `error` | `'dialstack-component--error'` | Error state | | `empty` | `'dialstack-component--empty'` | No voicemails state | | `list` | `'dialstack-voicemail-list'` | List container | | `item` | `'dialstack-voicemail-item'` | Voicemail item | | `itemExpanded` | `'dialstack-voicemail-item--expanded'` | Expanded item | | `itemUnread` | `'dialstack-voicemail-item--unread'` | Unread item | | `player` | `'dialstack-voicemail-player'` | Audio player | | `actions` | `'dialstack-voicemail-actions'` | Action buttons | ### Layout Variants ```tsx ``` ## Voicemail Data Model ```typescript interface Voicemail { id: string; from_name: string; from_number: string; created_at: string; duration_seconds: number; is_read: boolean; transcription?: string; } ``` ## Complete Example ```tsx import { Voicemails } from '@dialstack/sdk-react/voicemails'; import { useCallback, useState } from 'react'; function VoicemailInbox({ userId }: { userId: string }) { const [lastPlayed, setLastPlayed] = useState(null); const handleCallBack = useCallback((event: { phoneNumber: string }) => { // Open phone dialer or initiate WebRTC call window.location.href = `tel:${event.phoneNumber}`; }, []); const handleDeleteRequest = useCallback(async (voicemailId: string) => { const confirmed = window.confirm('Are you sure you want to delete this voicemail?'); return confirmed; }, []); return (

Voicemails

{ console.log('Selected:', event.voicemailId); }} onVoicemailPlay={(event) => { setLastPlayed(event.voicemailId); }} onCallBack={handleCallBack} onDeleteRequest={handleDeleteRequest} onVoicemailDelete={(event) => { console.log('Deleted:', event.voicemailId); }} onLoadError={(event) => { console.error('Load error:', event.error); }} />
); } ``` ## Accessibility The Voicemails component includes: - **ARIA labels** for all interactive elements - **Keyboard navigation** (Tab, Enter, Space) - **Focus management** when expanding/collapsing items - **Screen reader announcements** for state changes - **Visible focus indicators** ## Next Steps - [CallLogs](/sdks/react/call-logs) - Call history component - [Theming](/sdks/theming) - Customize appearance - [i18n](/sdks/i18n) - Internationalization --- ## Accounts Accounts represent your customers in DialStack. Each account is isolated and can have multiple users, phone numbers, and devices. ## Create an Account ```typescript const account = await dialstack.accounts.create({ email: 'customer@example.com', }); console.log(account.id); // 'acct_01h2xcejqtf2nbrexx3vqjhp41' ``` ### Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | --------------------- | | `email` | `string` | No | Account email address | ### Response ```typescript interface Account { id: string; email: string | null; created_at: string; updated_at: string; } ``` ## Retrieve an Account ```typescript const account = await dialstack.accounts.retrieve('acct_01h2xcejqtf2nbrexx3vqjhp41'); console.log(account.email); ``` ### Parameters | Parameter | Type | Required | Description | | ----------- | -------- | -------- | ----------- | | `accountId` | `string` | Yes | Account ID | ## Update an Account ```typescript const account = await dialstack.accounts.update('acct_01h2xcejqtf2nbrexx3vqjhp41', { email: 'newemail@example.com', }); ``` ### Parameters | Parameter | Type | Required | Description | | ----------- | -------- | -------- | ----------------- | | `accountId` | `string` | Yes | Account ID | | `email` | `string` | No | New email address | ## Delete an Account ```typescript await dialstack.accounts.del('acct_01h2xcejqtf2nbrexx3vqjhp41'); ``` :::danger[Destructive Action] Deleting an account removes all associated users, devices, and data. This action cannot be undone. ::: ### Parameters | Parameter | Type | Required | Description | | ----------- | -------- | -------- | ----------- | | `accountId` | `string` | Yes | Account ID | ## List Accounts ```typescript const response = await dialstack.accounts.list({ limit: 20, }); for (const account of response.data) { console.log(account.id, account.email); } if (response.has_more) { // Fetch next page } ``` ### Parameters | Parameter | Type | Default | Description | | ---------------- | -------- | ------- | -------------------------- | | `limit` | `number` | `10` | Results per page (max 100) | | `starting_after` | `string` | - | Cursor for next page | | `ending_before` | `string` | - | Cursor for previous page | ### Response ```typescript interface ListResponse { data: Account[]; has_more: boolean; } ``` ## Auto-Pagination ### Iterate All Accounts ```typescript for await (const account of dialstack.accounts.list().autoPagingEach()) { console.log(account.id); } ``` ### Collect to Array ```typescript const allAccounts = await dialstack.accounts.list().autoPagingToArray({ limit: 1000 }); console.log(`Total accounts: ${allAccounts.length}`); ``` ## Examples ### Create Account with Error Handling ```typescript import { DialStack, DialStackValidationError, DialStackError } from '@dialstack/sdk-server'; async function createAccount(email: string) { try { const account = await dialstack.accounts.create({ email }); return { success: true, account }; } catch (error) { if (error instanceof DialStackValidationError) { return { success: false, error: 'Invalid email format' }; } throw error; } } ``` ### Sync Accounts from External System ```typescript async function syncAccounts(externalAccounts: ExternalAccount[]) { const results = []; for (const ext of externalAccounts) { // Check if account exists let account: Account | null = null; try { account = await dialstack.accounts.retrieve(ext.dialstackId); } catch (error) { if (!(error instanceof DialStackNotFoundError)) { throw error; } } if (account) { // Update existing account = await dialstack.accounts.update(ext.dialstackId, { email: ext.email, }); } else { // Create new account = await dialstack.accounts.create({ email: ext.email, }); } results.push(account); } return results; } ``` ### List with Pagination ```typescript async function getAllAccounts() { const accounts: Account[] = []; let hasMore = true; let startingAfter: string | undefined; while (hasMore) { const response = await dialstack.accounts.list({ limit: 100, starting_after: startingAfter, }); accounts.push(...response.data); hasMore = response.has_more; if (response.data.length > 0) { startingAfter = response.data[response.data.length - 1].id; } } return accounts; } ``` ## Next Steps - [Users](/sdks/server/users) - Manage users in accounts - [Sessions](/sdks/server/sessions) - Create session tokens --- ## Server SDK The Server SDK provides a type-safe Node.js client for the DialStack API. Use it to manage accounts, users, and create session tokens for embedded components. ## Installation ```bash npm install @dialstack/sdk-server ``` ## Quick Start ```typescript import { DialStack } from '@dialstack/sdk-server'; const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); // Create an account const account = await dialstack.accounts.create({ email: 'customer@example.com', }); // Create a user (account context passed in options) const user = await dialstack.users.create( { name: 'John Doe', email: 'john@example.com' }, { dialstackAccount: account.id } ); // Create a session for embedded components const session = await dialstack.accountSessions.create({ account: account.id, components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); // Return session to frontend console.log(session.client_secret); ``` ## Configuration ### Basic Configuration ```typescript const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); ``` ### Advanced Configuration ```typescript const dialstack = new DialStack(process.env.DIALSTACK_API_KEY, { apiUrl: 'https://api.dialstack.ai', // Custom API URL timeout: 80000, // Request timeout (ms) maxNetworkRetries: 2, // Retry count telemetry: true, // Enable telemetry appInfo: { // Your app info (for User-Agent) name: 'MyApp', version: '1.0.0', url: 'https://myapp.com', }, }); ``` ### DialStackConfig Options | Option | Type | Default | Description | | ------------------- | --------- | -------------------------- | -------------------------- | | `apiUrl` | `string` | `https://api.dialstack.ai` | API base URL | | `timeout` | `number` | `80000` | Request timeout in ms | | `maxNetworkRetries` | `number` | `2` | Max retry attempts | | `telemetry` | `boolean` | `true` | Send usage telemetry | | `appInfo` | `AppInfo` | - | Application identification | ## Resources The SDK provides these resource namespaces: | Resource | Description | | ------------------------------------------ | ----------------------------------- | | [`accounts`](/sdks/server/accounts) | Create and manage customer accounts | | [`users`](/sdks/server/users) | Manage users within accounts | | [`accountSessions`](/sdks/server/sessions) | Create session tokens | ## Request Options All methods accept an optional `RequestOptions` parameter: ```typescript const account = await dialstack.accounts.retrieve('acct_123', { timeout: 30000, // Override timeout maxNetworkRetries: 5, // Override retries idempotencyKey: 'abc123', // For safe retries }); ``` ### RequestOptions | Option | Type | Description | | ------------------- | -------- | ---------------------------------------- | | `timeout` | `number` | Override request timeout | | `maxNetworkRetries` | `number` | Override retry count | | `idempotencyKey` | `string` | Idempotency key for safe retries | | `dialstackAccount` | `string` | Account context (for account-scoped ops) | ## Auto-Pagination List methods return paginated results with auto-pagination helpers: ### Iterate with autoPagingEach ```typescript for await (const account of dialstack.accounts.list().autoPagingEach()) { console.log(account.id); } ``` ### Collect with autoPagingToArray ```typescript const accounts = await dialstack.accounts.list().autoPagingToArray({ limit: 100 }); console.log(`Found ${accounts.length} accounts`); ``` ### Manual Pagination ```typescript // First page const page1 = await dialstack.accounts.list({ limit: 10 }); console.log(page1.data); console.log('Has more:', page1.has_more); // Next page if (page1.has_more) { const lastId = page1.data[page1.data.length - 1].id; const page2 = await dialstack.accounts.list({ limit: 10, starting_after: lastId, }); } ``` ## Event Listeners Monitor SDK activity: ```typescript dialstack.on('request', (event) => { console.log(`${event.method} ${event.path}`); }); dialstack.on('response', (event) => { console.log(`${event.statusCode} in ${event.elapsed}ms`); }); ``` ### Event Types **RequestEvent:** ```typescript interface RequestEvent { method: string; path: string; dialstackAccount?: string; idempotencyKey?: string; requestStartTime: number; } ``` **ResponseEvent:** ```typescript interface ResponseEvent { method: string; path: string; statusCode: number; requestId?: string; dialstackAccount?: string; elapsed: number; } ``` ## Error Handling The SDK throws typed errors: ```typescript import { DialStack, DialStackError, DialStackAuthenticationError, DialStackNotFoundError, DialStackRateLimitError, } from '@dialstack/sdk-server'; try { await dialstack.accounts.retrieve('invalid_id'); } catch (error) { if (error instanceof DialStackNotFoundError) { console.log('Account not found'); } else if (error instanceof DialStackAuthenticationError) { console.log('Invalid API key'); } else if (error instanceof DialStackRateLimitError) { console.log(`Rate limited. Retry after ${error.retryAfter}s`); } else if (error instanceof DialStackError) { console.log('API error:', error.message); } } ``` ### Error Classes | Error | HTTP Status | Description | | ------------------------------ | ----------- | ------------------- | | `DialStackAuthenticationError` | 401 | Invalid API key | | `DialStackPermissionError` | 403 | Access denied | | `DialStackNotFoundError` | 404 | Resource not found | | `DialStackConflictError` | 409 | Resource conflict | | `DialStackValidationError` | 400 | Validation failed | | `DialStackInvalidRequestError` | 400 | Invalid request | | `DialStackRateLimitError` | 429 | Rate limit exceeded | | `DialStackAPIError` | 5xx | Server error | | `DialStackConnectionError` | - | Network error | ## TypeScript Full TypeScript support with exported types: ```typescript import { DialStack } from '@dialstack/sdk-server'; import type { Account, AccountCreateParams, User, UserCreateParams, AccountSessionCreateParams, AccountSessionCreateResponse, DialStackConfig, RequestOptions, } from '@dialstack/sdk-server'; ``` ## Next Steps - [Accounts](/sdks/server/accounts) - Manage accounts - [Users](/sdks/server/users) - Manage users - [Sessions](/sdks/server/sessions) - Create session tokens - [Authentication](/sdks/authentication) - Session-based auth flow --- ## Phone Numbers(Server) Phone numbers (DIDs) are assigned to accounts for inbound and outbound calling. ## List Phone Numbers ```typescript const acct = { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }; const response = await dialstack.phoneNumbers.list({ limit: 10, status: 'active' }, acct); for (const phoneNumber of response.data) { console.log(phoneNumber.phone_number); // '+14155551234' } ``` ### Parameters | Parameter | Type | Default | Description | | ------------------ | -------- | -------- | ------------------------------------------------- | | `limit` | `number` | `10` | Results per page (max 100) | | `status` | `string` | - | Filter by status: `active`, `inactive`, `pending` | | `dialstackAccount` | `string` | Required | Account ID | ### Response ```typescript interface PhoneNumber { id: string; phone_number: string; // E.164 format (e.g., '+14155551234') status: 'active' | 'inactive' | 'pending'; created_at: string; } ``` ## Auto-Pagination ### Iterate All Phone Numbers ```typescript const acct = { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }; for await (const pn of dialstack.phoneNumbers.list(undefined, acct).autoPagingEach()) { console.log(pn.phone_number, pn.status); } ``` ### Collect to Array ```typescript const acct = { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }; const allNumbers = await dialstack.phoneNumbers .list({ status: 'active' }, acct) .autoPagingToArray({ limit: 100 }); console.log(`Active phone numbers: ${allNumbers.length}`); ``` ## Examples ### Get Primary Phone Number ```typescript async function getPrimaryPhoneNumber(accountId: string): Promise { const acct = { dialstackAccount: accountId }; const response = await dialstack.phoneNumbers.list({ limit: 1, status: 'active' }, acct); return response.data[0]?.phone_number ?? null; } ``` ### Display Phone Numbers in UI ```typescript async function getPhoneNumbersForDisplay(accountId: string) { const acct = { dialstackAccount: accountId }; const phoneNumbers = await dialstack.phoneNumbers .list({ status: 'active' }, acct) .autoPagingToArray(); return phoneNumbers.map((pn) => ({ id: pn.id, display: formatE164(pn.phone_number), // '+1 (415) 555-1234' raw: pn.phone_number, })); } function formatE164(e164: string): string { const match = e164.match(/^\+1(\d{3})(\d{3})(\d{4})$/); if (match) { return `+1 (${match[1]}) ${match[2]}-${match[3]}`; } return e164; } ``` ## Next Steps - [Users](/sdks/server/users) - Manage users - [Sessions](/sdks/server/sessions) - Create session tokens --- ## Account Sessions Account sessions provide secure, scoped tokens for client-side embedded components. Sessions are tied to a specific account and can be restricted to specific components. ## Create a Session ```typescript const session = await dialstack.accountSessions.create({ account: 'acct_01h2xcejqtf2nbrexx3vqjhp41', components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); console.log(session.client_secret); // 'cs_live_...' console.log(session.expires_at); // '2025-01-15T13:00:00Z' ``` ### Parameters | Parameter | Type | Required | Description | | ------------ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | `account` | `string` | Yes | Account ID to scope the session to | | `components` | `object` | Yes | Component permissions. At least one component must be enabled — a missing, empty, or all-disabled set is rejected. | ### Response ```typescript interface AccountSessionCreateResponse { client_secret: string; expires_at: string; } ``` ## Component Permissions Control which embedded components the session can access: ```typescript const session = await dialstack.accountSessions.create({ account: 'acct_01h2xcejqtf2nbrexx3vqjhp41', components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); ``` ### Available Components | Component | Description | | ----------------------- | ----------------------------------------------------------------------- | | `call_logs` | Access to call history | | `voicemails` | Access to voicemails | | `call_history` | Access to the call-history component | | `phone_numbers` | Manage the account's phone numbers | | `phone_number_ordering` | Search for and order new phone numbers | | `dial_plan` | View and edit call routing | | `ring_groups` | Manage ring groups | | `quality` | Call-quality summary and worst calls | | `ai_agent` | Configure the AI agent | | `account_onboarding` | The onboarding portal, including **reading** the subscription agreement | | `agreement_acceptance` | **Accepting** the subscription agreement — see the warning below | :::warning `agreement_acceptance` is an attestation This component lets the session bind the account to the DialStack Service Subscription Agreement — a legal commitment by your customer. It is separate from `account_onboarding` on purpose: a session with onboarding alone can read the agreement and render the accept screen, so someone who may not sign still learns that service is blocked, but the submission is rejected. DialStack does not decide who may sign, because the person is your user, not ours. **You do**, by enabling this component. By minting a session with it, you assert that you presented the agreement to someone entitled to accept on that account's behalf. Enable it for that person and leave it off for everyone else. DialStack's own admin portal, for reference, grants it only to the account's owner. If you render the [onboarding portal](../react/onboarding-portal.md), pass `canAcceptAgreement` with the same condition so a non-signer sees an explanation instead of a button that fails. ::: ### Restricting Access ```typescript // Only allow voicemails const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: false }, voicemails: { enabled: true }, }, }); ``` If a disabled component is used, it will show an error state. ## Session Lifecycle ### Expiration Sessions expire after **1 hour** by default. The `expires_at` field indicates when the session becomes invalid. ### Client-Side Handling Pass the full session response to enable automatic refresh: ```typescript // Server app.post('/api/dialstack/session', async (req, res) => { const session = await dialstack.accountSessions.create({ account: req.body.accountId, components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); // Return both fields res.json({ client_secret: session.client_secret, expires_at: session.expires_at, }); }); ``` ```tsx // Client (React) ``` ### Manual Refresh If the session expires and you only passed the `client_secret` string: ```typescript // Client-side async function refreshSession() { const response = await fetch('/api/dialstack/session', { method: 'POST', body: JSON.stringify({ accountId }), }); const session = await response.json(); setClientSecret(session.client_secret); } ``` ## Security ### API Key Required Sessions can only be created with an API key (`sk_live_*`). This prevents clients from creating their own sessions. ```typescript // This works (server-side with API key) const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: true } }, }); // Client-side tokens cannot create sessions // (no accountSessions.create method available) ``` ### Account Isolation Each session is scoped to a single account. The `client_secret` can only access data belonging to that account. ### Token Security - Tokens cannot be used to create new sessions - Tokens are short-lived (1 hour) - Tokens have limited scope (only enabled components) ## Examples ### Express.js Session Endpoint ```typescript import express from 'express'; import { DialStack } from '@dialstack/sdk-server'; const app = express(); const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); app.use(express.json()); app.post('/api/dialstack/session', async (req, res) => { try { const { accountId } = req.body; // Verify user has access to this account const userId = req.user?.id; // From auth middleware const hasAccess = await verifyAccountAccess(userId, accountId); if (!hasAccess) { return res.status(403).json({ error: 'Access denied' }); } // Create session const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); res.json(session); } catch (error) { console.error('Session creation failed:', error); res.status(500).json({ error: 'Failed to create session' }); } }); ``` ### Next.js API Route ```typescript // pages/api/dialstack/session.ts import type { NextApiRequest, NextApiResponse } from 'next'; import { DialStack } from '@dialstack/sdk-server'; import { getServerSession } from 'next-auth'; const dialstack = new DialStack(process.env.DIALSTACK_API_KEY!); export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } // Check authentication const authSession = await getServerSession(req, res); if (!authSession) { return res.status(401).json({ error: 'Unauthorized' }); } const { accountId } = req.body; // Verify user owns this account const userAccount = await db.accounts.findFirst({ where: { dialstackAccountId: accountId, userId: authSession.user.id, }, }); if (!userAccount) { return res.status(403).json({ error: 'Access denied' }); } // Create session const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: true }, voicemails: { enabled: true }, }, }); res.json(session); } ``` ### Component-Specific Sessions ```typescript // Only voicemails for this user async function getVoicemailSession(accountId: string) { return dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: false }, voicemails: { enabled: true }, }, }); } // Only call logs for analytics dashboard async function getAnalyticsSession(accountId: string) { return dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: true }, voicemails: { enabled: false }, }, }); } ``` ## Deprecated: sessions The `sessions` property is deprecated. Use `accountSessions` instead: ```typescript const params = { account: accountId, components: { call_logs: { enabled: true } }, }; // Deprecated const session = await dialstack.sessions.create(params); // Use this instead const session = await dialstack.accountSessions.create(params); ``` ## Next Steps - [Authentication](/sdks/authentication) - Full authentication flow - [React Components](/sdks/react) - Use sessions in React - [Web Components](/sdks/web-components) - Use sessions in vanilla JS --- ## Users Users belong to accounts and represent individuals who can make/receive calls and access voicemails. ## Create a User ```typescript const user = await dialstack.users.create( { name: 'John Doe', email: 'john@example.com' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); console.log(user.id); // 'user_01h2xcejqtf2nbrexx3vqjhp42' ``` ### Parameters | Parameter | Type | Required | Description | | ------------------ | -------- | -------- | -------------------- | | `name` | `string` | No | User's display name | | `email` | `string` | No | User's email address | | `dialstackAccount` | `string` | Yes | Parent account ID | ### Response ```typescript interface User { id: string; name: string | null; email: string | null; created_at: string; updated_at: string; } ``` ## Retrieve a User ```typescript const user = await dialstack.users.retrieve('user_01h2xcejqtf2nbrexx3vqjhp42', { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41', }); console.log(user.name); ``` ### Parameters | Parameter | Type | Required | Description | | ------------------ | -------- | -------- | ----------- | | `userId` | `string` | Yes | User ID | | `dialstackAccount` | `string` | Yes | Account ID | ## Update a User ```typescript const user = await dialstack.users.update( 'user_01h2xcejqtf2nbrexx3vqjhp42', { name: 'Jane Doe', email: 'jane@example.com' }, { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' } ); ``` ### Parameters | Parameter | Type | Required | Description | | ------------------ | -------- | -------- | ----------------- | | `userId` | `string` | Yes | User ID | | `name` | `string` | No | New display name | | `email` | `string` | No | New email address | | `dialstackAccount` | `string` | Yes | Account ID | ## Delete a User ```typescript await dialstack.users.del('user_01h2xcejqtf2nbrexx3vqjhp42', { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41', }); ``` ### Parameters | Parameter | Type | Required | Description | | ------------------ | -------- | -------- | ----------- | | `userId` | `string` | Yes | User ID | | `dialstackAccount` | `string` | Yes | Account ID | ## List Users ```typescript const acct = { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }; const response = await dialstack.users.list({ limit: 20 }, acct); for (const user of response.data) { console.log(user.id, user.name); } ``` ### Parameters | Parameter | Type | Default | Description | | ------------------ | -------- | -------- | -------------------------- | | `limit` | `number` | `10` | Results per page (max 100) | | `starting_after` | `string` | - | Cursor for next page | | `ending_before` | `string` | - | Cursor for previous page | | `dialstackAccount` | `string` | Required | Account ID | ### Response ```typescript interface ListResponse { data: User[]; has_more: boolean; } ``` ## Auto-Pagination ### Iterate All Users ```typescript const acct = { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }; for await (const user of dialstack.users.list(undefined, acct).autoPagingEach()) { console.log(user.id, user.name); } ``` ### Collect to Array ```typescript const acct = { dialstackAccount: 'acct_01h2xcejqtf2nbrexx3vqjhp41' }; const allUsers = await dialstack.users.list(undefined, acct).autoPagingToArray({ limit: 500 }); console.log(`Total users: ${allUsers.length}`); ``` ## Examples ### Create User for New Customer ```typescript async function onboardCustomer(customerData: CustomerData) { // Create account const account = await dialstack.accounts.create({ email: customerData.companyEmail, }); const acct = { dialstackAccount: account.id }; // Create primary user const user = await dialstack.users.create( { name: customerData.primaryContactName, email: customerData.primaryContactEmail }, acct ); return { account, user }; } ``` ### Sync Users from External System ```typescript async function syncUsers(accountId: string, externalUsers: ExternalUser[]) { const acct = { dialstackAccount: accountId }; // Get existing users const existingUsers = await dialstack.users.list(undefined, acct).autoPagingToArray(); const existingByEmail = new Map(existingUsers.map((u) => [u.email, u])); for (const ext of externalUsers) { const existing = existingByEmail.get(ext.email); if (existing) { // Update if changed if (existing.name !== ext.name) { await dialstack.users.update(existing.id, { name: ext.name }, acct); } } else { // Create new await dialstack.users.create({ name: ext.name, email: ext.email }, acct); } } } ``` ### Get User's Voicemails Session ```typescript async function getUserVoicemailsSession(accountId: string, userId: string) { const acct = { dialstackAccount: accountId }; // Verify user exists const user = await dialstack.users.retrieve(userId, acct); // Create session scoped to voicemails only const session = await dialstack.accountSessions.create({ account: accountId, components: { call_logs: { enabled: false }, voicemails: { enabled: true }, }, }); return { userId: user.id, clientSecret: session.client_secret, expiresAt: session.expires_at, }; } ``` ### Delete All Users in Account ```typescript async function deleteAllUsers(accountId: string) { const acct = { dialstackAccount: accountId }; const users = await dialstack.users.list(undefined, acct).autoPagingToArray(); for (const user of users) { await dialstack.users.del(user.id, acct); } console.log(`Deleted ${users.length} users`); } ``` ## Next Steps - [Sessions](/sdks/server/sessions) - Create session tokens - [Accounts](/sdks/server/accounts) - Manage accounts --- ## Theming Customize the appearance of DialStack components using CSS variables, theme presets, and layout variants. ## Quick Start ```typescript const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'light', variables: { colorPrimary: '#6772E5', fontFamily: 'Inter, system-ui, sans-serif', borderRadius: '8px', }, }, }); ``` ## Theme Variants Choose a base theme: | Theme | Description | | --------- | -------------------------------------------------- | | `'light'` | Light background with dark text | | `'dark'` | Dark background with light text | | `'auto'` | Follows system preference (`prefers-color-scheme`) | ```typescript // Follow system preference initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'auto', }, }); ``` ## Layout Variants Adjust component density with `layoutVariant`: | Variant | Description | | --------------- | ----------------------------- | | `'compact'` | Minimal padding, dense layout | | `'default'` | Standard spacing | | `'comfortable'` | Extra padding, breathing room | ```tsx ``` ## CSS Variables Customize components using CSS variables in the `variables` option: ### Colors ```typescript variables: { // Primary brand color colorPrimary: '#6772E5', colorPrimaryHover: '#5469D4', // Base colors colorBackground: '#ffffff', colorText: '#1a1a1a', colorTextSecondary: '#6b7280', // Semantic colors colorDanger: '#e5484d', colorSuccess: '#30a46c', colorWarning: '#f5a623', // Surface colors colorSurfaceSubtle: '#f9fafb', colorBorder: '#e5e7eb', colorBorderSubtle: '#f3f4f6', } ``` ### Typography ```typescript variables: { fontFamily: 'Inter, system-ui, sans-serif', fontSizeBase: '14px', fontSizeSmall: '12px', fontSizeLarge: '16px', fontSizeXLarge: '18px', fontWeightNormal: '400', fontWeightMedium: '500', fontWeightBold: '600', lineHeight: '1.5', } ``` ### Spacing ```typescript variables: { spacingUnit: '8px', spacingXs: '4px', spacingSm: '8px', spacingMd: '16px', spacingLg: '24px', spacingXl: '32px', } ``` ### Border & Effects ```typescript variables: { borderRadius: '6px', borderRadiusSmall: '4px', borderRadiusLarge: '12px', transitionDuration: '150ms', focusRingColor: 'rgba(59, 130, 246, 0.5)', focusRingWidth: '2px', } ``` ### Component-Specific ```typescript variables: { // Icons iconSize: '20px', iconSizeSmall: '16px', // Audio player playerButtonSize: '40px', playerProgressHeight: '4px', playerProgressHandleSize: '12px', // Indicators unreadIndicatorSize: '8px', spinnerSize: '24px', // Time display timeDisplayWidth: '48px', } ``` ## Complete Variables Reference | Variable | Default (Light) | Description | | -------------------- | ------------------------- | ---------------------- | | **Colors** | | | | `colorPrimary` | `#2563eb` | Primary brand color | | `colorPrimaryHover` | `#1d4ed8` | Primary color on hover | | `colorBackground` | `#ffffff` | Background color | | `colorText` | `#111827` | Primary text color | | `colorTextSecondary` | `#6b7280` | Secondary text color | | `colorDanger` | `#dc2626` | Error/danger color | | `colorSuccess` | `#16a34a` | Success color | | `colorWarning` | `#d97706` | Warning color | | `colorSurfaceSubtle` | `#f9fafb` | Subtle surface color | | `colorBorder` | `#e5e7eb` | Border color | | `colorBorderSubtle` | `#f3f4f6` | Subtle border color | | **Typography** | | | | `fontFamily` | `system-ui, sans-serif` | Font family | | `fontSizeBase` | `14px` | Base font size | | `fontSizeSmall` | `12px` | Small text | | `fontSizeLarge` | `16px` | Large text | | `fontSizeXLarge` | `18px` | Extra large text | | `fontWeightNormal` | `400` | Normal weight | | `fontWeightMedium` | `500` | Medium weight | | `fontWeightBold` | `600` | Bold weight | | `lineHeight` | `1.5` | Line height | | **Spacing** | | | | `spacingUnit` | `8px` | Base spacing unit | | `spacingXs` | `4px` | Extra small | | `spacingSm` | `8px` | Small | | `spacingMd` | `16px` | Medium | | `spacingLg` | `24px` | Large | | `spacingXl` | `32px` | Extra large | | **Border** | | | | `borderRadius` | `6px` | Default radius | | `borderRadiusSmall` | `4px` | Small radius | | `borderRadiusLarge` | `12px` | Large radius | | **Effects** | | | | `transitionDuration` | `150ms` | Animation duration | | `focusRingColor` | `rgba(59, 130, 246, 0.5)` | Focus outline color | | `focusRingWidth` | `2px` | Focus outline width | ## CSS Class Overrides Apply custom CSS classes using the `classes` prop: ### CallLogs Classes ```tsx ``` ### Voicemails Classes ```tsx ``` ## Updating Theme at Runtime Use the `update()` method to change appearance after initialization: ```typescript const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'light' }, }); // Later: switch to dark mode dialstack.update({ appearance: { theme: 'dark', variables: { colorPrimary: '#8B5CF6', }, }, }); ``` ## Custom Icons Replace default icons with custom SVG: ```typescript ...', outbound: '...', play: '...', pause: '...', phone: '...', trash: '...', chevronLeft: '...', chevronRight: '...', chevronDown: '...', spinner: '...', }} /> ``` ## Framework Integration ### Tailwind CSS ```tsx ``` ### Bootstrap ```tsx ``` ## Complete Example ```tsx import { initialize } from '@dialstack/sdk-js'; import { DialstackComponentsProvider } from '@dialstack/sdk-react'; import { CallLogs } from '@dialstack/sdk-react/call-logs'; import { Voicemails } from '@dialstack/sdk-react/voicemails'; const dialstack = initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'auto', variables: { // Brand colors colorPrimary: '#6366F1', colorPrimaryHover: '#4F46E5', // Typography fontFamily: '"Inter", -apple-system, sans-serif', fontSizeBase: '14px', // Spacing spacingUnit: '8px', borderRadius: '8px', // Focus states focusRingColor: 'rgba(99, 102, 241, 0.5)', focusRingWidth: '3px', }, }, }); function App() { return (

Voice Dashboard

Recent Calls

Voicemails

); } ``` ## Next Steps - [i18n](/sdks/i18n) - Internationalization - [CallLogs](/sdks/react/call-logs) - CallLogs component reference - [Voicemails](/sdks/react/voicemails) - Voicemails component reference --- ## dialstack-call-history The call history Web Component displays a compact call history list for a specific phone number with direction indicators and AI-generated summaries. ## Usage ```html ``` :::warning[Required: Phone Number] You must call `setPhoneNumber()` before the component will load data. The phone number must be in E.164 format (e.g., `+14155551234`). ::: ## Methods ### Common Methods | Method | Parameters | Description | | ------------------ | -------------------------------------------------- | ------------------------- | | `setInstance` | `instance: DialStackInstance` | Set SDK instance | | `setLocale` | `locale: Locale` | Set UI strings | | `setFormatting` | `options: FormattingOptions` | Set date/phone formatting | | `setIcons` | `icons: ComponentIcons` | Set custom SVG icons | | `setLayoutVariant` | `variant: 'compact' \| 'comfortable' \| 'default'` | Set layout density | | `setClasses` | `classes: CallHistoryClasses` | Set CSS classes | | `setOnLoaderStart` | `callback: (event) => void` | Set loading callback | | `setOnLoadError` | `callback: (event) => void` | Set error callback | ### CallHistory-Specific Methods | Method | Parameters | Description | | ------------------- | ------------------------------------ | --------------------------------------------- | | `setPhoneNumber` | `phoneNumber: string` | **Required.** Set phone number (E.164 format) | | `setLimit` | `limit: number` | Max calls to display (1-20, default 5) | | `setDisplayOptions` | `options: CallHistoryDisplayOptions` | Show/hide UI elements | ## Configuration Examples ### Phone Number and Limit ```javascript const callHistory = document.querySelector('dialstack-call-history'); callHistory.setPhoneNumber('+14155551234'); callHistory.setLimit(10); // Show up to 10 calls (default is 5) ``` ### Display Options ```javascript callHistory.setDisplayOptions({ showDuration: true, // Show call duration showRelativeTime: true, // Show "2 min ago", "Yesterday", etc. showDirectionIcon: true, // Show inbound/outbound/missed/voicemail icon }); ``` ### Formatting ```javascript callHistory.setFormatting({ dateLocale: 'en-US', // BCP 47 language tag use24HourTime: false, // 12-hour vs 24-hour }); ``` ### Layout and Styling ```javascript callHistory.setLayoutVariant('comfortable'); callHistory.setClasses({ base: 'my-call-history', list: 'my-list', item: 'my-item', itemInbound: 'my-item--inbound', itemOutbound: 'my-item--outbound', itemMissed: 'my-item--missed', itemVoicemail: 'my-item--voicemail', icon: 'my-icon', time: 'my-time', duration: 'my-duration', }); ``` ## Call Types and Icons The component displays different icons based on call type: | Type | Icon Color | Description | | -------------------- | ---------- | ------------------------------------ | | Inbound (completed) | Green | Successfully answered incoming call | | Outbound (completed) | Blue | Successfully connected outgoing call | | Missed | Red | Unanswered incoming call | | Voicemail | Purple | Caller left a voicemail | ## AI Summaries For completed calls and voicemails, the component displays an AI-generated summary of the conversation. The summary appears below the call information with a distinctive "AI Summary" badge. ```javascript // AI summaries are shown automatically for: // - Completed inbound calls // - Completed outbound calls // - Voicemails ``` The summary provides a concise overview of the call content, making it easy to quickly understand what was discussed without listening to recordings. ## Event Handlers ### onLoadError ```javascript callHistory.setOnLoadError((event) => { console.error('Error:', event.error); console.error('Element:', event.elementTagName); }); ``` ### onLoaderStart ```javascript callHistory.setOnLoaderStart((event) => { console.log('Loading call history...'); }); ``` ## Complete Example ```html

Recent Calls

``` ## Use Cases ### Customer Context Panel Display recent interactions when a customer calls in: ```html

Customer History

``` ### Patient Records Show call history in a healthcare patient record: ```html

Recent Calls

``` ## Next Steps - [call-logs Element](/sdks/web-components/call-logs) - Full call logs table with pagination - [voicemails Element](/sdks/web-components/voicemails) - Voicemails reference - [Theming](/sdks/theming) - Customize appearance - [i18n](/sdks/i18n) - Internationalization --- ## dialstack-call-logs The call logs Web Component displays a paginated table of call history. ## Usage ```html ``` ## Methods ### Common Methods | Method | Parameters | Description | | ------------------ | -------------------------------------------------- | ------------------------- | | `setInstance` | `instance: DialStackInstance` | Set SDK instance | | `setLocale` | `locale: Locale` | Set UI strings | | `setFormatting` | `options: FormattingOptions` | Set date/phone formatting | | `setIcons` | `icons: ComponentIcons` | Set custom SVG icons | | `setLayoutVariant` | `variant: 'compact' \| 'comfortable' \| 'default'` | Set layout density | | `setClasses` | `classes: CallLogsClasses` | Set CSS classes | | `setOnLoaderStart` | `callback: (event) => void` | Set loading callback | | `setOnLoadError` | `callback: (event) => void` | Set error callback | ### CallLogs-Specific Methods | Method | Parameters | Description | | ---------------------- | ---------------------------------- | -------------------- | | `setDateRange` | `{ start?: string; end?: string }` | Filter by date range | | `setDisplayOptions` | `options: CallLogDisplayOptions` | Show/hide columns | | `setCustomRowRenderer` | `renderer: (call) => string` | Custom row HTML | | `setOnRowClick` | `callback: (event) => void` | Row click callback | :::info[Automatic Pagination] Pagination is handled automatically by the component using cursor-based pagination. You don't need to manage cursors, offsets, or page state manually. The component automatically loads more data as needed. See the [Pagination Guide](/guides/pagination) for details. ::: ## Configuration Examples ### Date Range Filtering ```javascript const callLogs = document.querySelector('dialstack-call-logs'); callLogs.setDateRange({ start: '2025-01-01', end: '2025-01-31', }); ``` ### Display Options ```javascript callLogs.setDisplayOptions({ showDate: true, showDirection: true, showFrom: true, showTo: true, showDuration: true, showStatus: true, }); ``` ### Formatting ```javascript callLogs.setFormatting({ defaultCountry: 'US', dateLocale: 'en-US', use24HourTime: false, showTimezone: true, }); ``` ### Layout and Styling ```javascript callLogs.setLayoutVariant('comfortable'); callLogs.setClasses({ base: 'my-call-logs', table: 'my-table', row: 'my-row', rowInbound: 'my-row--inbound', rowOutbound: 'my-row--outbound', pagination: 'my-pagination', }); ``` ## Event Handlers ### onRowClick ```javascript callLogs.setOnRowClick((event) => { console.log('Call ID:', event.callId); console.log('Call data:', event.call); // Navigate to detail view showCallDetail(event.call); }); ``` ### onLoadError ```javascript callLogs.setOnLoadError((event) => { console.error('Error:', event.error); console.error('Element:', event.elementTagName); }); ``` ## Custom Row Renderer Provide a function that returns HTML for each row: ```javascript callLogs.setCustomRowRenderer((call) => { const icon = call.direction === 'inbound' ? '📥' : '📤'; const status = call.status === 'completed' ? '✓' : '✗'; return `
${icon} ${call.from_number} → ${call.to_number} ${call.duration_seconds}s ${status}
`; }); ``` ## Complete Example ```html

Call History

``` ## Next Steps - [voicemails Element](/sdks/web-components/voicemails) - Voicemails reference - [Theming](/sdks/theming) - Customize appearance - [i18n](/sdks/i18n) - Internationalization --- ## Web Components Use DialStack components in any JavaScript application without React. `@dialstack/sdk-js` provides native Web Components (Custom Elements) that work in vanilla JavaScript, Vue, Angular, Svelte, or any framework. ## Quick Start ### Using CDN ```html ``` ### Using ES Modules For bundled applications without React, import `@dialstack/sdk-js` — it registers the custom elements for you, and nothing else is needed. The `/pure` subpath is the same SDK with that registration left out, for cases where importing a module must not touch the DOM — server-side rendering, or a test that asserts on registration itself. There you call `registerComponents()` when you want the elements defined: ```typescript import { loadDialstackAndInitialize, registerComponents } from '@dialstack/sdk-js/pure'; async function init() { const dialstack = await loadDialstackAndInitialize({ publishableKey: 'pk_live_YOUR_KEY', fetchClientSecret: async () => { const res = await fetch('/api/dialstack/session', { method: 'POST' }); const { client_secret } = await res.json(); return client_secret; }, appearance: { theme: 'auto', }, }); // Register the custom elements before creating any of them await registerComponents(); // Create components programmatically — create() wires the instance for you const callLogs = dialstack.create('call-logs'); document.getElementById('call-logs-container').appendChild(callLogs); } init(); ``` :::caution[Register the elements before you use one] This applies to the `/pure` subpath only — importing `@dialstack/sdk-js` registers the elements on your behalf. `registerComponents()` is asynchronous, so await it before calling `create()` or touching an element in your markup. Until an element is registered it is an unknown element: it ignores every property you set on it and renders nothing, and calling a method like `setInstance()` on it throws. ::: ## Initialization ### UMD (Browser Global) When using the CDN, DialStack is available as a global: ```javascript const dialstack = DialStack.initialize({ publishableKey: 'pk_live_YOUR_KEY', appearance: { theme: 'light', variables: { colorPrimary: '#6772E5', }, }, }); ``` ### ES Module (Pure) For bundled apps, use the pure import: ```typescript import { loadDialstackAndInitialize } from '@dialstack/sdk-js/pure'; const dialstack = await loadDialstackAndInitialize({ publishableKey: 'pk_live_YOUR_KEY', }); ``` ## DialStackInstance API The instance returned from `initialize()` or `loadDialstackAndInitialize()` provides these methods: ### create(tagName) Create a component element. If you imported from `/pure`, `await registerComponents()` first: ```javascript const callLogs = dialstack.create('call-logs'); const voicemails = dialstack.create('voicemails'); // Append to DOM document.getElementById('container').appendChild(callLogs); ``` ### update(options) Update appearance for all components: ```javascript dialstack.update({ appearance: { theme: 'dark', variables: { colorPrimary: '#8B5CF6', }, }, }); ``` ### logout() Clear session and destroy all components: ```javascript dialstack.logout(); ``` ## Component Element Methods All component elements share these common methods: | Method | Description | | ---------------------------- | -------------------------- | | `setInstance(instance)` | Set the DialStack instance | | `setLocale(locale)` | Set UI locale | | `setFormatting(options)` | Set date/phone formatting | | `setIcons(icons)` | Set custom icons | | `setLayoutVariant(variant)` | Set layout density | | `setClasses(classes)` | Set CSS classes | | `setOnLoaderStart(callback)` | Set loading callback | | `setOnLoadError(callback)` | Set error callback | ## Available Components | Tag Name | Description | | --------------------------- | ---------------------------------------------------------------------------- | | `` | [Compact call history for a phone number](/sdks/web-components/call-history) | | `` | [Call history table](/sdks/web-components/call-logs) | | `` | [Voicemail list with playback](/sdks/web-components/voicemails) | | `` | [Unified phone number list with search](/sdks/web-components/phone-numbers) | ## Event Handling Set event handlers using setter methods: ```javascript const callLogs = document.querySelector('dialstack-call-logs'); callLogs.setOnLoadError((event) => { console.error('Load failed:', event.error); }); callLogs.setOnRowClick((event) => { console.log('Clicked call:', event.callId); }); ``` ## Framework Integration ### Vue.js ```vue ``` ### Angular ```typescript import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; // The root entry registers the custom elements on import import { loadDialstackAndInitialize } from '@dialstack/sdk-js'; @Component({ selector: 'app-voice-dashboard', template: ` `, }) export class VoiceDashboardComponent implements OnInit { @ViewChild('callLogs') callLogsRef!: ElementRef; async ngOnInit() { const dialstack = await loadDialstackAndInitialize({ publishableKey: 'pk_live_YOUR_KEY', fetchClientSecret: async () => { const res = await fetch('/api/dialstack/session', { method: 'POST' }); const { client_secret } = await res.json(); return client_secret; }, }); this.callLogsRef.nativeElement.setInstance(dialstack); } } ``` ### Svelte ```svelte ``` ## Next Steps - [call-history Element](/sdks/web-components/call-history) - Compact call history reference - [call-logs Element](/sdks/web-components/call-logs) - Call logs reference - [voicemails Element](/sdks/web-components/voicemails) - Voicemails reference - [phone-numbers Element](/sdks/web-components/phone-numbers) - Phone numbers reference - [Theming](/sdks/theming) - Customize appearance --- ## dialstack-phone-numbers The phone-numbers Web Component displays a unified, filterable list of all phone numbers for the authenticated account. It merges the account's active numbers together with any in-flight number orders and ports into a single table with status tabs, sortable columns, a search box, and pagination. ## Usage ```html ``` :::info[Account-scoped] This element loads every phone number on the account — there's no per-user setup. It fetches its data as soon as it's wired to an initialized instance via `setInstance()` (or created with `dialstack.create('phone-numbers')`, which wires it for you). ::: ## Status tabs Numbers are grouped into three tabs, each showing the columns relevant to that state (every tab shows the phone number itself): | Tab | Additional columns | | --------------- | ------------------------------------------------------------------------------------------------- | | **Active** | Outbound Caller ID, Inbound Caller ID Prefix, Usage (two-way, inbound-only, or fax), Call Routing | | **In Progress** | Status, Call Routing, Carrier, Transfer Date | | **Cancelled** | Date Cancelled | Numbers with no **Inbound Caller ID Prefix** set read _Not set_, the same empty state the caller ID column uses. A count badge on the **In Progress** tab highlights any numbers that need attention (for example, an order or transfer that hit an issue). Call routing can be set from the **In Progress** tab as well — before the number activates — so it starts routing the moment it goes live. ## Search A search box sits on its own row below the status tabs. Typing filters the list live and matches against: - the **phone number** — both the plain digits and the formatted form, so `4165551234` and `(416) 555` both match; - the **outbound caller ID name** and the **inbound caller ID prefix**; - the **losing carrier** — the carrier a number is being ported away from, which only in-progress ports have; and - the **call-routing target** name. Search applies **within the currently selected tab**, so switch tabs if a number you expect isn't showing. Filtering runs entirely in the browser over the already-loaded list — there is no extra network request per keystroke. ## Sorting and pagination Every column header is sortable; click it to toggle ascending/descending. The list is paginated client-side — use the **Previous** / **Next** controls beneath the table. Set the page size with `setLimit()` (default: 10). ## Methods ### Common Methods | Method | Parameters | Description | | ------------------ | -------------------------------------------------- | ------------------------- | | `setInstance` | `instance: DialStackInstance` | Set SDK instance | | `setLocale` | `locale: Locale` | Set UI strings | | `setFormatting` | `options: FormattingOptions` | Set date/phone formatting | | `setIcons` | `icons: ComponentIcons` | Set custom SVG icons | | `setLayoutVariant` | `variant: 'compact' \| 'comfortable' \| 'default'` | Set layout density | | `setClasses` | `classes: PhoneNumbersClasses` | Set CSS classes | | `setOnLoaderStart` | `callback: (event) => void` | Set loading callback | | `setOnLoadError` | `callback: (event) => void` | Set error callback | ### Phone-Numbers-Specific Methods | Method | Parameters | Description | | --------------- | --------------------------- | --------------------------------- | | `setLimit` | `limit: number` | Rows per page (default: 10) | | `setOnRowClick` | `callback: (event) => void` | Row / routing-cell click callback | ## Row clicks Wire `setOnRowClick` to navigate when a row is selected. The event carries the selected number, the merged item, and a `section` that distinguishes the two clickable areas: - `section: 'detail'` — the row body was clicked (open the number's, order's, or transfer's detail). - `section: 'routing'` — the call-routing cell was clicked (jump straight to that number's routing). ```javascript const phoneNumbers = document.querySelector('dialstack-phone-numbers'); phoneNumbers.setOnRowClick((event) => { if (event.section === 'routing' && event.item.did_id) { window.location.href = `/phone-numbers/${event.item.did_id}`; } else { window.location.href = `/phone-numbers/${event.phoneNumber}`; } }); ``` :::note[Fax numbers] A fax-enabled number receives inbound calls as faxes and has no call routing, so its **Call Routing** cell shows "Not applicable" and is not clickable. ::: ## Styling ```javascript phoneNumbers.setLayoutVariant('comfortable'); phoneNumbers.setClasses({ base: 'my-phone-numbers', table: 'my-table', row: 'my-row', statusBadge: 'my-badge', pagination: 'my-pagination', }); ``` See [Theming](/sdks/theming) for the full appearance system. ## Complete Example ```html ``` ## Next Steps - [call-logs Element](/sdks/web-components/call-logs) - Call logs reference - [Theming](/sdks/theming) - Customize appearance - [i18n](/sdks/i18n) - Internationalization --- ## dialstack-voicemails The voicemails Web Component displays a list of voicemails with audio playback and actions. ## Usage ```html ``` :::warning[Required: User ID] You must call `setUserId()` before the component will load data. ::: :::info[Automatic Pagination] Pagination is handled automatically by the component using cursor-based pagination. You don't need to manage cursors or page state manually. The component automatically loads more voicemails as needed. See the [Pagination Guide](/guides/pagination) for details. ::: ## Methods ### Common Methods | Method | Parameters | Description | | ------------------ | -------------------------------------------------- | ------------------------- | | `setInstance` | `instance: DialStackInstance` | Set SDK instance | | `setLocale` | `locale: Locale` | Set UI strings | | `setFormatting` | `options: FormattingOptions` | Set date/phone formatting | | `setIcons` | `icons: ComponentIcons` | Set custom SVG icons | | `setLayoutVariant` | `variant: 'compact' \| 'comfortable' \| 'default'` | Set layout density | | `setClasses` | `classes: VoicemailsClasses` | Set CSS classes | | `setOnLoaderStart` | `callback: (event) => void` | Set loading callback | | `setOnLoadError` | `callback: (event) => void` | Set error callback | ### Voicemails-Specific Methods | Method | Parameters | Description | | ---------------------- | ------------------------------------ | ------------------------- | | `setUserId` | `userId: string` | **Required.** Set user ID | | `setDisplayOptions` | `options: VoicemailDisplayOptions` | Show/hide UI elements | | `setBehaviorOptions` | `options: VoicemailBehaviorOptions` | Configure behavior | | `setCustomRowRenderer` | `renderer: (voicemail) => string` | Custom row HTML | | `setOnVoicemailSelect` | `callback: (event) => void` | Selection callback | | `setOnVoicemailPlay` | `callback: (event) => void` | Play callback | | `setOnVoicemailPause` | `callback: (event) => void` | Pause callback | | `setOnVoicemailDelete` | `callback: (event) => void` | Delete callback | | `setOnCallBack` | `callback: (event) => void` | Call back callback | | `setOnDeleteRequest` | `callback: (id) => Promise` | Custom delete confirm | ## Configuration Examples ### Display Options ```javascript const voicemails = document.querySelector('dialstack-voicemails'); voicemails.setDisplayOptions({ showDuration: true, showTranscription: true, showCallbackButton: true, showDeleteButton: true, showProgressBar: true, showTimestamp: true, }); ``` ### Behavior Options ```javascript voicemails.setBehaviorOptions({ autoPlayOnExpand: true, confirmBeforeDelete: true, markAsReadOnPlay: true, allowSeeking: true, }); ``` ### Formatting ```javascript voicemails.setFormatting({ defaultCountry: 'US', dateLocale: 'en-US', use24HourTime: false, showTimezone: false, }); ``` ### Layout and Styling ```javascript voicemails.setLayoutVariant('comfortable'); voicemails.setClasses({ base: 'my-voicemails', list: 'my-list', item: 'my-item', itemExpanded: 'my-item--expanded', itemUnread: 'my-item--unread', player: 'my-player', actions: 'my-actions', }); ``` ## Event Handlers ### onVoicemailSelect ```javascript voicemails.setOnVoicemailSelect((event) => { console.log('Selected:', event.voicemailId); }); ``` ### onVoicemailPlay / onVoicemailPause ```javascript voicemails.setOnVoicemailPlay((event) => { console.log('Playing:', event.voicemailId); }); voicemails.setOnVoicemailPause((event) => { console.log('Paused:', event.voicemailId); }); ``` ### onCallBack ```javascript voicemails.setOnCallBack((event) => { console.log('Call back:', event.phoneNumber); // Initiate call window.location.href = `tel:${event.phoneNumber}`; }); ``` ### onDeleteRequest Custom delete confirmation: ```javascript voicemails.setOnDeleteRequest(async (voicemailId) => { // Show custom modal const confirmed = await showConfirmModal('Delete voicemail?'); return confirmed; // Return true to delete, false to cancel }); ``` ### onVoicemailDelete ```javascript voicemails.setOnVoicemailDelete((event) => { console.log('Deleted:', event.voicemailId); showNotification('Voicemail deleted'); }); ``` ## Custom Row Renderer ```javascript voicemails.setCustomRowRenderer((voicemail) => { const unreadClass = voicemail.is_read ? '' : 'unread'; const duration = formatDuration(voicemail.duration_seconds); return `
${voicemail.from_name || 'Unknown'} ${voicemail.from_number}
${duration} ${new Date(voicemail.created_at).toLocaleDateString()}
${ voicemail.transcription ? `
${voicemail.transcription}
` : '' }
`; }); ``` ## Complete Example ```html

Voicemail Inbox

``` ## Next Steps - [call-logs Element](/sdks/web-components/call-logs) - Call logs reference - [Theming](/sdks/theming) - Customize appearance - [i18n](/sdks/i18n) - Internationalization --- ## Automotive Voice for dealerships, service centers, and automotive SaaS. ## Why it matters - **DMS-integrated Screen Pop** — customer, vehicle, open RO, and warranty on the first ring. - **Service appointment booking over voice** — AI Scheduling books service slots without a human. - **Department routing** — one number, smart routing to Sales / Service / Parts / BDC. - **Activity Logging** — every call attached to the RO or customer card for CRM and compliance. ## Pattern: DMS Screen Pop on the service advisor's desk ```mermaid sequenceDiagram participant Caller participant DS as DialStack participant DMS as Your DMS backend participant UI as Advisor's app Caller->>DS: Inbound call DS-->>DMS: Webhook call.incoming DMS->>DMS: ANI lookup → customer + vehicle + open RO DMS-->>UI: Push "open record" (WebSocket / SSE) UI->>UI: Customer + RO opens DS->>UI: Endpoint rings Caller->>UI: Advisor answers with full context ``` ### DMS-lookup webhook handler ```ts app.post('/webhooks/dialstack', express.raw({ type: 'application/json' }), async (req, res) => { verifySignature(req); const event = JSON.parse(req.body); if (event.type === 'call.incoming') { const customer = await dms.customers.findByPhone(event.data.from_number); if (customer) { const [vehicle, openRO] = await Promise.all([ dms.vehicles.findOne({ customer_id: customer.id, primary: true }), dms.repairOrders.findOne({ customer_id: customer.id, status: 'open' }), ]); pusher.trigger(`user-${event.data.user_id}`, 'screen-pop', { customerId: customer.id, vehicle, openRO, callId: event.data.call_id, }); } } res.status(200).end(); }); ``` DMS lookups can be slow — if yours take more than a second or two, queue the push and return `200` immediately. Full pattern: [Screen Pop](/guides/screen-pop). ## Pattern: department routing One published number, smart routing based on time-of-day, extensions, or caller history. ```ts await ds.dialPlans.create( { name: 'Main dealership line', entry_node: 'dept_schedule', nodes: [ { id: 'dept_schedule', type: 'schedule', config: { schedule_id: 'sch_service_hours', open: 'ring_service', closed: 'after_hours' }, }, { id: 'ring_service', type: 'ring_all_users', config: { timeout: 20, next: 'after_hours' } }, { id: 'after_hours', type: 'internal_dial', config: { target_id: 'va_after_hours_ai' } }, ], }, { dialstackAccount: account.id } ); ``` Publish extensions for direct access — sales `100`, service `200`, parts `300` — via the [Extensions API](/api#tag/Extensions). Each extension points at a user, ring group, or dial plan. ## Pattern: service appointment booking over voice Service scheduling is complex. Rather than rebuilding that logic, use the Notify node to stream call audio directly to your platform's existing AI assistant. Your bespoke VoiceAI handles the conversation, collects vehicle details, and books the appointment directly in your DMS — you maintain total control over the experience. Full flow: [BYO VoiceAI](/guides/voiceai-byo). ## Build this with DialStack - [Screen Pop](/guides/screen-pop) — pop the customer / vehicle / RO. - [Dial Plans](/guides/dial-plans) — department routing + time-of-day. - [Extensions](/api#tag/Extensions) — direct-access codes per department. - [AI Scheduling](/guides/voiceai-scheduling) — service appointment booking over voice. - [Click-to-Call](/guides/click-to-call) — BDC follow-up from the CRM. - [Activity Logging](/guides/activity-logging) — attach every call to the RO. --- ## Field Services Voice for dispatch-driven businesses — HVAC, plumbing, electrical, pest control, security, vehicle rental, etc. ## Why it matters - **Every missed call is a lost job.** Route to the right dispatcher instantly, fall back to AI when nobody's free. - **Click-to-Call velocity** — dispatchers return leads from inside your scheduler, not their personal phones. - **Activity Logging** — every call written to the job record for audit and coaching. - **On-call rotations** — dial plans that change by hour-of-day and on-call roster. ## Pattern: business-hour routing with AI overflow ```mermaid sequenceDiagram participant Caller participant DS as DialStack participant Team as Dispatch team participant AI as Voice App (AI receptionist) Caller->>DS: Inbound call DS->>DS: Check schedule alt Business hours DS->>Team: Ring all dispatchers (25s) alt Someone answers Team->>Caller: Connect else Timeout / all busy DS->>AI: Route to AI AI->>Caller: "All our dispatchers are busy — I can help" AI-->>DS: Webhook: customer + urgency + callback DS-->>Team: Ops notified via your on-call tool end else After hours DS->>AI: Route to AI directly end ``` ### Dial plan that does this ```ts await ds.dialPlans.create( { name: 'Main dispatch line', entry_node: 'hours', nodes: [ { id: 'hours', type: 'schedule', config: { schedule_id: 'sch_business_hours', open: 'ring_dispatch', closed: 'ai_intake' }, }, { id: 'ring_dispatch', type: 'ring_all_users', config: { timeout: 25, next: 'ai_intake' }, }, { id: 'ai_intake', type: 'internal_dial', config: { target_id: 'va_dispatch_ai' }, }, ], }, { dialstackAccount: account.id } ); ``` The AI Voice App fires a webhook to your on-call rotation system when it captures an after-hours job. Full Voice App setup: [Voice Apps](/guides/voice-apps). ## Pattern: dispatcher click-to-call from the job screen Give dispatchers a button on every job card that dials the customer. Alice's own endpoints ring first, then DialStack dials the customer and bridges the audio. ```tsx import { useDialstack } from '@dialstack/sdk-react'; function JobCard({ job, dispatcher }) { const { api } = useDialstack(); return ( ); } ``` Full behavior (ring-all endpoints, dial string formats, errors): [Click-to-Call](/guides/click-to-call). ## Pattern: log every call to the job record ```ts if (event.type === 'call.end') { const job = await db.jobs.findOne({ customer_phone: event.data.from_number }); if (job) { await db.jobActivity.insert({ job_id: job.id, call_id: event.data.call_id, user_id: event.data.user_id, direction: event.data.direction, duration: event.data.duration_seconds, status: event.data.status, ended_at: event.data.ended_at, }); } } ``` Full event surface: [Activity Logging](/guides/activity-logging). ## Build this with DialStack - [Dial Plans](/guides/dial-plans) — hour-of-day and on-call-roster logic. - [Ring Groups](/guides/ring-groups) — route by team or territory. - [Click-to-Call](/guides/click-to-call) — dispatcher velocity from the job screen. - [Screen Pop](/guides/screen-pop) — customer record on first ring. - [Activity Logging](/guides/activity-logging) — every call on the job record. - [Voice Apps](/guides/voice-apps) — AI intake for after-hours / overflow. --- ## Healthcare Embedded Voice for practices, clinics, and specialty-health SaaS. ## Why it matters - **HIPAA-aware** — encrypted media, BAA available, audit logs on every call. - **Screen Pop on the patient chart** — the chart opens on the first ring, before anyone says hello. - **AI Scheduling** — callers book their own appointments via voice. No hold music, no phone tag. - **After-hours triage** — urgent calls to the on-call provider, everything else to voicemail with transcription. ## Pattern: Screen Pop on the patient chart ```mermaid sequenceDiagram participant Caller participant DS as DialStack participant EHR as Your EHR backend participant UI as Clinician's app Caller->>DS: Inbound call DS-->>EHR: Webhook call.incoming EHR->>EHR: Lookup patient by from_number EHR-->>UI: Push "open chart" (WebSocket / SSE / Pusher) UI->>UI: Patient chart opens Note over Caller,DS: Phone still ringing DS->>UI: Endpoint rings Caller->>UI: Clinician answers, chart already on screen ``` ### Minimal webhook handler ```ts app.post('/webhooks/dialstack', express.raw({ type: 'application/json' }), async (req, res) => { verifySignature(req); const event = JSON.parse(req.body); if (event.type === 'call.incoming') { const patient = await db.patients.findOne({ phone: event.data.from_number }); if (patient) { // push to the clinician's open browser tab via your realtime channel pusher.trigger(`user-${event.data.user_id}`, 'screen-pop', { patientId: patient.id, lastVisit: patient.last_visit, callId: event.data.call_id, }); } } res.status(200).end(); }); ``` The webhook is fire-and-forget — respond `200` fast. See [Screen Pop](/guides/screen-pop) for the full pattern, including the frontend-only variant using the SSE event stream. ## Pattern: AI Scheduling for after-hours booking Route closed-hours calls to a Voice App in Notify mode that collects the booking request and calls your availability / booking APIs back. ```ts await ds.dialPlans.create( { name: 'Main line', entry_node: 'hours', nodes: [ { id: 'hours', type: 'schedule', config: { schedule_id: 'sch_business_hours', open: 'ring_front_desk', closed: 'ai_book' }, }, { id: 'ring_front_desk', type: 'ring_all_users', config: { timeout: 30, next: 'ai_book' } }, { id: 'ai_book', type: 'internal_dial', config: { target_id: 'va_booking_agent' } }, ], }, { dialstackAccount: account.id } ); ``` The booking agent is a Voice App configured with your availability + booking endpoints. Full flow: [AI Scheduling](/guides/voiceai-scheduling). ## HIPAA notes - **Encryption** — media in transit is SRTP; recordings and voicemails are encrypted at rest. - **BAA** — available on request for platforms handling PHI. - **Audit logs** — every call and admin action is retained in the platform audit log. - **Recording consent** — recording is per-account opt-in. Ensure you obtain consent before enabling it if your jurisdiction requires two-party consent. Specific compliance commitments are platform-agreement-level — reach out and we'll scope. ## Build this with DialStack - [Screen Pop](/guides/screen-pop) — pop the patient chart on first ring. - [Activity Logging](/guides/activity-logging) — write every call to the patient record. - [Click-to-Call](/guides/click-to-call) — outbound from the chart. - [AI Scheduling](/guides/voiceai-scheduling) — native booking over voice. - [Appointment Webhooks](/guides/appointment-webhooks) — sync bookings back to your calendar. --- ## Calling & Call Control Make and receive phone calls, and control them with hold, transfer, mute, and DTMF. ## Making Calls ### Outbound Call Flow ```javascript // Dial a phone number (E.164 format) const call = await phone.call('+14155551234'); // Or dial an extension const call = await phone.call('105'); // Optional: specify caller ID const call = await phone.call('+14155551234', { callerId: '+14155559876', }); ``` The call object emits events as the call progresses: | Event | Description | | ---------- | -------------------------------- | | `trying` | Server is processing the call | | `ringing` | Remote party's phone is ringing | | `answered` | Call connected, audio is flowing | | `held` | Call placed on hold | | `resumed` | Call resumed from hold | | `ended` | Call ended | ### Ringback Tone When an outbound call starts ringing, the SDK plays an audible ringback tone so the caller hears that the call is progressing — you don't need to wire up any audio for this. The tone stops automatically when the call is answered or ends. If the network already sends its own ringing audio (for example, a carrier ringback or a recorded announcement), the SDK detects that incoming audio and suppresses the synthetic tone, so the caller never hears two ringback sounds layered on top of each other. Because outbound calls are placed in response to a user action (such as clicking "Dial"), the browser permits the tone to play. No extra configuration is required. ### Inbound Call Flow ```javascript phone.on('incoming', (call) => { // call.id — call identifier // call.from — caller's number (E.164) // call.fromName — caller's display name (if available) // call.to — called number or extension // Answer call.answer(); // Or reject call.reject(); // silent decline call.reject('busy'); // send busy signal }); ``` Ring duration is controlled by the caller's routing (FMFM, dial plan, or upstream PBX). When the upstream gives up, the call ends with reason `no-answer`. :::tip[Multiple devices] When a call arrives, it rings **all** of the user's registered devices simultaneously (SIP desk phones, WebRTC softphones). The first device to answer wins — all others stop ringing. ::: ### Call Ended Reasons ```javascript call.on('ended', (reason) => { switch (reason) { case 'hangup': // Normal hangup by either party break; case 'busy': // Destination is busy break; case 'no-answer': // No one answered break; case 'failed': // Call setup failed (invalid number, network error) break; case 'transferred': // Call was transferred break; case 'rejected': // Remote party rejected the call break; } }); ``` ## Call Control ### Ending and Cancelling Calls `call.hangup()` ends a call from your side. It works at every stage of an outbound call, so it's the one method you use both to hang up a connected conversation and to cancel an outbound call that hasn't been answered yet: ```javascript // End the call — works whether it's ringing or already answered call.hangup(); ``` For an **inbound** call that hasn't been answered, decline it with [`call.reject()`](#inbound-call-flow) instead — `reject()` can signal a busy or decline reason, which `hangup()` cannot. #### Cancel a ringing outbound call To cancel an outbound call that is still ringing (before the remote party picks up), call `hangup()` on the same call object returned by `phone.call()`: ```javascript const call = await phone.call('+14155551234'); // The user changed their mind before the call was answered — cancel it call.hangup(); ``` You can always just call `hangup()` — the platform cancels the outbound attempt while the call is still `trying` or `ringing`, and hangs up the connected call once it's `answered` (or `held`). You don't need to check the state yourself. In every case the call ends with reason `hangup`: ```javascript call.on('ended', (reason) => { console.log(reason); // 'hangup' }); ``` ### Hold and Resume ```javascript // Place the call on hold (remote party hears hold music) call.hold(); // Resume the call call.resume(); // Listen for hold events (local or remote hold) call.on('held', (heldBy) => { if (heldBy === 'remote') { console.log('The other party put you on hold'); } }); call.on('resumed', () => { console.log('Call resumed'); }); ``` ### Mute ```javascript // Client-side mute (stops sending audio from the microphone) call.mute(); call.unmute(); // Check mute state console.log(call.isMuted); // true or false ``` The SDK both disables the local microphone track and sends a `call.mute` message to the server. Server-side mute is authoritative — even if the client sends audio, it is not forwarded to the remote party. ### DTMF Send DTMF tones (touch-tones for IVR menus, extensions, etc.) with `call.sendDtmf()`. Tones are sent inline with the audio stream (RFC 4733 `telephone-event` RTP payloads): ```javascript // Send a sequence of digits (0-9, *, #, A-D) call.sendDtmf('1234#'); // Optionally tune the per-tone duration and inter-tone gap (milliseconds) call.sendDtmf('1234#', 100, 70); ``` DTMF is only meaningful once the call is `answered`. On platforms whose WebRTC stack doesn't expose a DTMF sender (notably React Native), sending is unavailable — check `call.canSendDtmf` before offering a keypad, and note that `sendDtmf()` throws if called when it isn't available: ```javascript if (call.canSendDtmf) { call.sendDtmf('1'); } ``` ### Blind Transfer Transfer the call to another destination immediately: ```javascript // Transfer to an extension call.transfer('105'); // Transfer to a phone number call.transfer('+14155551234'); // The call ends for you after the transfer call.on('ended', (reason) => { console.log(reason); // 'transferred' }); ``` ### Attended Transfer Consult with the transfer target before completing the transfer: ```javascript // Step 1: Consult — your current call is held, a new call is placed const consultCall = await call.attendedTransfer('105'); consultCall.on('answered', () => { console.log('Consulting with transfer target...'); // Step 2: Complete — connect the original caller to the target call.completeTransfer(); // Or cancel — hang up the consultation call, original call resumes // consultCall.hangup(); }); ``` If the consultation call fails (busy, no-answer), the original call is automatically resumed. ## Multi-Call Handling A single WebSocket connection supports multiple simultaneous calls. This enables call waiting, call swap, and conferencing workflows. ### Call Waiting When a second call arrives while you're on a call, the `incoming` event fires as usual: ```javascript phone.on('incoming', (call) => { if (phone.activeCalls.length > 0) { // Already on a call — this is call waiting showCallWaitingUI(call); } else { showIncomingCallUI(call); } }); ``` ### Swapping Calls Hold the current call and answer or resume another: ```javascript // Hold current call and answer the waiting call currentCall.hold(); waitingCall.answer(); // Later, swap back waitingCall.hold(); currentCall.resume(); ``` ### Active Calls ```javascript // List all active calls const calls = phone.activeCalls; // [{ id, from, to, state: 'active' | 'held' | 'ringing', direction }] // Find a specific call const call = phone.getCall(callId); ``` ## Direct WebSocket Usage If you're not using the SDK, you can interact with the signalling protocol directly. See the [Signalling Protocol reference](./protocol.md) for the complete message specification. ### Making a Call (Raw WebSocket) ```javascript // 1. Create the peer connection and generate an SDP offer const pc = new RTCPeerConnection({ iceServers }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); stream.getTracks().forEach((track) => pc.addTrack(track, stream)); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // 2. Initiate the call with the offer in-band ws.send( JSON.stringify({ type: 'call.create', req_id: 'my-call-1', destination: '+14155551234', sdp: offer.sdp, }) ); // 3. Handle server messages ws.onmessage = async (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case 'call.trying': // Server assigned a call_id const callId = msg.call_id; break; case 'sdp.answer': // The answer to our offer await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }); break; case 'ice.candidate': await pc.addIceCandidate({ candidate: msg.candidate, sdpMid: msg.sdp_mid, sdpMLineIndex: msg.sdp_m_line_index, }); break; case 'call.ringing': // Play ringback tone break; case 'call.answered': // Call connected — audio is flowing break; case 'call.ended': pc.close(); break; } }; // 4. Send local ICE candidates pc.onicecandidate = (event) => { if (event.candidate) { ws.send( JSON.stringify({ type: 'ice.candidate', call_id: callId, candidate: event.candidate.candidate, sdp_mid: event.candidate.sdpMid, sdp_m_line_index: event.candidate.sdpMLineIndex, }) ); } else { ws.send(JSON.stringify({ type: 'ice.done', call_id: callId })); } }; ``` --- ## Emergency Calling (E911) WebRTC softphone users are nomadic — they may call 911 from different locations. Federal regulations (RAY BAUM's Act, Kari's Law) require that every 911 call includes a dispatchable location (street address with floor/suite) so first responders can find the caller. ## How It Works 1. **Register an emergency address** for the user with `phone.setEmergencyAddress(...)`. The address is validated against the MSAG (Master Street Address Guide), saved, and selected for the phone's next connection. 2. **Connect with the address selected.** DialStack binds an emergency address only when its id is presented in the WebSocket `authenticate` message. Set it before the first `connect()`, or call `reconnectWithEmergency(id)` after creating or selecting it on an already-connected phone. 3. **Prompt on network change** — when the device moves to a different network, the SDK emits a `network.changed` event. Prompt the user to confirm or register a location valid for the new network. 4. **911 calls route with location** — when the user dials 911, DialStack includes the bound address so the PSAP can dispatch to the correct location. ## Registering an address ```javascript // Validate and save an emergency address. The SDK selects it for future // connections, but it does not alter an already-authenticated connection. const address = await phone.setEmergencyAddress({ address_number: '123', street: 'Main St', unit: 'Suite 400', city: 'San Francisco', state: 'CA', postal_code: '94105', country: 'US', }); // If the phone is already connected, reconnect so its authenticate message // presents the new id. The prebuilt React softphone does this automatically. await phone.reconnectWithEmergency(address.id); ``` The address is validated synchronously. If it can't be matched to a valid civic address, the call rejects with a validation error. If you create it before the phone's first `connect()`, no extra reconnect is needed: `setEmergencyAddress` persists the selection and the initial authentication presents it. Creating an address through `POST /v1/me/emergency-addresses` directly also does not update a live softphone. Pass the returned id as `PhoneOptions.emergencyAddressId` before connecting, or call `phone.reconnectWithEmergency(address.id)` on the existing phone. Until a connection authenticates with that id, `registered_ip` remains `null`, non-emergency PSTN calls are blocked, and 911 calls have no dispatchable location. A user can keep **several saved addresses** (e.g. home and office). List and manage them: ```javascript const saved = await phone.listEmergencyAddresses().autoPagingToArray(); await phone.deleteEmergencyAddress(id); ``` ## Network changes The address is bound to the network the device registered from. When the device connects from a **different** network, the bound address no longer applies and the SDK emits `network.changed`: ```javascript phone.on('network.changed', async () => { // Bound location no longer applies here. Prompt the user, then register // an address valid for this network and reconnect with its id. const address = await phone.setEmergencyAddress({/* current location */}); await phone.reconnectWithEmergency(address.id); }); ``` The previously saved addresses are **not** deleted. To confirm a previously saved address on the current network, clear its old network binding and reconnect with it selected: ```javascript await phone.clearEmergencyAddressRegisteredIp(id); await phone.reconnectWithEmergency(id); ``` ## Requirements - **Address required for non-emergency calls**: a user must have an emergency address bound to the current network before placing **non-emergency** calls. Attempting to dial without one plays an "address not registered" announcement and ends the call. - **911 and 933 always work**: emergency and E911-test calls are never blocked — they go out with or without a registered address. The `network.changed` event is the signal to (re)register; it never disables emergency dialing. - **Prompt on network change**: detect `network.changed` and prompt the user to confirm their location. :::danger[Regulatory requirement] E911 compliance is not optional. If your users can place calls through the softphone, you must implement emergency address registration. Failure to provide accurate location with 911 calls carries regulatory and legal liability. ::: --- ## WebRTC import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # WebRTC Build softphone experiences for your **users** on web, mobile, and desktop using DialStack's WebRTC API. This API is the softphone side of DialStack — it lets a signed-in user place and receive calls from their browser or native app. It is not a programmable-voice or A2P (application-to-person) API: every WebRTC session is bound to an authenticated DialStack user, and the call shows up in their call history, billing, and presence just like a call from their desk phone. ## Overview The WebRTC API enables your application to make and receive phone calls directly from a browser or native app on behalf of a signed-in user. It consists of three parts: 1. **User authentication** — end users authenticate to get a token ([Authentication guide](/guides/authentication#user-tokens)) 2. **Signalling** — a WebSocket connection at `wss://api.dialstack.ai/v1/webrtc` handles call setup, control, and presence 3. **Media** — WebRTC peer connections carry the actual audio between the client and DialStack A single WebSocket connection handles all signalling for one user, including multiple simultaneous calls. ## Architecture ``` ┌───────────────────────────────────────────────────────────┐ │ Your Application (browser / mobile / desktop) │ │ │ │ ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │ │ │ Your UI │ │ DialStack SDK │ │ WebRTC │ │ │ │ (dial pad, │──│ (signalling, │──│ Media │ │ │ │ contacts) │ │ call control) │ │ (audio) │ │ │ └─────────────┘ └────────┬─────────┘ └──────┬──────┘ │ │ │ │ │ └────────────────────────────┼───────────────────┼──────────┘ │ WSS │ SRTP │ (signalling) │ (encrypted audio) ▼ ▼ ┌────────────────────────────────────────┐ │ DialStack │ │ │ │ Signalling ──── Media ──── PSTN │ │ Server Gateway Gateway │ └────────────────────────────────────────┘ ``` - **Signalling** travels over a single WebSocket (JSON messages) - **Media** flows directly via WebRTC (ICE/DTLS-SRTP) — encrypted end-to-end between client and DialStack - **PSTN connectivity** is handled server-side — your client just sends and receives audio ## Quick Start ### 1. Get a user token After your backend has authenticated the user with your own identity provider, mint a DialStack user session token for them. Your backend calls DialStack server-to-server — the user's IdP credentials never leave your infrastructure: ```javascript // Your backend import { DialStack } from '@dialstack/sdk-server'; const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); const { client_secret: token } = await dialstack.userSessions.create({ user: 'user_01h2xcejqtf2nbrexx3vqjhp42', // ttl_seconds: 3600, // optional; defaults to 24 hours, max 7 days }); ``` The returned `client_secret` is a short-lived JWT scoped to the named user. Hand it to your frontend; never expose your DialStack API key to the client. The user must already be provisioned via `POST /v1/users`. ### 2. Connect the softphone ```javascript // Your frontend import { DialStackPhone } from '@dialstack/sdk-webrtc'; const phone = new DialStackPhone({ token, onTokenExpiring: async () => { const { token } = await fetch('/api/dialstack/user-token', { method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }, }).then((r) => r.json()); return token; }, }); await phone.connect(); console.log('Phone connected, ready for calls'); ``` ### 3. Make a call ```javascript const call = await phone.call('+14155551234'); call.on('ringing', () => console.log('Ringing...')); call.on('answered', () => console.log('Connected!')); call.on('ended', (reason) => console.log('Call ended:', reason)); ``` ### 4. Receive a call ```javascript phone.on('incoming', (call) => { console.log('Incoming call from', call.from, call.fromName); // Show UI, then: call.answer(); // Or reject: // call.reject(); }); ``` ## Connecting ### WebSocket Connection The SDK manages the WebSocket connection automatically. Under the hood, it: 1. Opens a WebSocket to `wss://api.dialstack.ai/v1/webrtc` 2. Sends an `authenticate` message with the user token 3. Receives `authenticated` confirming the session 4. Sends `ping` every 30 seconds to keep the connection alive ```javascript const phone = new DialStackPhone({ token }); phone.on('connected', () => { // WebSocket connected and authenticated }); phone.on('disconnected', () => { // WebSocket lost — SDK will reconnect automatically }); phone.on('reconnected', () => { // Reconnected — active calls are restored }); await phone.connect(); ``` ### Reconnection If the WebSocket connection drops (network change, brief outage), the SDK reconnects automatically with exponential backoff. Active calls survive brief disconnections (up to 30 seconds) — the server preserves call state during the gap. On reconnection, the SDK: 1. Authenticates with the current token 2. Receives `call.restored` for each active call 3. Closes the old `RTCPeerConnection` and creates a new one 4. Completes a full SDP offer/answer exchange to re-establish media This adds ~1 second to call recovery but is simpler and more reliable than attempting an ICE restart on the existing connection. The SDK handles this automatically — your code just sees the `reconnected` event. ### Session Limits A single user can have up to **3 concurrent WebRTC sessions** (e.g., laptop, phone, tablet). All connected sessions ring on incoming calls, but **only one session at a time can hold an active call** — once a call is answered on one device, the others stop ringing and cannot place or answer another call until the active call ends. This avoids the "two calls on two devices" gaming behaviors that the model isn't built for. If a user attempts a 4th connection, the oldest session is disconnected. ```javascript phone.on('error', (error) => { if (error.code === 'session_limit') { // User has too many active sessions showMessage('You are connected on too many devices. Close another session and try again.'); } }); ``` ### Token Refresh User session tokens default to a 24-hour lifetime (configurable up to 7 days via `ttl_seconds`). The server enforces the token's expiry for the whole lifetime of the connection: when the token expires it closes the connection with an `auth_expired` error, so a long-lived connection does not outlive its token. To avoid that, the SDK fires the `onTokenExpiring` callback shortly before expiry (about 60 seconds ahead) and delivers the fresh token you return **in-band over the existing signalling connection** — there is no reconnect and no call disruption. The WebSocket, and any in-progress call, stays open across the token swap. How often the user has to interactively sign in depends on **your** identity provider, not DialStack. The session token is minted by your backend on demand, so DialStack inherits whatever session lifetime your IdP enforces. In practice this means: - While the IdP session is still valid, `onTokenExpiring` silently calls your backend, which mints a fresh DialStack session; the SDK refreshes the connection in place and the user notices nothing. - If the IdP session has expired, your backend's mint call won't happen. The SDK surfaces the failure as an `error` event (it does **not** tear down the still-valid connection); handle it by triggering your normal sign-in flow. If the token is never refreshed in time, the server evicts the connection with a fatal `auth_expired`; the SDK surfaces that and stops — obtain a fresh token and call `phone.connect()` again to start a new session. ```javascript const phone = new DialStackPhone({ token: initialToken, onTokenExpiring: async () => { // Fetch a new token from your backend const { token } = await fetch('/api/dialstack/user-token', { method: 'POST', }).then((r) => r.json()); return token; }, }); ``` ### Error Handling ```javascript phone.on('error', (error) => { switch (error.code) { case 'auth_failed': // Token is invalid — get a new one break; case 'auth_expired': // Token expired. The SDK refreshes in-band via onTokenExpiring before // this fires; seeing it means the refresh could not be obtained in time // (e.g. the IdP session ended). Trigger your sign-in flow, then reconnect. break; case 'rate_limited': // Too many requests — back off break; } }); ``` ## What's Next - [Calling & Call Control](./calling.md) — Make and receive calls, hold, transfer, DTMF - [Presence](./presence.md) — Real-time user status and BLF - [Mobile & Push Notifications](./mobile.md) — Push notifications and background audio - [Emergency Calling (E911)](./emergency.md) — Address registration and 911 routing for nomadic users - [Network & Troubleshooting](./network.md) — Codecs, firewall configuration, debugging - [Signalling Protocol](./protocol.md) — Low-level WebSocket message reference - [Client SDK Reference](./sdk.md) — Complete TypeScript API --- ## Mobile & Push Notifications import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Mobile & Push Notifications Build mobile softphone apps with push notifications so incoming calls can wake a backgrounded app. Mobile clients still need to register an emergency address for the user — see [Emergency Calling (E911)](./emergency.md). Nomadic use cases make this especially important on mobile, since users routinely change networks and locations. ## Push Notifications When a mobile app is in the background or closed, the WebRTC signalling connection is not active. Push notifications bridge this gap — DialStack notifies your backend via webhook, and your backend sends a push to wake the app. ### Enabling Wake-Up Hold By default, when a user has no active calling session, an incoming call moves on to their other devices and voicemail immediately. For DialStack to instead **hold the call** while your push wakes the app, enable `mobile_push_wakeup` on the user: ```javascript await dialstack.users.update( userId, { config: { mobile_push_wakeup: true } }, { dialstackAccount: accountId } ); ``` Enable this for users your application can actually reach with push notifications (for example, when they register a device token). With the flag on, the call keeps ringing toward the user's app for a wake-up window — long enough for push delivery, app wake-up, and connection — before falling through to the user's normal routing (next Find Me / Follow Me step or voicemail). With the flag off, there is no wait. ### How It Works ``` ┌──────────┐ ┌──────────────┐ ┌───────────┐ ┌──────────┐ │ Caller │ │ DialStack │ │ Your │ │ User's │ │ │ │ │ │ Backend │ │ Phone │ └────┬─────┘ └──────┬───────┘ └─────┬─────┘ └────┬─────┘ │ Calls user │ │ │ │────────────────>│ │ │ │ │ │ │ │ │ 1. Webhook: │ │ │ │ call.mobile_ │ │ │ │ push_wakeup │ │ │ │─────────────────>│ │ │ │ │ │ │ │ │ 2. Push │ │ │ │ notification │ │ │ │──────────────>│ │ │ │ │ │ │ │ 3. App wakes│ │ │ │ connects WS │ │ │ <───────────────────────────────│ │ │ │ │ │ │ 4. call.incoming│ │ │ │ (via WebSocket) │ │ │ │─────────────────────────────────>│ │ │ │ │ │ │ │ 5. User │ │ <─────────────────────────────────── answers │ │ Call connected │ │ ``` 1. DialStack sends a `call.mobile_push_wakeup` webhook to your platform's webhook URL when the call is being delivered to the user's app 2. Your backend sends a push notification (APNs or FCM) to the user's device 3. The app wakes and connects to the WebRTC signalling channel 4. DialStack delivers the `call.incoming` message over WebSocket 5. The user answers and the call connects :::tip[Time budget] You have approximately **30 seconds** from when the call arrives until it times out. Push notification delivery and app wake typically take 2–5 seconds, leaving plenty of time for the user to answer. ::: ### Webhook Payload DialStack delivers a `call.mobile_push_wakeup` [webhook event](/guides/webhook-events) to your platform's webhook URL whenever an incoming call is being delivered to the app session of a user with `mobile_push_wakeup` enabled — whether the call reached them directly, through a ring group, or as a call queue agent. The `user_id` field identifies the user whose app should be woken, so you can look up their device tokens in your own database and send the push: ```json { "id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v3", "type": "call.mobile_push_wakeup", "created_at": "2026-04-10T14:30:00Z", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "data": { "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "from_number": "+14155551234", "from_name": "John Smith", "to_number": "+14155559876", "ringing_at": "2026-04-10T14:30:00Z" } } ``` :::note[Why not `call.incoming` or `call.ringing`?] `call.incoming` fires once when the call enters the platform, and its `user_id` is only present when the number routes directly to a single user. `call.ringing` fires per user being reached, but also when their calls forward to an external number — a push sent on it could wake the app for a call that never arrives, which iOS penalizes (every VoIP push must report a call). `call.mobile_push_wakeup` fires only when the user's app session is actually being rung, so every push corresponds to a real, answerable call. ::: Your backend maintains the mapping between DialStack `user_id` and your push notification device tokens. DialStack does not store device tokens — push delivery is entirely in your control. ### Sending Push Notifications #### iOS (APNs) Send a VoIP push notification using the PushKit framework. VoIP pushes wake the app immediately and have higher priority than standard notifications. ```javascript import apn from 'apn'; const provider = new apn.Provider({ token: { key: './AuthKey.p8', keyId: 'YOUR_KEY_ID', teamId: 'YOUR_TEAM_ID', }, production: true, }); async function sendCallPush(deviceToken, callData) { const notification = new apn.Notification(); notification.topic = 'com.example.myapp.voip'; notification.pushType = 'voip'; notification.priority = 10; notification.expiry = Math.floor(Date.now() / 1000) + 30; // 30 second TTL notification.payload = { call_id: callData.call_id, from_number: callData.from_number, from_name: callData.from_name, to_number: callData.to_number, }; await provider.send(notification, deviceToken); } ``` #### Android (FCM) Send a high-priority data message to bypass Doze mode: ```javascript import admin from 'firebase-admin'; admin.initializeApp({ credential: admin.credential.applicationDefault(), }); async function sendCallPush(deviceToken, callData) { await admin.messaging().send({ token: deviceToken, android: { priority: 'high', ttl: 30000, // 30 seconds }, data: { type: 'call.mobile_push_wakeup', call_id: callData.call_id, from_number: callData.from_number, from_name: callData.from_name || '', to_number: callData.to_number, }, }); } ``` :::danger[Do not use notification messages] Use FCM **data messages** (not notification messages) for incoming calls. Data messages wake the app immediately and let you show a full-screen call UI. Notification messages display a banner that the user must tap. ::: ### Webhook Handler Your platform's webhook handler receives `call.mobile_push_wakeup` events for all accounts. Look up the user's devices and send notifications: ```javascript import express from 'express'; import { DialStack } from '@dialstack/sdk-server'; const app = express(); const dialstack = new DialStack(process.env.DIALSTACK_API_KEY); // This is your platform-level webhook URL (configured during onboarding) app.post('/webhooks/dialstack', express.json(), async (req, res) => { // Verify the webhook signature const isValid = dialstack.webhooks.verifySignature( req.body, req.headers['x-dialstack-signature'], process.env.DIALSTACK_WEBHOOK_SECRET ); if (!isValid) { return res.status(401).send('Invalid signature'); } const { type, data } = req.body; if (type === 'call.mobile_push_wakeup' && data.user_id) { // Look up the user's device tokens in YOUR database const devices = await getDeviceTokensForUser(data.user_id); // Send push to all registered devices in parallel await Promise.all( devices.map((device) => { if (device.platform === 'apns') { return sendApnsPush(device.token, data); } else if (device.platform === 'fcm') { return sendFcmPush(device.token, data); } }) ); } res.status(200).send('OK'); }); ``` ### Mobile App Flow #### iOS (Swift) ```swift // 1. Register for VoIP push notifications import PushKit class AppDelegate: UIResponder, PKPushRegistryDelegate { func registerForVoIPPush() { let registry = PKPushRegistry(queue: .main) registry.delegate = self registry.desiredPushTypes = [.voIP] } func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() // Store the device token in YOUR backend, mapped to the user's // DialStack user_id. Your backend uses this to send pushes // when it receives a call.mobile_push_wakeup webhook. YourAPI.shared.registerPushToken(token: token, platform: "apns") } // 2. Handle incoming VoIP push func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) { let callData = payload.dictionaryPayload // 3. Report to CallKit (required on iOS) let update = CXCallUpdate() update.remoteHandle = CXHandle( type: .phoneNumber, value: callData["from_number"] as? String ?? "" ) update.localizedCallerName = callData["from_name"] as? String let callUUID = UUID() CXProvider.shared.reportNewIncomingCall( with: callUUID, update: update ) { error in if error == nil { // 4. Connect to WebRTC in the background DialStackPhone.shared.connect() } } } } ``` #### Android (Kotlin) ```kotlin // 1. Handle FCM data message class CallFirebaseService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { val data = message.data if (data["type"] == "call.mobile_push_wakeup") { // 2. Show full-screen call notification val intent = Intent(this, IncomingCallActivity::class.java).apply { putExtra("call_id", data["call_id"]) putExtra("from_number", data["from_number"]) putExtra("from_name", data["from_name"]) flags = Intent.FLAG_ACTIVITY_NEW_TASK } val pendingIntent = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) // Use a full-screen intent for incoming call UI val notification = NotificationCompat.Builder(this, "calls") .setSmallIcon(R.drawable.ic_call) .setContentTitle(data["from_name"] ?: data["from_number"]) .setContentText("Incoming call") .setPriority(NotificationCompat.PRIORITY_MAX) .setCategory(NotificationCompat.CATEGORY_CALL) .setFullScreenIntent(pendingIntent, true) .build() val manager = getSystemService(NotificationManager::class.java) manager.notify(CALL_NOTIFICATION_ID, notification) } } } ``` ## Background Audio On mobile platforms, configure your app to continue audio playback in the background: - **iOS**: Enable the `audio` background mode in your app's capabilities - **Android**: Use a foreground service with the `mediaPlayback` type during active calls ## Battery Optimization The WebSocket connection consumes battery when maintained in the background. For mobile apps: - Maintain the WebSocket only while the app is in the foreground - Rely on push notifications for incoming calls when backgrounded ## Best Practices ### DO - Send push notifications with a short TTL (30 seconds) — stale call notifications confuse users - Use VoIP pushes on iOS (PushKit) for immediate delivery and CallKit integration - Use FCM data messages (not notification messages) on Android for full app control - Include the `call_id` in the push payload so the app can match it to the WebSocket event ### DON'T - Don't rely solely on the WebSocket for mobile incoming calls — the connection drops when backgrounded - Don't keep the WebSocket alive in the background on mobile — it drains battery --- ## Network & Troubleshooting Audio codecs, bandwidth requirements, firewall configuration, and common issues. ## Audio & Codec Details ### Supported Codecs | Codec | Direction | Use case | | --------------- | ------------------ | -------------------------------------------------------------------- | | **Opus** | Client ↔ DialStack | WebRTC media path. Wideband (48 kHz), low latency, adaptive bitrate. | | **G.711 μ-law** | DialStack ↔ PSTN | PSTN leg. DialStack transcodes between Opus and G.711 transparently. | The SDP offer from DialStack includes Opus as the preferred codec. You do not need to configure codecs — the browser's WebRTC stack negotiates automatically. ### Bandwidth Requirements | Scenario | Bandwidth (per direction) | | ------------------- | ------------------------- | | Opus voice call | 24–40 kbps | | TURN relay overhead | +10–15 kbps | | Typical total | ~50–60 kbps up + down | These are conservative estimates for voice-only calls. Actual bandwidth depends on network conditions — Opus adapts its bitrate dynamically. ### Encryption All WebRTC media is encrypted with DTLS-SRTP (mandatory per the WebRTC specification). The signalling WebSocket uses TLS (WSS). There is no unencrypted path. ## ICE/TURN Configuration WebRTC requires STUN/TURN servers for NAT traversal. The SDK handles this automatically, but if you're building a custom client: ```javascript // Fetch ICE server credentials const response = await fetch('https://api.dialstack.ai/v1/webrtc/ice-servers', { headers: { Authorization: `Bearer ${userToken}` }, }); const { ice_servers, expires_at } = await response.json(); // Pass to RTCPeerConnection const pc = new RTCPeerConnection({ iceServers: ice_servers }); ``` TURN credentials are time-limited. Fetch fresh credentials before the `expires_at` timestamp — typically before each new call. ## Network Requirements ### Required Connectivity | Protocol | Destination | Port | Purpose | | -------------- | ------------------------ | ---- | ------------------------------- | | WSS (TCP) | `api.dialstack.ai` | 443 | Signalling WebSocket | | STUN (UDP) | `global.stun.twilio.com` | 3478 | NAT type discovery | | TURN (UDP/TCP) | `global.turn.twilio.com` | 3478 | Media relay (preferred) | | TURN (TLS/TCP) | `global.turn.twilio.com` | 443 | Media relay (firewall fallback) | The exact ICE server URLs and credentials are returned dynamically by the `/v1/webrtc/ice-servers` endpoint — always use what the API returns rather than hardcoding hostnames. The signalling WebSocket always uses TCP 443 (standard HTTPS port). Media uses UDP when possible but falls back to TURN over TCP 443 when UDP is blocked — this ensures calls work even behind restrictive corporate firewalls. ### How Media Connectivity Works WebRTC uses ICE (Interactive Connectivity Establishment) to find the best path for audio: 1. **Direct (host candidate)** — local network, lowest latency 2. **STUN (server-reflexive)** — through NAT, still direct peer-to-peer 3. **TURN (relay)** — relayed through DialStack's TURN server, works behind any firewall The SDK tries all paths simultaneously and uses the best one. If UDP is blocked entirely, TURN over TCP 443 is the last resort — it adds ~20ms latency but is indistinguishable from HTTPS traffic to firewalls. ### Enterprise Firewall Configuration For IT administrators configuring firewalls for DialStack WebRTC: ``` # Minimum required (signalling + TURN fallback) Allow TCP 443 outbound to api.dialstack.ai Allow TCP 443 outbound to global.turn.twilio.com # Recommended (better audio quality via UDP) Allow UDP 3478 outbound to global.turn.twilio.com Allow UDP 3478 outbound to global.stun.twilio.com ``` TURN over TCP 443 is always available as a fallback. If only TCP 443 is permitted, calls will work but with slightly higher latency. ## Troubleshooting ### No audio after connecting Most common cause: UDP is blocked by a firewall. The SDK will fall back to TURN/TCP, but this takes a few seconds. If TURN/TCP is also blocked, media cannot flow. Verify connectivity: open `https://api.dialstack.ai/v1/webrtc/ice-servers` in a browser — if this loads, the signalling path is fine and the issue is media. ### One-way audio Typically a symmetric NAT issue. TURN relay resolves this automatically. If one-way audio persists, check that the TURN credentials haven't expired (fetch fresh credentials before each call). ### Call drops after ~30 seconds ICE connectivity check timeout. The media path couldn't be established. Check that at least one of UDP 3478 or TCP 443 to `global.turn.twilio.com` is reachable. ### High latency or choppy audio Check available bandwidth (50 kbps minimum per direction). Wi-Fi congestion and mobile network transitions are common causes. Use `call.peerConnection.getStats()` to inspect WebRTC quality metrics (jitter, packet loss, round-trip time). --- ## Presence & BLF Subscribe to real-time presence updates for other users in the account. This powers busy lamp field (BLF) indicators and contact status displays. ## Subscribing ```javascript // Subscribe to all users in the account phone.subscribePresence(); // Or subscribe to specific users phone.subscribePresence(['user_01h2xcejqtf2nbrexx3vqjhp42', 'user_01h2xcejqtf2nbrexx3vqjhp43']); // Get initial presence snapshot phone.on('presenceList', (users) => { // [{ userId, name, status, doNotDisturb, statusText, updatedAt }] updateBuddyList(users); }); // Listen for changes phone.on('presenceUpdate', (update) => { // { userId, status, doNotDisturb, statusText, updatedAt } updateBuddyListEntry(update); }); ``` ## Presence Statuses `status` reports what the phone system **observes** about reachability: | Status | Description | | ----------- | --------------------------------------------- | | `available` | User is online and can receive calls | | `on_call` | User is on an active call (set automatically) | | `offline` | No active WebRTC or SIP registration | ### Do Not Disturb is a separate field `doNotDisturb` is a **separate axis** from `status`, not one of its values: a user can be `available` but decline calls because `doNotDisturb` is on. ```js // A registered user who has DND on { status: 'available', doNotDisturb: true } ``` The two answer different questions, which is why we report them side by side rather than folding DND into `status`: - `doNotDisturb` is **configuration**. Someone turned it on, and it stays on until someone turns it off — across reconnects, restarts, and going offline. - `status` is a **lossy summary of what the system currently observes**. It reduces several signals to one of three values using the precedence `on_call` > `available` > `offline`, so some detail is deliberately discarded. That reduction is a product decision, not a technical necessity, and it has a consequence worth knowing if you are driving a BLF display: because `on_call` takes precedence, a user on a call reports `on_call` even if their device is no longer registered. Read `status` as the single best answer to "can I reach this person right now", not as a faithful description of every underlying signal. `status` never carries `dnd`. This matches the REST presence API, which reports `state` and `do_not_disturb` independently. ## Setting Your Status ```javascript // Set yourself as Do Not Disturb await phone.setPresence('dnd'); // Set yourself as available await phone.setPresence('available'); // Set with custom status text await phone.setPresence('away', 'In a meeting until 3pm'); ``` The `on_call` and `offline` statuses are managed automatically — you cannot set them manually. ## REST API You can also read and update your own presence via the REST API: ```bash # Get current presence curl https://api.dialstack.ai/v1/me/presence \ -H "Authorization: Bearer USER_TOKEN" # Update presence curl -X PUT https://api.dialstack.ai/v1/me/presence \ -H "Authorization: Bearer USER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "dnd", "status_text": "In a meeting" }' ``` --- ## WebRTC Signalling Protocol # DialStack WebRTC Signalling Protocol 1.0.0 documentation - Support: [DialStack API Support](https://docs.dialstack.ai) - Email support: [info@dialstack.ai](mailto:info@dialstack.ai) Real-time signalling protocol for DialStack WebRTC softphones. ## Overview The WebRTC signalling protocol enables softphone applications (web, mobile, desktop) to make and receive phone calls through DialStack. It handles call setup, media negotiation, call control, and presence — all over a single WebSocket connection. ## Connection Flow 1. Obtain a user token via `POST /v1/user_sessions` (see [Authentication](/guides/authentication#user-tokens)) 2. Open a WebSocket connection to `wss://api.dialstack.ai/v1/webrtc` 3. Send an `authenticate` message with the user token 4. Receive an `authenticated` message confirming the session 5. The connection is now ready for calls and presence ## Authentication The WebSocket connection itself does not use HTTP headers for authentication. Instead, the first message sent after connecting must be an `authenticate` message containing the user token. The server responds with `authenticated` on success or `error` on failure, closing the connection. ## Mid-session token refresh The user token has an expiry (`exp`). The server enforces it for the whole lifetime of the connection, not just at the handshake: when the token expires, the server closes the connection with a fatal `auth_expired` error. A long-lived WebSocket does **not** outlive its token. To keep the connection (and any in-progress calls) alive across an expiry, the client refreshes the token in-band **before** `exp` — mint a fresh token from `POST /v1/user_sessions` and send it as an `auth.refresh` message. The server re-verifies it and, on success, resets the expiry timer to the new token's `exp` and replies `auth.refreshed`. The WebSocket stays open; there is no reconnect and no call disruption. - The refreshed token **must resolve to the same user and account** as the original `authenticate`. A token for a different identity is rejected with a non-fatal `auth_failed`; the connection and its original expiry timer are unchanged (a refresh swaps the credential, it never switches identity). - An invalid or already-expired refresh token is rejected with a non-fatal `auth_failed` / `auth_expired`; the connection stays open and the **original** expiry timer keeps running — a failed refresh never extends the session. - If the client never refreshes in time, the connection is evicted with a fatal `auth_expired` at `exp`. Active calls survive the drop via the reconnection grace window (see *Reconnection* below): reconnect with a fresh token and the server replays `call.restored`. This refresh flow is independent of the keepalive ping/pong (see *Keepalive*): pings prove liveness, `auth.refresh` proves current authorization. ## Subprotocol negotiation The client MUST request the `dialstack.webrtc.v1` subprotocol on the HTTP upgrade via `Sec-WebSocket-Protocol`. A server that doesn't speak the offered version rejects the upgrade. Browser SDK: ```js new WebSocket('wss://api.dialstack.ai/v1/webrtc', ['dialstack.webrtc.v1']); ``` When v2 ships, clients that want to remain backward-compatible can offer both: `['dialstack.webrtc.v2', 'dialstack.webrtc.v1']` and the server picks the highest one it supports. ## Keepalive Liveness is handled at the WebSocket protocol layer. The server sends a native WebSocket ping control frame every 30 seconds; the client's WS library responds with the matching pong transparently — no application message is required. If the server doesn't get a pong within ~10 seconds, it closes the connection with `idle_timeout`. ## Reconnection If the WebSocket connection drops: 1. Wait with exponential backoff (1s, 2s, 4s, 8s, max 30s) 2. Reconnect and re-authenticate 3. Active calls survive brief disconnections (up to 30 seconds). The server holds call state and media continues flowing. On reconnection, the server sends `call.restored` for each active call so the client can rebuild its UI. ## Message Format All messages are JSON objects with a `type` field identifying the message kind. Messages from the client include a `type` and message-specific fields. Messages from the server include a `type` and message-specific fields. ## Call Lifecycle ### Outbound Call ``` Client Server │ call.create (with sdp) ───> │ │ <────────────── call.trying │ │ <──────────── sdp.pranswer │ (network early media; zero or more) │ <────────────── call.ringing │ (if the callee is alerting — independent │ │ of early media; either order) │ ice.candidate ───────────> │ (multiple, as they are gathered) │ ice.done ────────────────> │ │ <─────────────── sdp.answer │ (final answer, when the call is answered) │ <──────────── call.answered │ │ ... call in progress ... │ call.hangup ─────────────> │ │ <────────────── call.ended │ ``` The browser generates the SDP offer (via `RTCPeerConnection.createOffer()`) and sends it inside `call.create`. The browser cannot render any incoming audio until it applies an answer, so the server forwards SDP in two steps: - `sdp.pranswer` — a **provisional answer** carrying *network early media* (carrier ringback, announcements, "the number you dialed…"). Apply it with `setRemoteDescription({ type: 'pranswer', sdp })` so audio plays before the call is answered. Only sent when the network supplies early media, and the server may send it **more than once** (each early-media update) — apply each one as it arrives. - `sdp.answer` — the **final answer**, sent when the call is answered. Apply it with `setRemoteDescription({ type: 'answer', sdp })`. It *replaces* any provisional answer and may legitimately differ from it, so always apply it. `call.ringing` is emitted only when the callee's device is actually alerting, and is independent of early media — a call may have early media without ringing (e.g. an auto-attendant) or ringing without early media. ### Inbound Call ``` Client Server │ <──────────── call.incoming │ │ <─────────────── sdp.offer │ │ call.answer ─────────────> │ │ sdp.answer ──────────────> │ │ ice.candidate ───────────> │ (multiple, as they are gathered) │ ice.done ────────────────> │ │ <──────────── call.answered │ │ ... call in progress ... │ <────────────── call.ended │ ``` ## Related Documentation - [REST API Reference](/api) — User sessions, ICE servers, User Profile endpoints - [WebRTC Guide](/webrtc) — Integration guide with code examples - [Mobile & Push Notifications](/webrtc/mobile) — Mobile incoming call notifications - [Download AsyncAPI Spec](/api/asyncapi-webrtc.yaml) — Raw YAML specification ## Table of Contents - [Servers](#servers) - [dialstack](#dialstack-server) - [Operations](#operations) - [SEND Authenticate](#send-authenticate-operation) - [RECEIVE Authenticated](#receive-authenticated-operation) - [SEND Auth Refresh](#send-auth-refresh-operation) - [RECEIVE Auth Refreshed](#receive-auth-refreshed-operation) - [SEND Ping](#send-ping-operation) - [RECEIVE Pong](#receive-pong-operation) - [RECEIVE Error](#receive-error-operation) - [RECEIVE Network Changed](#receive-network-changed-operation) - [SEND Call Create](#send-call-create-operation) - [RECEIVE Call Trying](#receive-call-trying-operation) - [RECEIVE Call Ringing](#receive-call-ringing-operation) - [RECEIVE Call Incoming](#receive-call-incoming-operation) - [SEND Call Answer](#send-call-answer-operation) - [SEND Call Reject](#send-call-reject-operation) - [RECEIVE Call Answered](#receive-call-answered-operation) - [SEND Call Hangup](#send-call-hangup-operation) - [RECEIVE Call Ended](#receive-call-ended-operation) - [RECEIVE Call Restored](#receive-call-restored-operation) - [SEND Sdp Offer](#send-sdp-offer-operation) - [RECEIVE Sdp Offer](#receive-sdp-offer-operation) - [SEND Sdp Answer](#send-sdp-answer-operation) - [RECEIVE Sdp Provisional Answer](#receive-sdp-provisional-answer-operation) - [RECEIVE Sdp Answer](#receive-sdp-answer-operation) - [SEND Ice Candidate](#send-ice-candidate-operation) - [SEND Ice Done](#send-ice-done-operation) - [SEND Call Hold](#send-call-hold-operation) - [SEND Call Resume](#send-call-resume-operation) - [RECEIVE Call Held](#receive-call-held-operation) - [RECEIVE Call Resumed](#receive-call-resumed-operation) - [SEND Call Mute](#send-call-mute-operation) - [SEND Call Unmute](#send-call-unmute-operation) - [SEND Call Transfer](#send-call-transfer-operation) - [SEND Call Transfer Attended](#send-call-transfer-attended-operation) - [SEND Presence Subscribe](#send-presence-subscribe-operation) - [RECEIVE Presence List](#receive-presence-list-operation) - [RECEIVE Presence Update](#receive-presence-update-operation) ## Servers ### `dialstack` Server - URL: `wss://api.dialstack.ai/v1/webrtc` - Protocol: `wss` DialStack WebRTC signalling endpoint. Connect with a standard WebSocket client: ```javascript const ws = new WebSocket('wss://api.dialstack.ai/v1/webrtc'); ``` ## Operations ### SEND Authenticate Operation *Authenticate connection* - Operation ID: `sendAuthenticate` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Must be the first message sent after the WebSocket connection is established. The server responds with `authenticated` on success or `error` on failure. If authentication fails, the server closes the connection. #### Message Authenticate `authenticate` *First message after connection — authenticates the user* - Message ID: `authenticate` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Authenticates the WebSocket connection with a user token. | - | - | **additional properties are allowed** | | type | string | - | const (`"authenticate"`) | - | **required** | | req\_id | string | Optional client-chosen request correlation token, recommended on every client→server frame. The server echoes it on exactly one frame — the direct reply to the request: `authenticated` for this frame, `call.trying` for call-creating frames (`call.create` and the `consult` step of `call.transfer.attended`), or `error` on failure. State events caused by a request (`call.held`, `call.ended`, …) never carry a `req_id` — they can also occur for reasons other than your request. | - | - | - | | token | string | User token obtained from `POST /v1/user_sessions` | examples (`"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`) | - | **required** | | emergency\_address\_id | string | Optional. The emergency-address resource id (from `POST /v1/me/emergency-addresses`) this softphone uses for E911. The server evaluates and binds the address during this authentication handshake only. Creating or selecting an address while the socket is already authenticated does not change the live session; reconnect and present its id in the next `authenticate` message. When present and the connection's network matches the address, the server uses it to route emergency calls with a dispatchable location. If the network no longer matches, the server replies (in addition to `authenticated`) with a `network.changed` message and the address is not used until re-confirmed. Emergency calls (911/933) are never blocked, with or without an address. | examples (`"emerg_01h2xcejqtf2nbrexx3vqjhp42"`) | - | - | > Examples of payload *(generated)* ```json { "type": "authenticate", "req_id": "string", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "emergency_address_id": "emerg_01h2xcejqtf2nbrexx3vqjhp42" } ``` ### RECEIVE Authenticated Operation *Authentication confirmed* - Operation ID: `receiveAuthenticated` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Confirms successful authentication. Contains session metadata and any active calls that were preserved during a reconnection. #### Message Authenticated `authenticated` *Server confirms authentication and returns session info* - Message ID: `authenticated` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Confirms successful authentication. Includes session and connection metadata. | - | - | **additional properties are allowed** | | type | string | - | const (`"authenticated"`) | - | **required** | | req\_id | string | Echoes the `req_id` from the originating `authenticate` frame, when present. | - | - | - | | user\_id | string | Authenticated user identifier | examples (`"user_01h2xcejqtf2nbrexx3vqjhp42"`) | - | **required** | | account\_id | string | Account identifier | examples (`"acct_01h2xcejqtf2nbrexx3vqjhp41"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "authenticated", "req_id": "string", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41" } ``` ### SEND Auth Refresh Operation *Refresh the session token in-band* - Operation ID: `sendAuthRefresh` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Sent on an already-authenticated connection to present a fresh user token shortly before the current token's expiry. The refreshed token must resolve to the same user and account as the original `authenticate`. On success the server resets the connection's expiry timer and replies with `auth.refreshed`; the WebSocket stays open and in-progress calls are unaffected. On failure (missing/invalid/expired token, or an identity that doesn't match the session) the server replies with a non-fatal `error` (`auth_failed` or `auth_expired`) and leaves the connection — and its original expiry timer — untouched. #### Message Refresh Token `auth.refresh` *Present a fresh token before the current one expires* - Message ID: `authRefresh` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Presents a fresh user token on an already-authenticated connection so the server can reset the session's expiry timer without dropping the WebSocket. The token must resolve to the same user and account as the original `authenticate`. | - | - | **additional properties are allowed** | | type | string | - | const (`"auth.refresh"`) | - | **required** | | req\_id | string | Optional client-chosen correlation token. The server echoes it on the `auth.refreshed` reply (or on the `error` reply if the refresh is rejected). | - | - | - | | token | string | A fresh user token obtained from `POST /v1/user_sessions` | examples (`"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "auth.refresh", "req_id": "string", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` ### RECEIVE Auth Refreshed Operation *Token refresh accepted* - Operation ID: `receiveAuthRefreshed` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Confirms the refreshed token was accepted and the expiry timer was reset to the new token's expiry. Echoes the `req_id` from the originating `auth.refresh`. #### Message Token Refreshed `auth.refreshed` *Server accepted the refreshed token and reset the expiry timer* - Message ID: `authRefreshed` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Confirms a successful token refresh. The connection's expiry timer has been reset to the new token's expiry; the WebSocket stays open. | - | - | **additional properties are allowed** | | type | string | - | const (`"auth.refreshed"`) | - | **required** | | req\_id | string | Echoes the `req_id` from the originating `auth.refresh` frame, when present. | - | - | - | > Examples of payload *(generated)* ```json { "type": "auth.refreshed", "req_id": "string" } ``` ### SEND Ping Operation *Probe that the connection is still alive* - Operation ID: `sendPing` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. An application-layer liveness probe. A browser cannot send WebSocket control-frame pings from JavaScript, so a client that suspects the connection may have silently died while the tab was backgrounded sends this data frame on resume and expects a `pong`. Unlike `auth.refresh`, it has no side effect — in particular it does not reset the session expiry timer. If no `pong` arrives promptly the client treats the connection as dead and reconnects. #### Message Ping `ping` *Application-layer liveness probe* - Message ID: `ping` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | An application-layer liveness probe sent on an already-authenticated connection. The server replies with `pong` and takes no other action — it does not reset the session expiry timer. | - | - | **additional properties are allowed** | | type | string | - | const (`"ping"`) | - | **required** | | req\_id | string | Optional client-chosen correlation token, echoed on the `pong` reply. | - | - | - | > Examples of payload *(generated)* ```json { "type": "ping", "req_id": "string" } ``` ### RECEIVE Pong Operation *Liveness confirmed* - Operation ID: `receivePong` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Reply to a `ping`, echoing its `req_id`. Its arrival is proof the connection is alive. #### Message Pong `pong` *Server reply confirming the connection is alive* - Message ID: `pong` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Reply to a `ping`. Its arrival confirms the connection is alive. | - | - | **additional properties are allowed** | | type | string | - | const (`"pong"`) | - | **required** | | req\_id | string | Echoes the `req_id` from the originating `ping` frame, when present. | - | - | - | > Examples of payload *(generated)* ```json { "type": "pong", "req_id": "string" } ``` ### RECEIVE Error Operation *Error notification* - Operation ID: `receiveError` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Sent when an error occurs. The `code` field identifies the error type. Some errors are fatal (the server closes the connection after sending); others are informational (the connection remains open). #### Message Error `error` *Protocol or call error* - Message ID: `error` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Error notification. Fatal errors close the connection after sending. | - | - | **additional properties are allowed** | | type | string | - | const (`"error"`) | - | **required** | | req\_id | string | Echoes the `req_id` of the originating client frame when the error is in response to one. Absent on server-initiated errors (e.g. `going_away`, `session_limit`, `idle_timeout`). | - | - | - | | code | string | Machine-readable error code | allowed (`"auth_failed"`, `"auth_expired"`, `"invalid_message"`, `"call_failed"`, `"call_not_found"`, `"emergency_address_required"`, `"session_limit"`, `"session_replaced"`, `"session_revoked"`, `"rate_limited"`, `"internal_error"`, `"going_away"`, `"idle_timeout"`, `"slow_consumer"`, `"presence_unavailable"`, `"reachability_unavailable"`) | - | **required** | | message | string | Human-readable error description | examples (`"Invalid or expired user token"`) | - | **required** | | fatal | boolean | If `true`, the server will close the connection after this message | - | - | - | | context | object | Optional per-concern detail; which fields are present depends on `code`. Omitted when the error has no structured detail. | - | - | **additional properties are allowed** | | context.users | array\ | For `presence_unavailable`: the requested users whose presence could not be established. They are absent from the snapshot. The client may retry these. For `reachability_unavailable`: users who ARE in the snapshot and whose status is live, but for whom we can no longer tell whether a device is connected — so they will not be reported `offline` while that lasts. Re-subscribing re-attempts it. | - | - | - | | context.users (single item) | string | - | - | - | - | > Examples of payload *(generated)* ```json { "type": "error", "req_id": "string", "code": "auth_failed", "message": "Invalid or expired user token", "fatal": false, "context": { "users": [ "string" ] } } ``` ### RECEIVE Network Changed Operation *Emergency-address network advisory* - Operation ID: `receiveNetworkChanged` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Sent after `authenticated` when the emergency address presented on the handshake does not apply on the current network (E911). The session is unaffected for emergency calls; the app should prompt the user to confirm or register an address valid for this network before non-emergency calls. #### Message Network Changed `network.changed` \*Advisory that the bound emergency address no longer applies on the current network (E911). The session stays usable and emergency calls still go out; non-emergency PSTN is gated until an address valid here is confirmed. \* - Message ID: `networkChanged` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Advisory that the bound emergency address no longer applies on the current network (E911). Carries no fields beyond the discriminator. | - | - | **additional properties are allowed** | | type | string | - | const (`"network.changed"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "network.changed" } ``` ### SEND Call Create Operation *Initiate outbound call* - Operation ID: `sendCallCreate` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Dials the specified destination. The client's SDP offer (from `RTCPeerConnection.createOffer()`) is carried in the `sdp` field. The server responds with `call.trying`, then `sdp.answer` when the call is answered. Send ICE candidates from client to server as they are gathered to establish the media path. #### Message Create Call `call.create` *Initiate an outbound call* - Message ID: `callCreate` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Initiates an outbound call. The `destination` can be a phone number (E.164 format) or an extension dial code. The client generates a WebRTC SDP offer (`RTCPeerConnection.createOffer()`) and passes it in `sdp`. The server replies with `sdp.answer` when the call is answered. Send candidates as they are gathered — see `sendIceCandidate`. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.create"`) | - | **required** | | req\_id | string | Optional request correlation token. Echoed on this frame's direct reply — `call.trying` on success or `error` on failure — so the client can bind its provisional call object to the server-assigned `call_id`. | examples (`"req_a1b2c3d4"`) | - | - | | destination | string | Phone number in E.164 format (e.g., `+14155551234`) or an extension dial code (e.g., `105`). | examples (`"+14155551234"`, `"105"`) | - | **required** | | sdp | string | WebRTC SDP offer string from `RTCPeerConnection.createOffer()`. The server negotiates media with the destination and returns the result as `sdp.answer`. | - | - | **required** | | caller\_id | string \| null | Caller ID number to display (E.164 format). Must be a phone number assigned to the account. If omitted, the account's default outbound caller ID is used. | examples (`"+14155559876"`) | - | - | > Examples of payload *(generated)* ```json { "type": "call.create", "req_id": "req_a1b2c3d4", "destination": "+14155551234", "sdp": "string", "caller_id": "+14155559876" } ``` ### RECEIVE Call Trying Operation *Outbound call is being processed* - Operation ID: `receiveCallTrying` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Confirms the server is processing the call request. The `call_id` in this message should be used for all subsequent messages about this call. #### Message Call Trying `call.trying` *Server is processing the outbound call request* - Message ID: `callTrying` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Server is processing the outbound call. Contains the server-assigned `call_id` to use for all subsequent messages about this call. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.trying"`) | - | **required** | | call\_id | string | Server-assigned call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | req\_id | string \| null | Echoes the `req_id` of the frame that created this call (`call.create` or the `consult` step of `call.transfer.attended`). `call.trying` is that frame's direct reply. | - | - | - | > Examples of payload *(generated)* ```json { "type": "call.trying", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "req_id": "string" } ``` ### RECEIVE Call Ringing Operation *Remote party is ringing* - Operation ID: `receiveCallRinging` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. The destination phone is ringing. Play a ringback tone to the user. #### Message Call Ringing `call.ringing` *Remote party's phone is ringing* - Message ID: `callRinging` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | The remote party's phone is ringing. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.ringing"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.ringing", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### RECEIVE Call Incoming Operation *Incoming call notification* - Operation ID: `receiveCallIncoming` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. An inbound call is arriving for this user. The message includes caller ID information and an `sdp.offer` follows immediately. The client should alert the user and, when they answer, respond with `call.answer` and `sdp.answer`. The ring duration is controlled by the upstream caller (their PBX, the Find Me / Follow Me chain, or the dial plan node that routed to this user). When the upstream gives up, the server sends `call.ended` with reason `no-answer`. There is no server-side cap on ring time — clients should not assume a specific timeout. #### Message Incoming Call `call.incoming` *An inbound call is arriving for this user* - Message ID: `callIncoming` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | An inbound call is arriving. An `sdp.offer` message follows immediately with the remote session description. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.incoming"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | from | string | Caller's phone number (E.164 format) | examples (`"+14155551234"`) | - | **required** | | from\_name | string \| null | Caller's display name (from caller ID, if available) | examples (`"John Smith"`) | - | - | | to | string | Called number or extension | examples (`"+14155559876"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.incoming", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "from": "+14155551234", "from_name": "John Smith", "to": "+14155559876" } ``` ### SEND Call Answer Operation *Answer incoming call* - Operation ID: `sendCallAnswer` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Accepts an incoming call. Must be followed by `sdp.answer` with the local session description. #### Message Answer Call `call.answer` *Answer an incoming call* - Message ID: `callAnswer` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Accepts an incoming call. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.answer"`) | - | **required** | | call\_id | string | Call identifier from the `call.incoming` message | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.answer", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### SEND Call Reject Operation *Reject incoming call* - Operation ID: `sendCallReject` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Rejects an incoming call on this device. The call may continue ringing on the user's other devices. #### Message Reject Call `call.reject` *Reject an incoming call* - Message ID: `callReject` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Rejects an incoming call on this device. The call may continue ringing on the user's other devices. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.reject"`) | - | **required** | | call\_id | string | Call identifier from the `call.incoming` message | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | reason | string | Rejection reason: - `busy` — signal busy to the caller - `decline` — silently decline (no busy signal) | default (`"decline"`), allowed (`"busy"`, `"decline"`) | - | - | > Examples of payload *(generated)* ```json { "type": "call.reject", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "reason": "busy" } ``` ### RECEIVE Call Answered Operation *Call connected* - Operation ID: `receiveCallAnswered` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. The call has been answered and media is flowing. For outbound calls, this means the remote party picked up. For inbound calls, this confirms the answer was processed. #### Message Call Answered `call.answered` *Call has been answered (media is flowing)* - Message ID: `callAnswered` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | The call has been answered and media is flowing. For outbound calls, this means the remote party picked up. For inbound calls, this confirms the client's `call.answer` was processed. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.answered"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | answered\_at | string | When the call was answered (ISO 8601) | - | format (`date-time`) | - | > Examples of payload *(generated)* ```json { "type": "call.answered", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "answered_at": "2019-08-24T14:15:22Z" } ``` ### SEND Call Hangup Operation *End a call* - Operation ID: `sendCallHangup` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Ends an active or ringing call. The server confirms with `call.ended`. #### Message Hang Up `call.hangup` *End an active or ringing call* - Message ID: `callHangup` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Ends an active or ringing call. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.hangup"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.hangup", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### RECEIVE Call Ended Operation *Call ended* - Operation ID: `receiveCallEnded` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. The call has ended. The `reason` field indicates why (hangup, no-answer, busy, failed, transferred). After receiving this message, clean up any media resources for this call. #### Message Call Ended `call.ended` *Call has ended* - Message ID: `callEnded` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | The call has ended. Clean up media resources (close `RTCPeerConnection`). | - | - | **additional properties are allowed** | | type | string | - | const (`"call.ended"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | reason | string | Why the call ended: - `hangup` — either party hung up - `no-answer` — timed out without answer - `busy` — destination returned busy - `failed` — call setup failed (network error, invalid destination) - `transferred` — call was transferred to another destination - `rejected` — the remote party rejected the call | allowed (`"hangup"`, `"no-answer"`, `"busy"`, `"failed"`, `"transferred"`, `"rejected"`) | - | **required** | | duration\_seconds | integer \| null | Call duration in seconds (null if never answered) | - | - | - | > Examples of payload *(generated)* ```json { "type": "call.ended", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "reason": "hangup", "duration_seconds": 0 } ``` ### RECEIVE Call Restored Operation *Active call restored after reconnection* - Operation ID: `receiveCallRestored` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Sent after `authenticated` when reconnecting with active calls. The client should rebuild its UI for each restored call. An `sdp.offer` follows for re-establishing the media path. #### Message Call Restored `call.restored` *Active call restored after reconnection* - Message ID: `callRestored` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Sent after `authenticated` when reconnecting with an active call that was preserved during the disconnection (up to 30 seconds). An `sdp.offer` follows for re-establishing the media path. The client should close any existing `RTCPeerConnection` for this call and create a new one. Then handle the `sdp.offer` normally (set remote description, create answer, exchange ICE candidates). | - | - | **additional properties are allowed** | | type | string | - | const (`"call.restored"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | state | string | Current state of the restored call | allowed (`"ringing"`, `"active"`, `"held"`) | - | **required** | | from | string | Caller's phone number | - | - | **required** | | from\_name | string \| null | Caller's display name | - | - | - | | to | string | Called number or extension | - | - | **required** | | direction | string | - | allowed (`"inbound"`, `"outbound"`) | - | - | | answered\_at | string \| null | When the call was answered (null if still ringing) | - | format (`date-time`) | - | > Examples of payload *(generated)* ```json { "type": "call.restored", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "state": "ringing", "from": "string", "from_name": "string", "to": "string", "direction": "inbound", "answered_at": "2019-08-24T14:15:22Z" } ``` ### SEND Sdp Offer Operation *Send SDP offer* - Operation ID: `sendSdpOffer` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Send a local session description offer. Used during call setup and mid-call renegotiation (e.g., codec change, hold with sendonly). #### Message SDP Offer `sdp.offer` *SDP offer for media negotiation* - Message ID: `sdpOffer` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | SDP offer for media negotiation. Sent by the server for inbound calls and outbound call setup. Sent by the client during renegotiation. | - | - | **additional properties are allowed** | | type | string | - | const (`"sdp.offer"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | sdp | string | SDP (Session Description Protocol) offer string. Pass this to `RTCPeerConnection.setRemoteDescription()` as an offer. | - | - | **required** | > Examples of payload *(generated)* ```json { "type": "sdp.offer", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "sdp": "string" } ``` ### RECEIVE Sdp Offer Operation *Receive SDP offer* - Operation ID: `receiveSdpOffer` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Server sends an SDP offer for the client to answer. This happens for inbound calls (the browser is the called party), for the consultation leg of an attended transfer (the consult target is effectively a new inbound call to the transferring user), for `call.restored` (re-establishing media after a WS reconnect), and for any mid-call renegotiation initiated by the server. Outbound call setup uses the reverse direction: the client sends its offer inside `call.create` and receives `sdp.answer`. #### Message SDP Offer `sdp.offer` *SDP offer for media negotiation* - Message ID: `sdpOffer` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | SDP offer for media negotiation. Sent by the server for inbound calls and outbound call setup. Sent by the client during renegotiation. | - | - | **additional properties are allowed** | | type | string | - | const (`"sdp.offer"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | sdp | string | SDP (Session Description Protocol) offer string. Pass this to `RTCPeerConnection.setRemoteDescription()` as an offer. | - | - | **required** | > Examples of payload *(generated)* ```json { "type": "sdp.offer", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "sdp": "string" } ``` ### SEND Sdp Answer Operation *Send SDP answer* - Operation ID: `sendSdpAnswer` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Respond to an SDP offer with a local session description answer. #### Message SDP Answer `sdp.answer` *SDP answer for media negotiation* - Message ID: `sdpAnswer` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | SDP answer for media negotiation. Sent by the client in response to an `sdp.offer`. The SDP should be obtained from `RTCPeerConnection.createAnswer()`. | - | - | **additional properties are allowed** | | type | string | - | const (`"sdp.answer"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | sdp | string | SDP answer string from `RTCPeerConnection.createAnswer()`. Pass the received offer to `setRemoteDescription()` first, then create and send the answer. | - | - | **required** | > Examples of payload *(generated)* ```json { "type": "sdp.answer", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "sdp": "string" } ``` ### RECEIVE Sdp Provisional Answer Operation *Receive SDP provisional answer (network early media)* - Operation ID: `receiveSdpProvisionalAnswer` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Before the call is answered, the server may send a provisional answer carrying network early media (e.g. carrier ringback or an announcement), and may send more than one as the early media changes. Apply each with `setRemoteDescription({ type: 'pranswer', sdp })` so audio plays during the ringing phase. The final `sdp.answer` replaces the last provisional when the call is answered. #### Message SDP Provisional Answer `sdp.pranswer` *Provisional answer carrying network early media (before the call is answered)* - Message ID: `sdpProvisionalAnswer` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | SDP provisional answer carrying network early media, sent by the server during outbound call setup before the call is answered, when the network supplies early media. Apply it with `setRemoteDescription({ type: 'pranswer', sdp })` so early media (carrier ringback, announcements) plays during ringing. The server may send more than one as the early media changes; apply each. The final `sdp.answer` replaces the last provisional when the call is answered and may legitimately differ (e.g. early media is send-only, the answered call is send/receive). | - | - | **additional properties are allowed** | | type | string | - | const (`"sdp.pranswer"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | sdp | string | SDP provisional-answer string. Pass to `RTCPeerConnection.setRemoteDescription()` with `type: 'pranswer'`. | - | - | **required** | > Examples of payload *(generated)* ```json { "type": "sdp.pranswer", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "sdp": "string" } ``` ### RECEIVE Sdp Answer Operation *Receive SDP answer* - Operation ID: `receiveSdpAnswer` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Server responds to the client's SDP offer with the final answer. If a provisional answer (`sdp.pranswer`) was sent earlier, this replaces it and may legitimately differ — always apply it. #### Message SDP Answer `sdp.answer` *SDP answer for media negotiation* - Message ID: `sdpAnswer` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | SDP answer for media negotiation. Sent by the client in response to an `sdp.offer`. The SDP should be obtained from `RTCPeerConnection.createAnswer()`. | - | - | **additional properties are allowed** | | type | string | - | const (`"sdp.answer"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | sdp | string | SDP answer string from `RTCPeerConnection.createAnswer()`. Pass the received offer to `setRemoteDescription()` first, then create and send the answer. | - | - | **required** | > Examples of payload *(generated)* ```json { "type": "sdp.answer", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "sdp": "string" } ``` ### SEND Ice Candidate Operation *Send ICE candidate* - Operation ID: `sendIceCandidate` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Send a locally gathered ICE candidate. Send each one as it is discovered; the server conveys them to the far end as they arrive. Your offer does not need to carry any candidates. A candidate-less offer is answered with a full ICE block, so you can send `call.create` as soon as `setLocalDescription` resolves and trickle everything. What your offer does need is `a=ice-options:trickle`, which is how you state that more candidates are coming. Without it nothing is conveyed and the call negotiates on the offer alone. Browsers include it by default, so this is normally automatic. Delivery is best effort. A candidate may be dropped, which costs a possible network path rather than the call. #### Message ICE Candidate `ice.candidate` *ICE candidate for connectivity checks* - Message ID: `iceCandidate` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | An ICE candidate gathered after the offer was sent, conveyed to the far end for connectivity checks. | - | - | **additional properties are allowed** | | type | string | - | const (`"ice.candidate"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | candidate | string | ICE candidate string from the `RTCIceCandidate.candidate` property. | examples (`"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 192.168.1.100 rport 44323"`) | - | **required** | | sdp\_mid | string \| null | Media stream identification tag | examples (`"0"`) | - | - | | sdp\_m\_line\_index | integer \| null | Index of the media description in the SDP | - | - | - | > Examples of payload *(generated)* ```json { "type": "ice.candidate", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "candidate": "candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 192.168.1.100 rport 44323", "sdp_mid": "0", "sdp_m_line_index": 0 } ``` ### SEND Ice Done Operation *ICE gathering complete* - Operation ID: `sendIceDone` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. All local ICE candidates have been gathered; none will follow. Worth sending even when no candidates followed the offer: it lets the far end stop expecting more and conclude ICE, rather than waiting out a timeout. #### Message ICE Done `ice.done` *All ICE candidates have been gathered* - Message ID: `iceDone` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | All ICE candidates have been gathered. No more `ice.candidate` messages will be sent for this call from this side. | - | - | **additional properties are allowed** | | type | string | - | const (`"ice.done"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "ice.done", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### SEND Call Hold Operation *Hold call* - Operation ID: `sendCallHold` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Places the call on hold. The server confirms with `call.held` and plays hold music to the remote party. #### Message Hold Call `call.hold` *Place an active call on hold* - Message ID: `callHold` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Places the specified call on hold. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.hold"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.hold", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### SEND Call Resume Operation *Resume call* - Operation ID: `sendCallResume` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Resumes a held call. The server confirms with `call.resumed`. #### Message Resume Call `call.resume` *Resume a held call* - Message ID: `callResume` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Resumes a held call. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.resume"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.resume", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### RECEIVE Call Held Operation *Call placed on hold* - Operation ID: `receiveCallHeld` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Confirms the call was placed on hold. The `held_by` field indicates whether the local user or the remote party initiated the hold. #### Message Call Held `call.held` *Call was placed on hold (by local or remote party)* - Message ID: `callHeld` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Confirms the call was placed on hold. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.held"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | held\_by | string | Who initiated the hold: - `local` — this user placed the call on hold - `remote` — the remote party placed the call on hold | allowed (`"local"`, `"remote"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.held", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "held_by": "local" } ``` ### RECEIVE Call Resumed Operation *Call resumed* - Operation ID: `receiveCallResumed` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Confirms a held call was resumed. Media resumes flowing. #### Message Call Resumed `call.resumed` *Held call was resumed* - Message ID: `callResumed` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Confirms a held call was resumed. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.resumed"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.resumed", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### SEND Call Mute Operation *Server-side mute* - Operation ID: `sendCallMute` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Mutes the user's audio at the server. The remote party hears silence. This is in addition to any client-side mute (disabling the microphone track). Server-side mute is authoritative — even if the client sends audio, it is not forwarded. #### Message Mute `call.mute` *Mute the microphone (server-side)* - Message ID: `callMute` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Mutes the user's audio at the server. The remote party hears silence regardless of whether the client is sending audio. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.mute"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.mute", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### SEND Call Unmute Operation *Server-side unmute* - Operation ID: `sendCallUnmute` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Unmutes the user's audio at the server. #### Message Unmute `call.unmute` *Unmute the microphone (server-side)* - Message ID: `callUnmute` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Unmutes the user's audio at the server. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.unmute"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.unmute", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45" } ``` ### SEND Call Transfer Operation *Blind transfer* - Operation ID: `sendCallTransfer` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Transfers the call to another destination without consulting the transfer target first. The call is immediately redirected and this client's participation ends. The server sends `call.ended` with reason `transferred`. #### Message Blind Transfer `call.transfer` *Transfer the call to another destination* - Message ID: `callTransfer` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Blind transfer — immediately redirects the call to another destination. The current client's participation ends after the transfer. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.transfer"`) | - | **required** | | call\_id | string | Call identifier | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | destination | string | Transfer destination (E.164 number or extension) | examples (`"105"`, `"+14155551234"`) | - | **required** | > Examples of payload *(generated)* ```json { "type": "call.transfer", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "destination": "105" } ``` ### SEND Call Transfer Attended Operation *Attended transfer* - Operation ID: `sendCallTransferAttended` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Performs an attended (consultative) transfer in two steps: **Step 1 — Consult** (`step: "consult"`): Send with a `destination` and an `sdp` offer (the consultation leg reuses the outbound call flow, so the client supplies its offer here exactly as it does in `call.create`). The server: 1. Places the original call on hold (sends `call.held` with `held_by: "local"`) 2. Initiates a new call to the transfer target using the supplied offer 3. Sends `call.trying` with a **new `call_id`** for the consultation call 4. Sends `sdp.answer` for the consultation call's media 5. Normal call setup follows (`call.ringing`, `call.answered`) The client now has two calls: the original (held) and the consultation (active). **Step 2 — Complete** (`step: "complete"`): Send to bridge the original caller with the transfer target. The server: 1. Sends `call.ended` with reason `transferred` for the **original** call 2. Sends `call.ended` with reason `transferred` for the **consultation** call 3. The original caller and transfer target are connected directly **Cancelling:** Send `call.hangup` for the **consultation** call. The server ends the consultation call and resumes the original call automatically. **Failure:** If the consultation call fails (busy, no-answer), the server ends the consultation call and resumes the original call automatically. #### Message Attended Transfer `call.transfer.attended` *Start or complete an attended (consultative) transfer* - Message ID: `callTransferAttended` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Attended (consultative) transfer. Send with step "consult" to dial the target, then "complete" to bridge the calls. | - | - | **additional properties are allowed** | | type | string | - | const (`"call.transfer.attended"`) | - | **required** | | call\_id | string | For `consult`: the call identifier of the original call to be transferred. For `complete`: the call identifier of the original call. | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | step | string | Transfer step: - `consult` — hold the current call and dial the transfer target - `complete` — connect the original caller to the transfer target | allowed (`"consult"`, `"complete"`) | - | **required** | | destination | string | Transfer destination (required for `consult` step, ignored for `complete`). E.164 number or extension. | examples (`"105"`) | - | - | | sdp | string | SDP offer for the consultation leg (required for `consult` step, ignored for `complete`). The consultation reuses the outbound call flow, so the client supplies its offer here as it does in `call.create`; the server replies with `sdp.answer`. | - | - | - | | req\_id | string | Optional request correlation token. The `consult` step is a call-creating frame, so its direct reply is the consultation's `call.trying`, which echoes this value — exactly like `call.create`. On failure, the `error` frame echoes it instead. | examples (`"req_lx3k9f_2"`) | - | - | > Examples of payload *(generated)* ```json { "type": "call.transfer.attended", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "step": "consult", "destination": "105", "sdp": "string", "req_id": "req_lx3k9f_2" } ``` ### SEND Presence Subscribe Operation *Subscribe to presence updates* - Operation ID: `sendPresenceSubscribe` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Subscribes to real-time presence updates for users in the account. The server responds with `presence.list` containing the current status of all users, then sends `presence.update` messages as statuses change. #### Message Subscribe to Presence `presence.subscribe` *Subscribe to presence updates for users in the account* - Message ID: `presenceSubscribe` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Subscribe to presence updates for an explicit set of users. The server responds with `presence.list` (a snapshot of the requested users), then sends `presence.update` messages as their statuses change. `users` is required and bounded — an empty or over-limit list is rejected with an `error` frame (`invalid_message`). There is no whole-account default. | - | - | **additional properties are allowed** | | type | string | - | const (`"presence.subscribe"`) | - | **required** | | req\_id | string | Optional client-supplied correlation id. When present, it is echoed on the `presence.list` reply so the client can match the snapshot to this request. | - | - | - | | users | array\ | The users to watch — an explicit, bounded list (max 100). Required. An empty or over-limit list is rejected with an `invalid_message` error rather than defaulting to the whole account. | - | \[ 1 .. 100 ] items | **required** | | users (single item) | string | - | - | - | - | > Examples of payload *(generated)* ```json { "type": "presence.subscribe", "req_id": "string", "users": [ "string" ] } ``` ### RECEIVE Presence List Operation *Presence snapshot* - Operation ID: `receivePresenceList` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. Sent in response to `presence.subscribe`. Contains the current presence status of the subscribed users. Use this to populate the initial state of a buddy list or BLF panel. #### Message Presence Snapshot `presence.list` *Initial presence snapshot after subscribing* - Message ID: `presenceList` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Presence snapshot of all subscribed users. Sent in response to `presence.subscribe`. | - | - | **additional properties are allowed** | | type | string | - | const (`"presence.list"`) | - | **required** | | req\_id | string | Echoes the `req_id` of the originating `presence.subscribe` frame, when present. | - | - | - | | users | array\ | Current presence status for each subscribed user | - | - | **required** | | users.user\_id | string | User identifier | examples (`"user_01h2xcejqtf2nbrexx3vqjhp42"`) | - | **required** | | users.name | string | User's display name | examples (`"Jane Doe"`) | - | **required** | | users.status | string | The user's current status in the snapshot. `on_call` = the user is on a call; `available` = the user can take a call; `offline` = the user has no device connected, so they cannot be reached. A requested user whose presence could not be looked up is left out of the snapshot and reported in a non-fatal `presence_unavailable` error, which the client may retry — that is different from `offline`, which is a real answer. A user named in a non-fatal `reachability_unavailable` error is in the snapshot and their status is real, but `offline` will not be reported for them until that watch recovers. `dnd` and `away` are reserved and not sent. | allowed (`"available"`, `"on_call"`, `"dnd"`, `"away"`, `"offline"`) | - | **required** | | users.do\_not\_disturb | boolean | Whether the user has server-side Do Not Disturb enabled. A separate axis from `status`: a user can be `available` but decline calls because DND is on. The two answer different questions — DND is configuration that persists until someone changes it, while `status` is a lossy summary of currently observed signals, reduced to one value by the precedence `on_call` > `available` > `offline`. Keeping them apart is a product decision, not a technical necessity. Matches the `do_not_disturb` field on the REST presence resource. DND is never reported as a `status` value. | - | - | **required** | | users.status\_text | string \| null | Optional custom status message | - | - | - | | users.updated\_at | string | - | - | format (`date-time`) | - | > Examples of payload *(generated)* ```json { "type": "presence.list", "req_id": "string", "users": [ { "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "name": "Jane Doe", "status": "available", "do_not_disturb": true, "status_text": "string", "updated_at": "2019-08-24T14:15:22Z" } ] } ``` ### RECEIVE Presence Update Operation *Presence changed* - Operation ID: `receivePresenceUpdate` Bidirectional signalling channel for WebRTC call setup, control, and presence. A single WebSocket connection handles all calls for the authenticated user, including multiple simultaneous calls. A user's presence status has changed (e.g., went on a call, set DND, came online). Update the UI for the affected user. #### Message Presence Update `presence.update` *A user's presence status has changed* - Message ID: `presenceUpdate` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | A user's presence status has changed. | - | - | **additional properties are allowed** | | type | string | - | const (`"presence.update"`) | - | **required** | | user\_id | string | User whose presence changed | examples (`"user_01h2xcejqtf2nbrexx3vqjhp42"`) | - | **required** | | status | string | The user's new status. `on_call` = the user is on a call; `available` = the user can take a call; `offline` = the user has no device connected, so they cannot be reached. `dnd` and `away` are reserved and not sent. | allowed (`"available"`, `"on_call"`, `"dnd"`, `"away"`, `"offline"`) | - | **required** | | do\_not\_disturb | boolean | Whether the user has server-side Do Not Disturb enabled. A separate axis from `status`: a user can be `available` but decline calls because DND is on. The two answer different questions — DND is configuration that persists until someone changes it, while `status` is a lossy summary of currently observed signals, reduced to one value by the precedence `on_call` > `available` > `offline`. Keeping them apart is a product decision, not a technical necessity. Matches the `do_not_disturb` field on the REST presence resource. DND is never reported as a `status` value. | - | - | **required** | | status\_text | string \| null | Optional custom status message | - | - | - | | updated\_at | string | - | - | format (`date-time`) | - | > Examples of payload *(generated)* ```json { "type": "presence.update", "user_id": "user_01h2xcejqtf2nbrexx3vqjhp42", "status": "available", "do_not_disturb": true, "status_text": "string", "updated_at": "2019-08-24T14:15:22Z" } ``` --- ## WebRTC Client SDK The WebRTC Client SDK provides a high-level interface for building softphone applications. It handles WebSocket signalling, WebRTC media, call management, and presence — so you can focus on your UI. ## Installation ```bash npm install @dialstack/sdk-webrtc ``` ## DialStackPhone The main entry point. Manages the WebSocket connection, calls, and presence for a single user. ### Constructor ```typescript import { DialStackPhone } from '@dialstack/sdk-webrtc'; const phone = new DialStackPhone(options: PhoneOptions); ``` #### PhoneOptions | Property | Type | Required | Description | | --------------------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `token` | string | Yes | User token from `POST /v1/user_sessions` | | `onTokenExpiring` | () => Promise\ | No | Called ~60s before token expiry. Return a fresh token; the SDK applies it in-band over the open connection (no reconnect). | | `autoReconnect` | boolean | No | Reconnect automatically on disconnect (default: `true`) | | `iceServers` | RTCIceServer[] | No | Override ICE server configuration (default: fetched from API) | | `audioInputDeviceId` | string | No | Microphone `deviceId` for new calls; defaults to the OS default. Not persisted — see [Audio Devices](#audio-devices). | | `audioOutputDeviceId` | string | No | Speaker `deviceId` for the ringback tone. Does not route call audio — see [Audio Devices](#audio-devices). | ### Connection ```typescript // Connect to the signalling server await phone.connect(): Promise; // Disconnect (ends all active calls) phone.disconnect(): void; // Connection state phone.isConnected: boolean; ``` ### Making Calls ```typescript // Dial a phone number or extension const call = await phone.call( destination: string, options?: CallOptions ): Promise; ``` #### CallOptions | Property | Type | Description | | ---------- | ------ | ------------------------------------------------------------ | | `callerId` | string | Outbound caller ID (E.164). Must be assigned to the account. | ### Active Calls ```typescript // All active calls (ringing, active, held) phone.activeCalls: Call[]; // Find a call by ID phone.getCall(callId: string): Call | undefined; ``` ### Presence ```typescript // Subscribe to presence updates phone.subscribePresence(userIds?: string[]): void; // Set your own presence await phone.setPresence( status: 'available' | 'dnd' | 'away', statusText?: string ): Promise; ``` ### Emergency Address ```typescript // Validate, save, and select an emergency address for the next connection phone.setEmergencyAddress(address: EmergencyAddressRequest): Promise; // Select an address and re-run authentication so it applies to a live phone phone.reconnectWithEmergency(emergencyAddressId: string): Promise; // Get current emergency address await phone.getEmergencyAddress(): Promise; ``` #### EmergencyAddressRequest | Property | Type | Required | Description | | --------- | -------------- | -------- | ------------------------------------ | | `street` | string | Yes | Street address | | `street2` | string \| null | No | Suite, floor, apartment, room number | | `city` | string | Yes | City | | `state` | string | Yes | Two-letter state/province code | | `zip` | string | Yes | ZIP/postal code | | `country` | string | Yes | Two-letter country code (ISO 3166-1) | ### Audio Devices Select which microphone is captured and which speaker the ringback tone plays through. Both apply to calls already in progress, not just new ones. ```typescript // `labelsAvailable` is false until the browser has granted microphone access — before // that it reports how many devices exist but withholds their names. const { inputs, outputs, labelsAvailable, supported } = await phone.listAudioDevices(); // Pick a microphone. Pass null for the OS default. await phone.setAudioInputDevice(inputs[0].deviceId); phone.audioInputDeviceId; // string | null // Record a saved choice at startup without acquiring the device (no permission prompt). phone.seedAudioInputDevice(savedId); // Pick a speaker for the ringback tone. phone.setAudioOutputDevice(outputs[0].deviceId); phone.audioOutputDeviceId; // string | null ``` `setAudioInputDevice` rejects without changing the selection when the device can't be acquired. Check `error.code` for `mic_permission_denied` (re-promptable), `audio_device_unavailable` (offer a different device), or `audio_device_in_use` (another application holds the microphone — a different device won't help). **Routing call audio is the host's job.** The SDK exposes the remote audio as `call.remoteMediaStream`; whoever attaches it to a media element is the only code that can route it: ```typescript const el = document.querySelector('audio'); el.srcObject = call.remoteMediaStream; // Feature-detect: TypeScript types setSinkId, but iOS Safari has no implementation. // A rejection leaves the element on its previous sink, so audio keeps playing — never // gate play() on this. if (typeof el.setSinkId === 'function') { await el.setSinkId(deviceId).catch(() => undefined); } ``` The React `` does all of this for you, including the device picker. #### AudioDevice | Property | Type | Description | | ----------- | ------- | -------------------------------------------------------- | | `deviceId` | string | Opaque id to pass to `setAudioInputDevice` / `setSinkId` | | `label` | string | Name, or `''` when the browser withholds it | | `groupId` | string | Shared by the input and output halves of one device | | `isDefault` | boolean | True for the host's default entry | Selections are not persisted — the phone holds them for its lifetime, so save the choice yourself and pass it back via `PhoneOptions`. Device ids are origin-scoped and rotate, so an id that no longer resolves falls back to the default device rather than failing the call. ### Events | Event | Payload | Description | | --------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------- | | `connected` | — | WebSocket connected and authenticated | | `disconnected` | — | WebSocket disconnected | | `reconnected` | — | Reconnected after a drop | | `incoming` | `Call` | Incoming call | | `presenceList` | `PresenceEntry[]` | Initial presence snapshot | | `presenceUpdate` | `PresenceUpdate` | User presence changed | | `network.changed` | — | Client IP address changed (prompt for E911) | | `audioDevicesChanged` | `AudioDeviceList` | A device was plugged/unplugged, or names became readable after permission was granted. Carries the fresh list. | | `error` | `PhoneError` | Connection or protocol error | ```typescript phone.on(event: string, handler: Function): void; phone.off(event: string, handler?: Function): void; ``` ## Call Represents a single phone call. Obtained from `phone.call()` or the `incoming` event. ### Properties | Property | Type | Description | | ----------- | ---------------------------------------------------------------- | ----------------------------------------------- | | `id` | string | Call identifier | | `direction` | `'inbound'` \| `'outbound'` | Call direction | | `state` | `'trying'` \| `'ringing'` \| `'active'` \| `'held'` \| `'ended'` | Current call state | | `from` | string | Caller's number (E.164) | | `fromName` | string \| null | Caller's display name | | `to` | string | Called number or extension | | `isMuted` | boolean | Whether the microphone is muted | | `isHeld` | boolean | Whether the call is on hold | | `duration` | number | Call duration in seconds (updates in real-time) | ### Methods ```typescript // Answer an incoming call call.answer(): void; // Reject an incoming call call.reject(reason?: 'busy' | 'decline'): void; // Hang up call.hangup(): void; // Hold / resume call.hold(): void; call.resume(): void; // Mute / unmute call.mute(): void; call.unmute(): void; // DTMF (sent inline with the audio stream, RFC 4733) call.sendDtmf(digits: string, duration?: number, interToneGap?: number): void; call.canSendDtmf: boolean; // false where no DTMF sender exists (e.g. React Native) // Blind transfer call.transfer(destination: string): void; // Attended transfer (returns the consultation call) call.attendedTransfer(destination: string): Promise; // Complete an attended transfer call.completeTransfer(): void; // Access the underlying RTCPeerConnection (for stats, custom media handling) call.peerConnection: RTCPeerConnection; ``` ### Events | Event | Payload | Description | | ---------- | ----------------------- | --------------------------------------------------------------------------------------- | | `trying` | — | Server is processing the call | | `ringing` | — | Remote party is ringing | | `answered` | — | Call connected | | `held` | `'local'` \| `'remote'` | Call placed on hold | | `resumed` | — | Call resumed from hold | | `ended` | `string` | Call ended (reason: `hangup`, `no-answer`, `busy`, `failed`, `transferred`, `rejected`) | ```typescript call.on(event: string, handler: Function): void; call.off(event: string, handler?: Function): void; ``` ## Types ### PresenceEntry ```typescript interface PresenceEntry { userId: string; name: string; status: 'available' | 'on_call' | 'offline'; /** A separate axis from `status`: persistent configuration, not an observed state. */ doNotDisturb: boolean; statusText: string | null; updatedAt: string; } ``` ### PresenceUpdate ```typescript interface PresenceUpdate { userId: string; status: 'available' | 'on_call' | 'offline'; /** A separate axis from `status`: persistent configuration, not an observed state. */ doNotDisturb: boolean; statusText: string | null; updatedAt: string; } ``` ### PhoneError ```typescript interface PhoneError { code: | 'auth_failed' | 'auth_expired' | 'invalid_message' | 'call_failed' | 'call_not_found' | 'emergency_address_required' | 'session_limit' | 'session_revoked' | 'rate_limited' | 'mic_permission_denied' | 'audio_device_unavailable' | 'audio_device_in_use' | 'internal_error'; message: string; callId?: string; fatal: boolean; } ``` ## Server SDK Additions The following resources are added to the server SDK for WebRTC-related backend operations: ### userSessions Mints a short-lived JWT session token for a single DialStack user. The returned `client_secret` is the Bearer token your frontend uses on the signalling WebSocket and `/v1/me/*` REST routes. ```typescript // Create a user session token const { client_secret, expires_at } = await dialstack.userSessions.create({ user: 'user_01h2xcejqtf2nbrexx3vqjhp42', // ttl_seconds: 3600, // optional; defaults to 24 hours, max 7 days }); ``` The user must already be provisioned via `POST /v1/users` and belong to an account owned by the calling platform. To extend a session, simply call `userSessions.create` again — there is no refresh endpoint; expiry is enforced by the token's own `exp` claim. To cut a user off before their tokens expire (offboarding, a compromised device), revoke all of their sessions at once: ```typescript // Invalidate every outstanding session token for a user const { sessions_revoked_at } = await dialstack.users.revokeSessions( 'user_01h2xcejqtf2nbrexx3vqjhp42' ); ``` Revoked tokens stop working immediately on REST routes and at connection time; an already-connected phone is disconnected with a fatal `session_revoked` error the next time it places or receives a call, and the SDK will not auto-reconnect. Minting a new session afterwards restores service. ### webrtc ```typescript // Get ICE server configuration const { ice_servers, expires_at } = await dialstack.webrtc.iceServers({ token: userToken, }); ``` ## Related Resources - [WebRTC Overview](/webrtc) — Integration guide with complete code examples - [Signalling Protocol](./protocol.md) — Low-level WebSocket message reference - [Server SDK](/sdks/server) — Server-side SDK for account and user management --- ## WebSocket API # DialStack Media WebSocket Protocol 2.0.0 documentation - Support: [DialStack API Support](https://docs.dialstack.ai) - Email support: [info@dialstack.ai](mailto:info@dialstack.ai) Real-time audio streaming protocol over WebSocket. ## Overview DialStack connects to a platform-provided WSS URL, sends a `begin` handshake, exchanges `audio` frames, and sends an `end` message before closing. The same wire protocol is used for every session; the enabled features depend on which REST API created it. ## Features ### Outbound audio Audio from DialStack to the platform is always present. Each `audio` message carries a timestamp (milliseconds from session start) and a base64-encoded μ-law payload. ### Inbound audio (optional) The platform may send `audio` messages back to DialStack to be played to the caller. When this is not enabled, DialStack does not read from the WebSocket. ### Channel tagging (optional) Outbound `audio` messages may carry a `channel` field (`caller` or `callee`) when both sides of the call are streamed on the same connection. When the field is absent, the stream is a single mixed channel. ### Session identity The `begin` message carries exactly one of `voice_app_id` or `listener_id`, identifying the resource that created the session. Consumers can branch on which field is present to select per-resource behavior. ### Graceful end Before closing the WebSocket, DialStack sends an `end` message with a `reason` (`call_ended`, `deleted`, `error`) so the consumer can distinguish normal termination from errors. ## Connection Flow 1. Platform provides a WSS URL when creating the session 2. DialStack connects and sends `begin` with session metadata and audio format 3. Audio flows via `audio` messages 4. DialStack sends `end` with a reason 5. DialStack closes the WebSocket ## Audio Format - Encoding: μ-law (G.711) - Sample rate: 8000 Hz - Channels: 1 (mono) - Chunk size: ~20ms (160 bytes before base64 encoding) - Bandwidth: ~8 KB/second per channel ## Connection Behavior - DialStack initiates the TLS WebSocket connection - Ping/pong keepalive every 30 seconds - If the connection drops during an active call, DialStack attempts reconnection (up to 3 times) - Closing the WebSocket stops the audio session; the effect on the underlying call depends on the session type ## Related Documentation - [REST API Reference](/api) — endpoints that create sessions - [Download AsyncAPI Spec](/api/asyncapi.yaml) — raw YAML specification ## Table of Contents - [Servers](#servers) - [platform](#platform-server) - [Operations](#operations) - [SEND Begin](#send-begin-operation) - [SEND Audio](#send-audio-operation) - [RECEIVE Audio](#receive-audio-operation) - [SEND End](#send-end-operation) ## Servers ### `platform` Server - URL: `wss://ai.platform.example.com/voice` - Protocol: `wss` Platform-provided WebSocket URL, supplied when the session is created via the REST API. DialStack connects to this URL and streams audio. ## Operations ### SEND Begin Operation *Session start notification* - Operation ID: `sendBegin` Media stream channel. DialStack connects to the platform's WebSocket URL and exchanges JSON messages for session control and audio data. Outbound audio is always streamed; inbound audio is accepted when the session was created with that feature enabled. Sent by DialStack immediately after the WebSocket connection is established. Contains session metadata (exactly one of `voice_app_id` or `listener_id`), optional pass-through `metadata`, and the audio format specification. #### Message Session Begin `begin` *First message after WebSocket connection, containing session metadata* - Message ID: `begin` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Session start message with call metadata and audio format. Exactly one of `voice_app_id` or `listener_id` is present. | - | - | **additional properties are allowed** | | event | string | Event type identifier | const (`"begin"`) | - | **required** | | call\_id | string | Unique identifier for this call | examples (`"call_01h2xcejqtf2nbrexx3vqjhp45"`) | - | **required** | | account\_id | string | Account identifier | examples (`"acct_01h2xcejqtf2nbrexx3vqjhp41"`) | - | **required** | | audio\_format | object | Audio encoding specification | - | - | **required**, **additional properties are allowed** | | audio\_format.encoding | string | Audio encoding (μ-law G.711) | const (`"audio/x-mulaw"`) | - | **required** | | audio\_format.sample\_rate | integer | Sample rate in Hz | const (`8000`) | - | **required** | | audio\_format.channels | integer | Number of audio channels (mono) | const (`1`) | - | **required** | | voice\_app\_id | string | Session identifier. Mutually exclusive with `listener_id` — exactly one of the two is present on every `begin`. | examples (`"voiceapp_01h2xcejqtf2nbrexx3vqjhp42"`) | - | - | | listener\_id | string | Session identifier. Mutually exclusive with `voice_app_id` — exactly one of the two is present on every `begin`. | examples (`"lstn_01h2xcejqtf2nbrexx3vqjhp50"`) | - | - | > Examples of payload *(generated)* ```json { "event": "begin", "call_id": "call_01h2xcejqtf2nbrexx3vqjhp45", "account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41", "audio_format": { "encoding": "audio/x-mulaw", "sample_rate": 8000, "channels": 1 }, "voice_app_id": "voiceapp_01h2xcejqtf2nbrexx3vqjhp42", "listener_id": "lstn_01h2xcejqtf2nbrexx3vqjhp50" } ``` ### SEND Audio Operation *Audio to platform* - Operation ID: `sendAudio` Media stream channel. DialStack connects to the platform's WebSocket URL and exchanges JSON messages for session control and audio data. Outbound audio is always streamed; inbound audio is accepted when the session was created with that feature enabled. Audio sent by DialStack to the platform. Each message carries a timestamp (milliseconds from session start). When multi-leg audio is streamed on the same connection, messages also carry a `channel` tag (`caller` or `callee`); otherwise the tag is omitted. #### Message Audio from DialStack `audio` \*Audio sent from DialStack to the platform. May carry a per-chunk `channel` tag when multi-leg audio is streamed on the same connection. \* - Message ID: `audioFromDialStack` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Audio from DialStack to the platform. Carries an optional `channel` tag when multi-leg audio is streamed on the same connection. | - | - | **additional properties are allowed** | | event | string | Event type identifier | const (`"audio"`) | - | **required** | | timestamp | integer | Milliseconds from session start | examples (`0`, `20`, `40`) | - | **required** | | channel | string | Which party's audio this chunk contains. Present only when multi-leg audio is streamed on the same connection; otherwise omitted (the stream is a single mixed channel). | allowed (`"caller"`, `"callee"`) | - | - | | payload | string | Base64-encoded μ-law audio data (~160 bytes per 20ms chunk) | - | format (`byte`) | **required** | > Examples of payload *(generated)* ```json { "event": "audio", "timestamp": 0, "channel": "caller", "payload": "string" } ``` ### RECEIVE Audio Operation *Audio to caller* - Operation ID: `receiveAudio` Media stream channel. DialStack connects to the platform's WebSocket URL and exchanges JSON messages for session control and audio data. Outbound audio is always streamed; inbound audio is accepted when the session was created with that feature enabled. Audio to play to the caller, sent by the platform to DialStack. Accepted only when the session was created with inbound audio enabled; otherwise DialStack does not read from the WebSocket. #### Message Audio to DialStack `audio` \*Audio to play to the caller, sent from the platform to DialStack. Accepted only when the session was created with inbound audio enabled. \* - Message ID: `audioToDialStack` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Audio data to play to caller (Platform → DialStack). Accepted only when the session was created with inbound audio enabled. | - | - | **additional properties are allowed** | | event | string | Event type identifier | const (`"audio"`) | - | **required** | | payload | string | Base64-encoded μ-law audio data | - | format (`byte`) | **required** | > Examples of payload *(generated)* ```json { "event": "audio", "payload": "string" } ``` ### SEND End Operation *Session end* - Operation ID: `sendEnd` Media stream channel. DialStack connects to the platform's WebSocket URL and exchanges JSON messages for session control and audio data. Outbound audio is always streamed; inbound audio is accepted when the session was created with that feature enabled. Sent by DialStack before closing the WebSocket. The `reason` field distinguishes a normal hangup from an explicit deletion or an error. #### Message Session End `end` *Sent before DialStack closes the WebSocket, with a reason* - Message ID: `end` - Content type: [application/json](https://www.iana.org/assignments/media-types/application/json) ##### Payload | Name | Type | Description | Value | Constraints | Notes | |---|---|---|---|---|---| | (root) | object | Sent by DialStack before closing the WebSocket. | - | - | **additional properties are allowed** | | event | string | Event type identifier | const (`"end"`) | - | **required** | | reason | string | Why the session ended: - `call_ended`: the call was hung up - `deleted`: the session was closed via the API - `error`: an internal error occurred | allowed (`"call_ended"`, `"deleted"`, `"error"`) | - | **required** | > Examples of payload *(generated)* ```json { "event": "end", "reason": "call_ended" } ```