Skip to main content
Zendesk A softphone in the Zendesk top bar. Agents place and take real phone calls in the browser, see who is calling before they answer, and every call is written to the ticket they are working on. Source code: vobiz-ai/Vobiz-Zendesk-Calling — the ZAF v2 app and the Node backend used throughout this guide. Both ship in the one repository.
Scope: inbound and outbound, with browser audio over WebRTC. Call logs are written through the standard Tickets API, so this works on any Zendesk Support plan rather than requiring Talk Partner Edition.

What you get

How it works

The browser carries the audio; the backend carries the credentials. Your Vobiz Auth Token never reaches a third party and is never written into a ticket.

Three app locations, three jobs

A ZAF app can run in several places at once, and each instance is a separate iframe with its own JavaScript context. They share no variables and talk only through ZAF messaging.
All three locations are required. ZAF exposes ticket.id only to an instance that is in ticket context, and the top bar is global. Without the ticket_sidebar instance the app can never resolve a ticket, so every call log creates a brand-new ticket instead of commenting on the one the agent is looking at.
Click-to-dial arrives as Zendesk’s voice.dialout event, which only reaches the background instance. That instance finds the top_bar instance and forwards the number to it over an app-internal event. voice.dialout fires only on accounts with Talk Partner Edition enabled — on other plans the manual dialpad still works.

The outbound call flow

Five-step outbound call flow: the agent clicks Call, the browser sends the SIP INVITE itself as the A leg, Vobiz fetches the answer URL of the endpoint's application, the backend answers with a Record element followed by Dial Number, and Vobiz dials the customer, bridging the legs when they answer.Five-step outbound call flow: the agent clicks Call, the browser sends the SIP INVITE itself as the A leg, Vobiz fetches the answer URL of the endpoint's application, the backend answers with a Record element followed by Dial Number, and Vobiz dials the customer, bridging the legs when they answer.
The order cannot be reversed. Originating to the customer over the REST API and then bridging the agent in with <User> is blocked platform-side: Vobiz builds a gateway URI it cannot itself parse and drops its own INVITE (tr_eval_uri(): invalid uri, blocking gw). The customer answers, hears ringback, then “the agent could not be reached”.That design is what this app used to do. It was removed in the 17 September 2026 rewrite. Inbound is the exception — <Dial><User> is the only way to reach a registered endpoint, and it works.

The answer XML

One handler serves both directions; which one it is is decided by who the call is from. A sip: URI (or RouteType=sip) means the browser dialled out.

Diagnosing from the CDR

hangup_cause_name in the CDR tells you where a call stopped:

Inbound calls

A call to one of your Vobiz numbers reaches the same /answer handler. From is a plain number rather than a sip: URI, so the handler takes the inbound branch and returns <User> naming the registered endpoint. The panel auto-answers.
callerId is mandatory on the inbound branch. Omit it and Vobiz derives it from the A leg — which on an inbound call is the caller’s number, not one this account owns — so B-leg creation is refused silently and totally, and the browser never rings. Use the DID that was actually dialled, normalised to E.164.
Inbound into a registered WebRTC endpoint was blocked platform-side for two weeks and was observed working on 17 September 2026. The XML is correct either way, so nothing needs to change here if it regresses — but treat inbound as watch rather than settled.

Requirements

Every test places a real call and bills your balance. An outbound call bills two legs — the customer’s and the agent’s.

Step 1: Create a SIP endpoint per agent

The softphone registers to Vobiz as a SIP endpoint. Each agent needs their own, or calls ring the wrong person. Create one in the console under Voice → Endpoints, or via the API:
Two things to catch here:
  1. Vobiz appends a numeric suffix to the username you asked for. Use the value it returns, not the one you sent — registration is rejected otherwise.
  2. The password is never returned again. Record it now.
Put the stored username and the password you chose into the backend’s .env as VOBIZ_SIP_USER and VOBIZ_SIP_PASSWORD.
Use a separate endpoint per integration. An endpoint binds to exactly one Vobiz application at a time, so sharing one between Zendesk and another CRM silently breaks whichever was configured first.

Step 2: Run the backend

The backend is bound to one Vobiz account through .env: VOBIZ_AUTH_ID, VOBIZ_AUTH_TOKEN, VOBIZ_FROM_NUMBER, and the VOBIZ_SIP_USER / VOBIZ_SIP_PASSWORD from Step 1. Zendesk writes need ZENDESK_SUBDOMAIN, ZENDESK_EMAIL and ZENDESK_API_TOKEN. The one value worth setting deliberately is SIGNING_SECRET:
Without it a random key is generated at each boot, and recording links written into tickets before the last restart stop resolving. RECORDING_URL_TTL_SECONDS controls how long those links stay valid, and ALLOWED_ORIGINS is a CORS allowlist rather than *.

Step 3: Expose the backend

Vobiz fetches /answer to find out what to do with the call, so that URL has to be reachable from the public internet. Put the tunnel hostname in PUBLIC_BASE and restart.
Check the answer URL before touching any UI. Send the shape an outbound call produces — a sip: caller:
Anything else — a tunnel error page, an ngrok interstitial — and every call dies silently with the symptom people blame on registration: the customer answers, hears ringback, then “the agent could not be reached”.
Quick tunnels get a new hostname on every restart, and the old one is recycled to somebody else. Fine for development; use a named tunnel or a real host for anything permanent.
If your backend sits behind an IP allowlist, see IP whitelisting.

