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

The DID is selected by application routing. 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 await post(`/v1/sessions/${sessionUuid}/answer`);
5 await waitForEvent("call.answered", sessionUuid);
6
7 if (call.caller_id === mailbox.listenerNumber) {
8 return playSavedVoicemailMessages(sessionUuid);
9 }
10
11 return recordMessageFromUnknownCaller(sessionUuid);
12}

Phone numbers arrive in the canonical format described in Formats.

Play saved messages

Create a fresh, time-bounded S3 URL for each message. Start the next message only after the previous playback has stopped:

1async function playSavedVoicemailMessages(sessionUuid) {
2 if (mailbox.savedMessages.length === 0) {
3 await playPrompt(sessionUuid, prompts.noMessages);
4 await waitForEvent("playback.stopped", sessionUuid);
5 return hangUp(sessionUuid);
6 }
7
8 for (const objectKey of mailbox.savedMessages) {
9 const playbackUrl = await createS3PlaybackUrl(objectKey);
10
11 const operation = await post(
12 `/v1/sessions/${sessionUuid}/playback/play`,
13 { type: "files", urls: [playbackUrl] }
14 );
15
16 await waitForMatchingEvent("playback.started", operation.operation_uuid);
17 await waitForMatchingEvent("playback.stopped", operation.operation_uuid);
18 }
19
20 await playPrompt(sessionUuid, prompts.endOfMessages);
21 await waitForEvent("playback.stopped", sessionUuid);
22 return hangUp(sessionUuid);
23}

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 playPrompt(sessionUuid, prompts.leaveMessage);
3 await waitForEvent("playback.stopped", sessionUuid);
4
5 const startOperation = await post(
6 `/v1/sessions/${sessionUuid}/recording/start`
7 );
8 await waitForMatchingEvent(
9 "command.recording.start.accepted",
10 startOperation.operation_uuid
11 );
12
13 const stopReason = await waitForCallerStopOrTimeout(sessionUuid, 120000);
14 if (stopReason === "hangup") return;
15
16 await post(
17 `/v1/sessions/${sessionUuid}/recording/stop`
18 );
19 const recording = await waitForMatchingEvent(
20 "recording.ended",
21 startOperation.operation_uuid
22 );
23
24 const objectKey = `voicemail/${recording.recording_uuid}.wav`;
25 await uploadToS3(objectKey, recording.pull_url);
26 mailbox.savedMessages.push(objectKey);
27
28 await playPrompt(sessionUuid, prompts.messageSaved);
29 await waitForEvent("playback.stopped", sessionUuid);
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

Every command follows the same shape:

1const operation = await post(commandPath, requestBody);
2await waitForMatchingEvent(expectedEvent, operation.operation_uuid);

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