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

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

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

Choose the call path

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

1async function handleIncomingCall(call) {
2 const sessionUuid = call.session_uuid;
3
4 const answered = waitForEvent("session.answered", sessionUuid);
5 await post(`/v1/sessions/${sessionUuid}/answer`);
6 await answered;
7
8 if (call.caller_id === mailbox.listenerNumber) {
9 return playSavedVoicemailMessages(sessionUuid);
10 }
11
12 return recordMessageFromUnknownCaller(sessionUuid);
13}

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:

1function playAndWait(sessionUuid, url) {
2 return waitForCommand(
3 operationUuid => post(
4 `/v1/sessions/${sessionUuid}/playback/play`,
5 {
6 type: "files",
7 urls: [url]
8 },
9 { headers: { "Operation-Id": operationUuid } }
10 ),
11 {
12 resolveOn: ["playback.stopped"],
13 rejectOn: ["playback.failed"]
14 }
15 );
16}
17
18async function playSavedVoicemailMessages(sessionUuid) {
19 if (mailbox.savedMessages.length === 0) {
20 await playAndWait(sessionUuid, prompts.noMessages);
21 return hangUp(sessionUuid);
22 }
23
24 for (const objectKey of mailbox.savedMessages) {
25 const playbackUrl = await createS3PlaybackUrl(objectKey);
26
27 await playAndWait(sessionUuid, playbackUrl);
28 }
29
30 await playAndWait(sessionUuid, prompts.endOfMessages);
31 return hangUp(sessionUuid);
32}

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:

1async function recordMessageFromUnknownCaller(sessionUuid) {
2 await playAndWait(sessionUuid, prompts.leaveMessage);
3
4 const operationUuid = crypto.randomUUID();
5 const recordingEnded = waitForMatchingEvent(
6 "recording.ended",
7 operationUuid
8 );
9
10 // Register the terminal-event listener before dispatch.
11 const { recording_uuid: recordingUuid } = await post(
12 `/v1/sessions/${sessionUuid}/recordings`,
13 { direction: "both", channels: "mono" },
14 { headers: { "Operation-Id": operationUuid } }
15 );
16
17 const stopReason = await waitForCallerStopOrTimeout(sessionUuid, 120000);
18 if (stopReason === "hangup") return;
19
20 await post(
21 `/v1/sessions/${sessionUuid}/recordings/${recordingUuid}/stop`
22 );
23 const recording = await recordingEnded;
24
25 const objectKey = `voicemail/${recording.recording_uuid}.wav`;
26 await uploadToS3(objectKey, recording.pull_url);
27 mailbox.savedMessages.push(objectKey);
28
29 await playAndWait(sessionUuid, prompts.messageSaved);
30 await hangUp(sessionUuid);
31}

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:

1const result = await waitForCommand(
2 operationUuid => post(
3 commandPath,
4 requestBody,
5 { headers: { "Operation-Id": operationUuid } }
6 ),
7 { resolveOn: [expectedEvent], rejectOn: [failureEvent] }
8);

Deduplicate webhook deliveries before changing mailbox state. On playback.failed, recording.failed, or session.ended, cancel pending timers, avoid saving unconfirmed recordings, and mark the session terminal.