Step 4: Run the Zendesk app

zcli.apps.config.json
The filename matters. zcli reads zcli.apps.config.json. A file named zcli.json is silently ignored, and you are prompted for the values instead.
Then open Zendesk with the apps server attached:
The softphone appears in the top bar. If nothing loads, your browser may be blocking requests to localhost:4567 — Chrome asks for Local Network Access permission, so allow it.
Zendesk top bar showing the headset icon with tooltip Vobiz Calling

The Vobiz Calling softphone in the Zendesk top navigation bar.

Step 5: Place a test call

Open the softphone from the top bar. It opens on a CONNECTING badge while the SIP stack registers.
The Vobiz Calling panel open over the Zendesk agent interface, showing an orange CONNECTING badge, a Connecting banner, and step 1 Sign in to Vobiz

The panel on load, inside Zendesk.

Enter your Vobiz Auth ID and Auth Token — from the console under API credentials, not your login password — and sign in.
Close-up of the panel showing the filled Auth ID and Auth Token fields, a Logged in as line with the calling-from number, and the Choose a caller ID step with a Calling from field

Registered and signed in with caller ID.

Wait for Ready before dialling. The Call button stays disabled until the SIP endpoint registers, precisely so you cannot dial into a dead bridge.
Pick the number to call from. Calling from lists every number on the account.
The Calling from field expanded into a dropdown of five Vobiz numbers, with the Call button below

Every provisioned number is selectable as the outbound caller ID.

Type a number in E.164 format — or click any phone number in Zendesk and the panel dials it.
The panel during a live call showing the Number to call field, a red Hang up button, an On a call with status line, and a Receive calls here section below

An active call, with the live status line.

Confirm it landed in Vobiz with GET /Call/, or read the CDR.

Inbound

A call to the attached DID raises a prompt in the panel and a Zendesk notification. Enter accepts, Escape declines.
The Vobiz Calling panel showing an incoming call prompt with Accept and Decline buttons

The inbound prompt, alongside Zendesk's own notification.

Installing permanently

zcli apps:server serves the app to your browser only, for as long as it runs. To install it for the whole account:
Upload the resulting zip under Admin Center → Apps and integrations → Zendesk Support apps → Upload private app, then fill in the settings below. You also need a stable backend_url — a quick tunnel is not good enough.
Packaging needs two files the repository does not ship: assets/logo-small.png, which Zendesk requires, and a top-bar icon at assets/icon_top_bar.svg. Add your own branding before packaging.

App settings

Set in Zendesk → Admin Center → Apps → Vobiz Calling App → Settings:

Backend routes

Full request and response shapes are in the repository’s backend contract. In summary:
GET /agent is behind the session on purpose. An earlier build served it unauthenticated and invented an agent for any unknown id, so GET /agent/anything handed a live SIP password to anyone who knew the backend URL. Read the repository’s SECURITY.md before deploying.
The backend is bound to one account. /login rejects an Auth ID that is not the one in its .env, and says so by name — the SIP endpoint it bridges to and the caller ID it dials from both belong to that account, and Vobiz rejects a from number the account does not own.

How a call reaches a ticket

There is no Zendesk “call” object available to a normal app, so calls are recorded as ordinary tickets and comments. The app resolves the ticket the agent clicked from, else whatever ticket_sidebar reports, then posts to /sync-call, which either comments on that ticket or creates a new one. If the backend write fails, the app falls back to writing through the agent’s own Zendesk session. That fallback is the better path in one respect worth knowing: it attributes the comment to the agent, whereas the backend path attributes everything to a single shared API user. Screen pop is a ZAF search from the browser — a type:user phone:… query, then routeTo the matched user. The comment itself is a structured call log, with the recording behind the signed link:
A Zendesk ticket showing the Vobiz Call Log note with direction, duration, recording link, and status

The call log as it lands on the ticket, as an internal note.

A recording link written into a ticket is readable by every agent, every export and every audit log, forever. It therefore cannot carry credentials.
The browser audio player for a call recording, showing the transport controls and elapsed time

In-browser call recording audio playback.

Links expire after RECORDING_URL_TTL_SECONDS (300 by default). Playback is signature-gated rather than session-gated, because an <audio> element cannot send an Authorization header — the signature is what stands in for the session.
The link carries no credentials and takes no caller-supplied URL. Both matter: an earlier generation of this pattern accepted a url parameter and fetched it with the account’s Vobiz credentials attached, which is a credential-exfiltration primitive any web page could drive.
Set SIGNING_SECRET explicitly. Without it a random key is generated at each boot and every link written before the last restart stops resolving. See call recording for what is retained on the platform side.

Where credentials live

The deliberate trade-off: agents type their own Vobiz credentials into the panel. That keeps one shared account token out of the app, but the credentials do pass through the backend on each request — so the backend must be trusted and reachable only over HTTPS.

Troubleshooting

Scope today

The app is a working softphone plus ticket logging. Not yet built: hold, mute, transfer and conference; routing a call to an agent’s mobile instead of the browser (the backend’s <Number> branch is ready, the UI is not); and Talk Partner Edition reporting, since logs go through the standard Tickets API. It installs as a private app rather than from the Zendesk Marketplace.

Next steps