@ruut/voice-sdk
v1.0.1
Published
Universal TypeScript SDK for Ruut Voice REST, TwiML, webhooks, and browser SIP/WebRTC calling.
Readme
@ruut/voice-sdk
Universal TypeScript SDK for Ruut Voice — programmable voice, SMS, and SIP.
- Server: REST client for calls, messages, phone numbers, recordings, and agent extensions
- TwiML: Builder for
Say,Play,Gather,Dial,Conference,Connect,Stream,Record,Enqueue, and more - Browser: WebRTC softphone via SIP.js — make and receive calls, transfer, DTMF, hold
- Webhooks: HMAC-SHA1 signing, validation, and parsing
- Express: Ready-to-use middleware for webhook verification
npm install @ruut/voice-sdkNode.js ≥ 18. Browser calling needs WebRTC, microphone permission, and a wss:// SIP endpoint.
Quick Start
Make a call
import { RuutVoice, VoiceResponse } from "@ruut/voice-sdk";
const client = new RuutVoice({
accountSid: process.env.RUUT_ACCOUNT_SID!,
authToken: process.env.RUUT_AUTH_TOKEN!,
baseUrl: "https://voice.example.com",
});
const call = await client.calls.create({
to: "+2348000000001",
from: "+2348000000002",
twiml: new VoiceResponse().say("Hello!").toXml(),
statusCallback: "https://app.example.com/webhooks/call-status",
});Receive calls in the browser
import { RuutVoice } from "@ruut/voice-sdk";
import { RuutDevice } from "@ruut/voice-sdk/browser";
const client = new RuutVoice({ accountSid, authToken, baseUrl });
const agent = await client.agentExtensions.credentials("AE001");
// → { id, username, password, domain, ws_url, extension,
// restBaseUrl, restAccountSid, restAuthToken }
const device = new RuutDevice({ credentials: agent });
device.on("incoming", (call) => {
call.accept();
});
await device.start();
// SIP registered → auto-reports online via REST APIServer: REST API Client
Calls
client.calls.create({ to, from, url?, twiml?, method?, statusCallback?, idempotencyKey? })
client.calls.createAgentCall({ agentUsername, sipDomain, from, twiml? })
client.calls.createSipCall({ sipUri, from, url?, twiml? })
client.calls.get(callSid)
client.calls.list({ to?, from?, status?, startTime?, page?, pageSize? })
client.calls.update(callSid, { status?, url?, twiml?, method? })
client.calls.cancel(callSid)
client.calls.complete(callSid)
client.calls.redirect(callSid, { url } | { twiml })
client.calls.hold(callSid, musicUrl)
client.calls.resume(callSid, { url } | { twiml })
client.calls.transfer(callSid, target, { callerId?, timeout? })
client.calls.moveToConference(callSid, conferenceName)
client.calls.waitFor(callSid, { timeoutMs?, pollIntervalMs?, statuses?, signal? })Messages (SMS)
client.messages.create({ to, from, body, mediaUrl?, statusCallback? })
client.messages.get(messageSid)
client.messages.list({ to?, from?, dateSent?, page?, pageSize? })Phone Numbers
client.incomingPhoneNumbers.create({ phoneNumber, friendlyName?, voiceUrl?, smsUrl?, ... })
client.incomingPhoneNumbers.get(phoneNumberSid)
client.incomingPhoneNumbers.list({ phoneNumber?, friendlyName?, page?, pageSize? })
client.incomingPhoneNumbers.update(phoneNumberSid, { friendlyName?, voiceUrl?, smsUrl?, ... })
client.incomingPhoneNumbers.remove(phoneNumberSid)
client.availablePhoneNumbers.list({ countryCode, type: "Local"|"TollFree"|"Mobile", areaCode?, ... })
client.availablePhoneNumberCountries.list()
client.availablePhoneNumberCountries.get(countryCode)Agent Extensions (SIP identities)
// List
client.agentExtensions.list()
// Create (permanent)
client.agentExtensions.create({ agentName, extension?, ttlSeconds? })
// Create + fetch credentials in one call
client.agentExtensions.provision({ agentName, extension?, ttlSeconds? })
// → { id, username, password, domain, ws_url, extension, expires_at? }
// Get credentials for existing extension
client.agentExtensions.credentials("AE001")
// Update presence or extend TTL
client.agentExtensions.update("AE001", { status: "online", ttlSeconds: 3600 })
// Extend ephemeral TTL
client.agentExtensions.refresh("AE001", 3600)Recordings
client.recordings.list({ callSid?, page?, pageSize? })
client.recordings.get(recordingSid, { callSid? })
client.recordings.mediaUrl(recordingSid, { callSid?, format? })Error handling
import { RuutApiError, RuutTimeoutError, RuutValidationError } from "@ruut/voice-sdk";
try {
await client.calls.get("CA123");
} catch (err) {
if (err instanceof RuutApiError) {
console.log(`API ${err.status}: ${err.message} (requestId: ${err.requestId})`);
}
}TwiML Builder
import { VoiceResponse } from "@ruut/voice-sdk";
// Simple IVR
new VoiceResponse()
.say("Welcome!", { voice: "alice", language: "en-US" })
.gather({ numDigits: 1, action: "/menu" }, (g) => {
g.say("Press 1 for sales, 2 for support.");
})
.toXml();
// Dial with recording
new VoiceResponse()
.dial({ callerId: "+123", record: "record-from-answer" }, (d) => {
d.number("+456", { statusCallback: "/webhooks/status" });
})
.toXml();
// SIP dial with auth
new VoiceResponse()
.dial({}, (d) => {
d.sip("sip:[email protected]", { username: "agent", password: "secret" });
})
.toXml();
// Conference
new VoiceResponse()
.dial({}, (d) => {
d.conference("weekly-standup", { startConferenceOnEnter: true, record: "record-from-start" });
})
.toXml();
// AI/Transcription stream
new VoiceResponse()
.connect((c) => {
c.stream({ url: "wss://ai.example.com/media", track: "both_tracks" })
.parameter("tenant", "AC123");
})
.toXml();
// Record voicemail
new VoiceResponse()
.say("Leave a message after the beep.")
.record({ maxLength: 60, finishOnKey: "#", playBeep: true, transcribe: true })
.toXml();
// Call queue
new VoiceResponse()
.enqueue("support", { waitUrl: "/queue/music" })
.toXml();
// Reject
new VoiceResponse().reject({ reason: "busy" }).toXml();Full verb support: Say, Play, Pause, Hangup, Leave, Reject, Redirect, Record, Enqueue, Gather, Dial (with Number, Sip, Client, Conference), Connect (with Stream + Parameter).
Browser: WebRTC Softphone
Device setup
import { RuutDevice } from "@ruut/voice-sdk/browser";
const device = new RuutDevice({
credentials: {
username: "agent001",
password: "...",
domain: "sip.example.com",
wsUrl: "wss://sip.example.com:8443",
// Optional: auto-presence reads these to report online/offline
agentExtensionId: "AE001",
restBaseUrl: "https://voice.example.com",
restAccountSid: "AC123",
restAuthToken: "secret",
},
audioElement: document.getElementById("remote-audio") as HTMLAudioElement,
localVideoElement: document.getElementById("local-video") as HTMLVideoElement,
remoteVideoElement: document.getElementById("remote-video") as HTMLVideoElement,
iceServers: [{ urls: "turn:turn.example.com", username: "user", credential: "pass" }],
displayName: "Support Agent",
// autoPresence defaults to true — device auto-reports online/offline via REST
});
await device.start(); // WebSocket connect + SIP REGISTERDevice events
device.on("stateChanged", (state, prev) => { /* new → registered → ... */ });
device.on("registered", () => { /* SIP registered, presence auto-reported */ });
device.on("unregistered", () => { /* SIP unregistered */ });
device.on("incoming", (call) => { /* answer or reject */ });
device.on("callStarted", (call) => { /* outgoing call created */ });
device.on("callEnded", (call) => { /* call ended */ });
device.on("transportConnected", () => { /* WebSocket connected */ });
device.on("transportDisconnected", (error?) => { /* WebSocket dropped */ });
device.on("error", (error) => { /* device error */ });Make a call
const call = await device.connect("+2348000000001");
// or: device.connect("1002") — extension number
// or: device.connect("sip:agent@domain")
await call.disconnect();Answer a call
device.on("incoming", (call) => {
call.accept(); // audio only
call.accept({ video: true }); // audio + video
call.reject(); // decline
call.ignore(); // stop ringing, no reject
});Call control
call.mute(); // mute microphone
call.unmute();
await call.hold(); // put on hold
await call.unhold();
await call.sendDigits("123#"); // DTMF, comma = 500ms pause
await call.transfer("sip:agent@domain");Attended transfer
const consultation = await device.connect("agent-1002");
// ... talk to agent-1002 ...
await call.attendedTransfer(consultation);Call events
call.on("stateChanged", (state, prev) => { /* pending → ringing → connecting → open → held → closed */ });
call.on("mute", (muted) => {});
call.on("hold", (held) => {});
call.on("digits", (digits) => {});
call.on("transfer", (target) => {});
call.on("error", (error) => {});WebRTC stats
const stats = await call.getStats();
// RTCStatsReport with bitrate, packet loss, jitter, codec, etc.Device lifecycle
device.state // "new" | "connecting" | "registered" | "unregistered" | "reconnecting" | "destroyed" | "error"
device.calls // active call list
device.isBusy // has active calls
await device.register(); // manual re-register
await device.unregister(); // manual unregister
await device.disconnectAll(); // hang up all calls
await device.destroy(); // cleanup, unregister, disconnect WebSocket, auto-report offlineEphemeral Agents
For temporary SIP identities (e.g., website visitors clicking "Call Support"):
// Provision a temporary SIP identity — auto-destroyed after TTL + call ends
const agent = await client.agentExtensions.provision({
agentName: "visitor-session-abc",
ttlSeconds: 3600, // 1 hour
});
// Pass directly to device — autoPresence works out of the box
const device = new RuutDevice({ credentials: agent });
await device.start();
// Extend if session stays active
await client.agentExtensions.refresh(agent.id, 3600);
// When session ends — destroy device, cleanup job handles de-provisioning
await device.destroy();
// Background job: deletes expired extensions with active_calls_count = 0No ttlSeconds = permanent agent.
Webhook Validation
Express middleware
import { ruutWebhook } from "@ruut/voice-sdk/express";
import { parseWebhook, isTerminalWebhook } from "@ruut/voice-sdk/webhooks";
app.post("/webhooks/call-status",
ruutWebhook({ authToken: process.env.RUUT_AUTH_TOKEN! }),
(req, res) => {
const payload = req.ruutWebhook!; // typed WebhookPayload
if (isTerminalWebhook(payload)) {
console.log(`Call ${payload.CallSid} ended: ${payload.CallStatus}`);
if (payload.RecordingUrl) {
console.log(`Recording: ${payload.RecordingUrl}`);
}
}
// Return TwiML to continue the call
res.type("text/xml").send(
new VoiceResponse().say("Thank you.").toXml()
);
}
);Manual validation
import { signWebhook, validateWebhookSignature, parseWebhook } from "@ruut/voice-sdk/webhooks";
// Sign (generate a signature)
const sig = await signWebhook(url, params, authToken);
// Validate (constant-time comparison)
const valid = await validateWebhookSignature({ url, params, signature, authToken });
// Parse
const payload = parseWebhook(rawBody); // string | URLSearchParams | Record
// Check terminal
isTerminalWebhook(payload);
// true for: completed, busy, failed, no-answer, canceledModule Exports
| Import | Contains |
|--------|----------|
| @ruut/voice-sdk | Everything: client, TwiML, webhooks, express, types |
| @ruut/voice-sdk/server | RuutVoice, CallsResource, RecordingsResource, MessagesResource, AgentExtensionsResource, phone number resources |
| @ruut/voice-sdk/browser | RuutDevice, RuutCall, TypedEventEmitter |
| @ruut/voice-sdk/twiml | VoiceResponse, Dial, Gather, Connect, Stream, twiml() factory |
| @ruut/voice-sdk/webhooks | signWebhook, validateWebhookSignature, parseWebhook, isTerminalWebhook |
| @ruut/voice-sdk/express | ruutWebhook middleware, RuutWebhookMiddlewareOptions |
| @ruut/voice-sdk/messages | MessagesResource, message types |
Requirements
- Server: Node.js ≥ 18, Fetch API
- Browser: WebRTC, microphone permission, HTTPS + WSS in production
- STUN/TURN: Required for NAT traversal in production. Use short-lived credentials.
- Callbacks: At-least-once delivery. Deduplicate with
CallSid+ status. - Call creation: Never blindly retry POSTs after transport failure. Query state instead.
