> For complete lifecycle-event payload schemas and examples, use the canonical overview: Session /api/sessions; Room lifecycle, members, and room playback /api/rooms; Session playback /api/playback; Recording /api/recording; DTMF /api/keypad-input; Messages /api/messages; WebSocket Session /api/web-socket. Use /api/event-delivery for transport behavior. Endpoint pages name relevant events, but these overviews are the canonical references.

# Build a simple voicemail

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:

```javascript
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`:

```javascript
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](/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](/async-operations):

```javascript
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`:

```javascript
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") return;

  await post(
    `/v1/sessions/${sessionUuid}/recordings/${recordingUuid}/stop`
  );
  const recording = await recordingEnded;

  const objectKey = `voicemail/${recording.recording_uuid}.wav`;
  await uploadToS3(objectKey, recording.pull_url);
  mailbox.savedMessages.push(objectKey);

  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:

```javascript
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`, `recording.failed`, or `session.ended`, cancel pending timers,
avoid saving unconfirmed recordings, and mark the session terminal.