Skip to navigation

Build a simple voicemail

Play saved messages for one caller and record messages from everyone else.
View as Markdown

This guide composes sessions, playback, recording, webhooks, and S3-backed message storage. The examples use JavaScript-like pseudocode.

Mailbox state

Call routing picks the destination number (DID). The application only needs the approved listener number and the saved message references:

const mailbox = {
listenerNumber: "+972509876543",
savedMessages: [] // S3 object keys, not temporary URLs
};

Choose the call path

The application answers the session, waits for the confirmation event, and then chooses the path from caller_id:

async function handleIncomingCall(call) {
const sessionUuid = call.session_uuid;
const answered = waitForEvent("session.answered", sessionUuid);
await post(`/v1/sessions/${sessionUuid}/answer`);
await answered;
if (call.caller_id === mailbox.listenerNumber) {
return playSavedVoicemailMessages(sessionUuid);
}
return recordMessageFromUnknownCaller(sessionUuid);
}

Phone numbers arrive in the canonical format described in Formats.

Play saved messages

Start the next message only after the previous playback has stopped. Create a fresh, time-bounded S3 URL for each message. This helper uses the pre-registered correlation pattern:

function playAndWait(sessionUuid, url) {
return waitForCommand(
operationUuid => post(
`/v1/sessions/${sessionUuid}/playback/play`,
{
type: "files",
urls: [url]
},
{ headers: { "Operation-Id": operationUuid } }
),
{
resolveOn: ["playback.stopped"],
rejectOn: ["playback.failed"]
}
);
}
async function playSavedVoicemailMessages(sessionUuid) {
if (mailbox.savedMessages.length === 0) {
await playAndWait(sessionUuid, prompts.noMessages);
return hangUp(sessionUuid);
}
for (const objectKey of mailbox.savedMessages) {
const playbackUrl = await createS3PlaybackUrl(objectKey);
await playAndWait(sessionUuid, playbackUrl);
}
await playAndWait(sessionUuid, prompts.endOfMessages);
return hangUp(sessionUuid);
}

The playback/play response is 202 Accepted with an operation_uuid. The matching webhook event is the completion signal; the HTTP response only confirms that the command was accepted.

Record a message and store it in S3

The application starts recording, stops it on a caller action or timeout, and stores the completed recording only after recording.ended. A caller hangup also stops the recording; save the message after its terminal event:

async function recordMessageFromUnknownCaller(sessionUuid) {
await playAndWait(sessionUuid, prompts.leaveMessage);
const operationUuid = crypto.randomUUID();
const recordingEnded = waitForMatchingEvent(
"recording.ended",
operationUuid
);
// Register the terminal-event listener before dispatch.
const { recording_uuid: recordingUuid } = await post(
`/v1/sessions/${sessionUuid}/recordings`,
{ direction: "both", channels: "mono" },
{ headers: { "Operation-Id": operationUuid } }
);
const stopReason = await waitForCallerStopOrTimeout(sessionUuid, 120000);
if (stopReason !== "hangup") {
await post(
`/v1/sessions/${sessionUuid}/recordings/${recordingUuid}/stop`
);
}
const recording = await recordingEnded;
const objectKey = `voicemail/${recording.recording_uuid}.wav`;
await uploadToS3(objectKey, recording.media_url);
mailbox.savedMessages.push(objectKey);
if (stopReason === "hangup") return;
await playAndWait(sessionUuid, prompts.messageSaved);
await hangUp(sessionUuid);
}

Store the S3 object key as durable state. Generate a fresh playback URL when the message is played; recording URLs expire.

Command and event rules

When a lifecycle event carries a command’s operation_uuid, register the listener before sending that UUID in the Operation-Id request header:

const result = await waitForCommand(
operationUuid => post(
commandPath,
requestBody,
{ headers: { "Operation-Id": operationUuid } }
),
{ resolveOn: [expectedEvent], rejectOn: [failureEvent] }
);

Deduplicate webhook deliveries before changing mailbox state. On playback.failed or recording.failed, cancel pending timers and avoid saving unconfirmed recordings. On session.ended, stop call-control timers, but wait for any active recording’s recording.ended event before saving its message.