> ## Documentation Index
> Fetch the complete documentation index at: https://docs.unpod.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Number Masking

> Connect an agent and a customer so each sees only your Unpod number

Let two people talk without sharing their phone numbers: a delivery agent and a customer, a
support agent and a caller, a driver and a rider. Unpod dials both from your Unpod number,
so each one sees only that number, never the other's.

A masked call is a call with two participants. If you haven't added a second person to a
call yet, read [Multiple Participants](https://docs.unpod.ai/telephony/apis/multiple-participants.md) first. This
page is the production version: everything happens on events, and the call ends as soon as
either person is gone.

## The flow

1. Your backend dials the **agent** with `create`, with `role="agent"`, `record=True`, a
   `status_callback_url`, a `recording_callback_url` and the customer's number in
   `metadata`.
2. When the agent's leg sends `Answered`, your app adds the **customer** with
   `add_participant`, taking the number from `ev.metadata`.
3. When the customer's leg ends, your app ends the call, whether the customer's phone was
   busy (`Busy Line`), rang out (`Ring Timeout Reached`, `No Answer`), rejected the call
   (`Rejected`) or they hung up after talking. Otherwise the agent waits on the line alone.
   When the agent's leg ends, Unpod already hangs up the customer; calling `end` again is
   harmless.
4. On each `Hangup`, your app saves the outcome from `ev.role` and `ev.hangup_cause`.
5. When the recording is ready, your app saves it. The recording event may not arrive for
   a call started with `create`, so also call `get_recording` after the last `Hangup`
   (see [Recording](https://docs.unpod.ai/telephony/apis/recording.md#get-a-recording)).

```mermaid theme={null}
sequenceDiagram
    participant App as Your app
    participant U as Unpod
    participant A as Agent's phone
    participant C as Customer's phone

    App->>U: calls.create(agent, role="agent")
    U->>A: rings, showing your Unpod number
    A-->>U: answers
    U-->>App: Answered (role agent)
    App->>U: calls.add_participant(customer)
    U->>C: rings, showing your Unpod number
    C-->>U: answers
    Note over A,C: talking: each sees only your Unpod number
    C-->>U: hangs up
    U-->>App: Hangup (role customer)
    App->>U: calls.end
    U-->>App: recording event
```

## Start a masked call

Install the SDK and a web server:

```bash theme={null}
pip install "unpod>=0.6.2" fastapi uvicorn
```

Export your [credentials](https://docs.unpod.ai/telephony/introduction.md#credentials) (`UNPOD_BASE_URL`,
`UNPOD_PLATFORM_TOKEN`, `UNPOD_ORG_HANDLE`) and your
[webhook secret](https://docs.unpod.ai/telephony/apis/quickstart.md#receive-your-first-call) as
`UNPOD_WEBHOOK_SECRET`. Your server needs a public https URL (ngrok works, as in the Quick
Start). Start `app.py` with:

```python app.py theme={null}
import os
from contextlib import suppress

from fastapi import FastAPI, HTTPException, Request
from unpod import AsyncClient
from unpod.telephony import TelephonyAPIError, WebhookSignatureError, parse_call_event

FROM = "+919000000001"             # your Unpod number: all either person sees
BASE = "https://abc123.ngrok.app"  # this server's public https URL
SECRET = os.environ["UNPOD_WEBHOOK_SECRET"]
client = AsyncClient()  # one client, reused for every request
app = FastAPI()
# In memory: run one process, or use your database.
ours: set[str] = set()  # call and leg ids seen on signed events
outcomes: dict = {}  # call id -> role -> hangup cause


async def start_masked_call(agent: str, customer: str) -> str:
    """Dial the agent; /call-status adds the customer once they answer."""
    call = await client.telephony.calls.create(
        FROM, agent, role="agent", record=True,
        status_callback_url=f"{BASE}/call-status",
        recording_callback_url=f"{BASE}/recording",
        metadata={"customer": customer},  # comes back on every event
    )
    return call.call_id
```

`start_masked_call` is a plain function, not a route. Call it from your own backend, for
example when an order is assigned to a delivery agent. A public route that starts calls
would let anyone dial from your number.

The same request over REST:

```bash cURL theme={null}
curl -s -X POST "https://unpod.ai/api/v2/platform/sbc/v1/calls" \
  -H "Authorization: Token $UNPOD_PLATFORM_TOKEN" -H "Org-Handle: $UNPOD_ORG_HANDLE" \
  -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
  -d '{"from": "+919000000001", "to": "+919000000002", "role": "agent",
       "record": true,
       "status_callback_url": "https://abc123.ngrok.app/call-status",
       "recording_callback_url": "https://abc123.ngrok.app/recording",
       "metadata": {"customer": "+919000000003"}}'
```

Outbound events don't carry the phone numbers in `ev.to` or `ev.from_`, so the customer's
number travels in `metadata`, which Unpod echoes on every event as `ev.metadata`.

> [!NOTE]
>
> Whether a call is recorded follows the recording policy (set by the Unpod team):
> `record=True` doesn't override it today. See [Recording](https://docs.unpod.ai/telephony/apis/recording.md).

## Handle events

Add the two routes below to `app.py`:

```python app.py theme={null}
@app.post("/call-status")
async def call_status(request: Request):
    try:
        ev = parse_call_event(await request.body(), request.headers, secret=SECRET)
    except WebhookSignatureError:
        raise HTTPException(401)

    ours.update(filter(None, (ev.call_id, ev.leg_id)))  # signed: it's yours
    calls = client.telephony.calls
    customer = ev.metadata.get("customer")
    adding = ev.event == "Answered" and ev.role == "agent" and bool(customer)
    try:
        if adding:
            await calls.add_participant(
                ev.call_id, FROM, customer, role="customer",
                status_callback_url=f"{BASE}/call-status",
                idempotency_key=f"{ev.call_id}:customer",  # a resend never redials
            )
        elif ev.event == "Hangup":
            # an upsert by role: a resent Hangup overwrites, never duplicates
            outcomes.setdefault(ev.call_id, {})[ev.role] = ev.hangup_cause
            await calls.end(ev.call_id)  # never leave the other person alone
    except TelephonyAPIError as e:
        print(ev.call_id, e.status_code, e.message)
        if e.status_code == 429 or e.status_code >= 500:
            raise HTTPException(503)  # transient: Unpod sends the event again
        if adding and e.status_code not in (404, 409):  # 404 gone, 409 dialed
            outcomes.setdefault(ev.call_id, {})["customer"] = "Not dialed"
            with suppress(TelephonyAPIError):
                await calls.end(ev.call_id)  # can't dial the customer: free the agent
    return {"ok": True}  # 2xx once signed, or Unpod resends


@app.post("/recording")
async def recording(request: Request):
    # Recording events aren't signed yet: trust only what the API returns
    ev = parse_call_event(await request.body(), request.headers, None, verify=False)
    if ev.call_id not in ours:  # not a call you started, or already saved
        return {"ok": True}
    try:
        rec = await client.telephony.calls.get_recording(ev.call_id)
    except TelephonyAPIError as e:
        if e.status_code not in (403, 404):
            raise HTTPException(503)  # e.g. not ready yet: Unpod sends it again
        return {"ok": True}
    if rec.available:
        ours.discard(ev.call_id)
        print("recording", rec.call_id, rec.duration_secs)  # save with the outcome
    return {"ok": True}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=8000)
```

Run it with `python app.py` and expose it as in the
[Quick Start](https://docs.unpod.ai/telephony/apis/quickstart.md#receive-your-first-call). Then start a call from
another terminal (ngrok already holds the second one), in the same directory and
virtualenv, with all four variables (`UNPOD_BASE_URL`, `UNPOD_PLATFORM_TOKEN`,
`UNPOD_ORG_HANDLE`, `UNPOD_WEBHOOK_SECRET`) exported, since importing `app` reads them:

```bash theme={null}
python -c 'import asyncio, app
print(asyncio.run(app.start_masked_call("+919000000002", "+919000000003")))'
```

The agent's phone rings first, showing your Unpod number. When they answer, the customer's
phone rings, also showing your Unpod number, and the two are connected.

Every event for the call has the same `ev.call_id`; `ev.leg_id` tells the two legs apart and
`ev.role` is the `role` you gave each one. Until it gets a 2xx, Unpod resends an event a
few times over about a minute
([Delivery and retries](https://docs.unpod.ai/telephony/apis/webhooks.md#delivery-and-retries)), so every branch
is safe to run twice: the
`idempotency_key` stops a resent `Answered` from dialing the customer again, and ending a
call that's already over succeeds again (a `404` comes back only briefly right after
hangup, and is logged and ignored). A rate limit or server error
answers `503`, so Unpod sends the event again; any other error adding the customer ends
the call, so the agent isn't left waiting. If an `Answered` or `Hangup` is lost for good,
someone can stay on the line: as a backstop, sweep your active calls with `calls.list()`
(every page, until one comes back empty) every few minutes and end any that should be over: one with only one leg left after the
customer's ring time (see `calls.get(call_id).legs`), or one older than your longest
expected call. In the same sweep, clear the `ours` and `outcomes` entries of calls that are
over and saved, so they don't grow forever.

The `/recording` route doesn't check a signature, so it ignores any `call_id` your
`/call-status` route hasn't seen on a signed event (which also skips a resent event once
the recording is saved), then asks the API for the recording with your credentials. You
can also put a secret token in the query string of `recording_callback_url`: Unpod sends
it back unchanged. Save the `call_id` and duration; to download the audio, see
[Recording](https://docs.unpod.ai/telephony/apis/recording.md).

The recording event may not arrive for a call started with `create`, so after the last
`Hangup` also call `get_recording` yourself. Run it from a background task (FastAPI's
`BackgroundTasks`, or your job queue), not inside `/call-status`: the handler must answer
with a 2xx quickly, within 10 s.

## Event map

| Event | Leg (`ev.role`) | What your app does |
| - | - | - |
| `Initiated` | `agent`, later `customer` | The leg is being dialed. Optional: save `ev.leg_id` with your record |
| `Ringing` | `agent` | Optional: show "calling the agent" in your app. Not always sent, so don't wait for it |
| `Ringing` | `customer` | The agent hears the customer's phone ringing. `dial_music` plays nothing yet; to play your own sound, see [ringing sound](https://docs.unpod.ai/telephony/apis/multiple-participants.md#play-a-ringing-sound-while-the-second-person-rings) |
| `Answered` | `agent` | Add the customer with `add_participant` |
| `Answered` | `customer` | They're talking. Optional: mark the call connected |
| `Hangup` | `customer` | End the call. `Busy Line`, `Ring Timeout Reached`, `No Answer` or `Rejected`: the customer wasn't reached. `Cancelled`: the agent hung up while the customer's phone was ringing. `Normal Hangup`: they answered |
| `Hangup` | `agent` | Unpod already hangs up the customer; ending the call again is harmless. Any cause but `Normal Hangup` means the agent never answered, so the customer was never dialed |
| `MPCRecordingCompleted` | - | Arrives at `recording_callback_url` after the call. Fetch and save the recording. It may not arrive for a call started with `create`: also call `get_recording` after the last `Hangup` ([Recording](https://docs.unpod.ai/telephony/apis/recording.md#get-a-recording)) |

Store any other cause (`Service Unavailable`, `Unknown`, ...) and `ev.sip_code` as it is;
see [Hangup causes](https://docs.unpod.ai/telephony/apis/webhooks.md#hangup-causes). A call holds at most two
people, so a masked call is always one agent and one customer.

## Customer calls the masked number

The other direction: the customer calls your Unpod number back and you connect them to
their agent. Point the number's answer URL at your app (see
[Incoming Calls](https://docs.unpod.ai/telephony/apis/incoming-calls.md#point-your-number-at-your-app)), look up the
agent from the caller's number (`ev.from_` on `StartApp`) in your own data, and reply:

```xml theme={null}
<Response>
    <Dial><Number>+919000000002</Number></Dial>
</Response>
```

If several pairings share one Unpod number, the caller's number alone can match more than
one agent: give each pairing its own number, or ask the caller for an order ID first. For
the full server, with signature checks and a busy reply, see
[Handle the call](https://docs.unpod.ai/telephony/apis/incoming-calls.md#handle-the-call).

> [!WARNING]
>
> Masking an inbound call with `<Dial>` is not verified to hide the caller's number: the
> agent may see the customer's real number. Test it with your own phones before relying on
> it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.