Calls From Your App
A user of your web app can place and receive phone calls as one of your numbers. Your backend mints a short-lived voice token for the signed-in user, the browser registers with it through the @sentdm/voice SDK, and every call the user places or receives is decided by your callback, the same way an inbound call to your number is.
Prerequisites
Phone calls must be enabled for your account. This is set by Sent: contact support@sent.dm to request it.
You also need:
- At least one number with phone calls turned on. See Voice in the channels guide.
- A callback URL that answers Sent's questions about each call. See Answering Calls.
- A web app served over HTTPS, or from
localhostwhile developing. The browser hides the microphone and service worker APIs over plain HTTP.
How It Fits Together
- Your backend calls
POST /v3/channels/voice/tokenswith the user's identity and returns the token to the browser. - The browser registers with
@sentdm/voice, which fetches tokens through atokenProviderfunction you give it and refreshes them before they expire. - When the user places a call, Sent asks your callback what to do with it.
toin the app says who the user wants to reach. Your answer decides what rings. - Incoming calls reach the browser as push messages, through a service worker your app serves.
The API key stays on your server. The browser only ever holds a voice token, which is bound to one identity and expires on its own.
Mint a Voice Token
Mint tokens on your backend, for the user who is signed in:
curl -X POST "https://api.sent.dm/v3/channels/voice/tokens" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"identity": "agent-42",
"number": "+12025550123",
"ttl": 600
}'| Field | Description |
|---|---|
identity | Your identifier for the app user, for example an agent id. Letters, digits, -, and _ only, up to 200 characters. Required |
number | One of your voice numbers, in E.164 format. Calls this identity places go out from it, and your callback sees it as number on every question about them. Omit it to use your default line for app calls |
ttl | Token lifetime in seconds. Defaults to 600 and cannot exceed 3600 |
The response carries the token and what it was bound to:
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZ2VudC00MiJ9.signature",
"identity": "agent-42",
"number": "+12025550123",
"expires_at": "2026-07-22T12:34:56+00:00"
},
"error": null,
"meta": {
"request_id": "req_7X9zKp2jDw",
"timestamp": "2026-07-22T12:24:56+00:00",
"version": "v3"
}
}Hand token to the browser unchanged. Minting again for the same identity binds it to the number given in that request, so an identity can move between numbers.
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_011 | identity is missing or has characters outside letters, digits, - and _, or is longer than 200 characters |
| 400 | BUSINESS_024 | number is not one of your voice-enabled numbers |
| 400 | VALIDATION_001 | ttl is outside 1 to 3600 |
| 403 | BUSINESS_023 | Your account has no active voice number, so there is no line to place calls from |
Never call the token endpoint from the browser. It authenticates with your API key, which must not leave your server. Expose an endpoint of your own, such as /api/voice-token, that checks the user's session and returns the token as text.
Register the Browser
Install the SDK and serve the service worker it ships, which is how incoming calls reach the page:
npm install @sentdm/voice
cp node_modules/@sentdm/voice/sw.js public/sw.jsCreate one client per tab with a tokenProvider that fetches a fresh token from your backend, then register:
import SentVoice from '@sentdm/voice';
const client = new SentVoice({
tokenProvider: () => fetch('/api/voice-token').then((response) => response.text()),
});
// From a click handler: the browser asks for the notification permission here
await client.register();
const call = await client.connect({ to: '+14155551234' });
call.on('connected', () => console.log('Connected'));
call.on('disconnected', ({ state }) => console.log(`The call ended as ${state}`));The first register() asks the user to allow notifications, which the browser needs to deliver incoming calls. Firefox and Safari only show that prompt after a user gesture, so call it from a click handler.
tokenProvider is how the SDK gets every token. It is called by register() and again before each token expires: at 80% of the token's lifetime, or 30 seconds before it expires if that is earlier. tokenWillExpire fires first. A provider that throws is retried after a short backoff. If the retries run out, the client emits offline and tries again about every 30 seconds until it is registered again; calls in progress go on, and connect() throws NotRegisteredError until then.
client.on('registered', () => console.log('Ready for calls'));
client.on('tokenWillExpire', ({ expiresAt }) => console.log('Refreshing, expires', new Date(expiresAt)));
client.on('offline', (reason) => console.warn(`Offline (${reason.code}), trying again every 30 seconds`));Every method, event and error is documented on the Voice SDK page.
Place and Receive Calls
connect({ to }) calls a phone number in E.164 format or another user of your app by identity. joinConference({ name }) joins one of your account's rooms. Either way, to says who the user wants; your callback's answer decides what rings.
const call = await client.connect({ to: 'ben' });
call.on('ringing', () => console.log('Ringing'));
call.on('connected', () => call.sendDigits('1'));
call.on('disconnected', ({ state, error }) => console.log(`The call ended as ${state}`, error?.code));Incoming calls arrive as an invite the user can accept or decline:
client.on('incomingCall', async (invite) => {
console.log('Incoming call from', invite.from);
invite.on('cancelled', () => console.log('The caller hung up'));
// When the user answers
const call = await invite.accept();
call.on('disconnected', ({ state }) => console.log(`The call ended as ${state}`));
// When the user declines instead
// await invite.reject();
});A leg to a phone number costs money and runs for at most what your account's balance affords at the destination's per-minute rate, capped at four hours. Legs between app users and legs into a room cost nothing and have no such limit. Every call, app to app included, is a record under GET /v3/calls and reports through the call webhooks.
Route by Department
Bind identities to numbers when you mint tokens. Mint a sales agent's token with number set to the sales line and a support agent's token with the support line. Calls each agent places go out from that number, and every question your callback receives about those calls carries it as number.
curl -X POST "https://api.sent.dm/v3/channels/voice/tokens" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"identity": "sales-agent-7",
"number": "+12125550100"
}'An inbound call to the sales line carries the same number, so one callback can serve every department by branching on it: ring an agent of that department who is online, dial a fallback phone number when nobody is, and answer reject with busy when there is no fallback. The Department Routing recipe shows the answers.
Next Steps
Answering Calls
How your backend answers calls with the Sent API: the question Sent sends to your callback URL, the answer it expects, signature verification, deadlines and retries, cookies, and the test endpoint.
Calls, Recordings, and Participants
How to read a call record, hang up a live call, start and stop recordings and download them, and add, mute and remove conference participants with the Sent API.