Skip to main content

Troubleshooting: number masking and proxy-session failures

A masked proxy session fails in a small, predictable set of ways: it never binds, it forwards in one direction only, a voice leg bridges to the wrong party, or it ends earlier or later than your order state expects. This page is the playbook for each of those — what to read back, what each symptom means, and what to hand support when the symptom is on the platform side. If you are still deciding whether masking is the right primitive, start with Choosing a privacy primitive; the lifecycle model this page references is on Proxy session model.

1. Symptom map

Find your symptom, then jump to the section that works it: Two errors are deliberately not failures: 409 CONFLICT on close means the session already ended (section 3), and silence when a third number texts the proxy number is the privacy boundary working as designed — a sender matching neither participant is dropped, not forwarded.

2. Read back the session object

Every diagnosis starts the same way: fetch the session and compare what the API says with what your order state believes. Create returns session_id, proxy_number, status, ttl_minutes, and expires_at; every later check reads those back:
Read the four fields in order and the symptom usually decodes itself:
  1. session_id. A 404 NOT_FOUND here means the id does not exist in your organization — you are holding a stale or mistyped id. Fetch it from the create response on your order record; list sessions to reconcile.
  2. status. active forwards; closed and expired do not. A session your order state believes is open but that reads closed was ended by an earlier DELETE — find that call in your logs. One that reads expired hit its TTL (section 3).
  3. proxy_number. Confirm both participants are actually addressing this number. A participant texting or calling any other number — a real participant number, an old session’s proxy number — never resolves to this session. The proxy number is the only address the session owns.
  4. expires_at. Compare it against now. An active-looking row past expires_at still rejects traffic — expiry is enforced on access; the sweeper is the cleanup pass, not the gate (section 3).
Three create-time rules keep a session from ever binding, and all return 400 VALIDATION_ERROR: participant_a equals participant_b, a participant is not E.164 (leading +, country code, digits, nothing else), or ttl_minutes is below 1 or above 1440. These are request errors — they never succeed on retry without a change. The full state machine this read-back walks is section 2 of Proxy session model; the create and close flows are walked end to end in the masking sessions guide.

3. TTL and expiry drift

“Drift” here means the session ends on a different schedule than your order state expects. Three mechanics explain every case:
  • Expiry is wall-clock, enforced on access. Forwarding stops the moment expires_at passes, even while the row still reads active. A participant texting one minute past expires_at gets silence even though your list call still shows the session. If your “session died early” report has a timestamp just past expires_at, this is the explanation — not a fault.
  • The sweeper is periodic, not instant. A platform job runs every five minutes, marks lapsed sessions expired, and releases their numbers. Between wall-clock expiry and the sweep, the row sits in a limbo that reads active but forwards nothing. Do not build on the intermediate status — treat expires_at as the truth and the status as bookkeeping that catches up within one sweep interval.
  • A 409 close race is a successful end state. If your completion handler closes near the TTL, the sweeper may expire the session first, and your DELETE returns 409 CONFLICT. Both paths release the number. Handle a 409 on close as “already ended” — log it and move on; alerting or retrying on it creates noise about a correct outcome.
If sessions end earlier than intended, the fix is sizing, not platform behavior: ttl_minutes is bounded at 1440 and defaults to 60 when omitted. An omitted TTL on a use case that legitimately spans hours ends at the one-hour mark by design. Set the TTL to the longest window the two parties need each other — the per-use-case table is section 3 of Choosing a privacy primitive — and create a fresh session when a marketplace thread re-opens, rather than stretching one TTL as insurance. If sessions linger past what your order state expects, that is the drift the list call catches: GET /api/v1/proxy/sessions?status=active against your open orders, and close anything your app believes is finished. Your completion handler owning the close is the primary path; the TTL is the backstop, not the plan.

4. Missing webhook subscription on the inbound leg

A session can forward perfectly while your integration sees nothing, because the lifecycle events were never subscribed. Three events exist — and each answers a different operational question:
  • proxy.session.created — the session exists and the number is bound.
  • proxy.message.forwarded — one per forwarded message, telling you the direction (from_participant, to_participant, channel).
  • proxy.session.closed — the pairing is released.
The common miss is subscribing to created and closed only. That pair gives you lifecycle bookkeeping and leaves the conversation itself invisible: without proxy.message.forwarded you cannot render a two-sided thread or reconcile message flow, because it is the only event that says which direction each message moved. Subscribe to all three; registration, retries, and signature verification are in the webhook consumer guide and the webhook security page. When you suspect a forwarding gap, compare two clocks: the message actually arriving on the counterparty handset, and your webhook log. A delivered message with no proxy.message.forwarded event is a subscription or ingestion gap on your side. An event with no delivered message is a sender-side leg failure — section 5. A healthy proxy.message.forwarded delivery looks like this:
The corresponding failure shape is the absence of this event paired with an inbound SMS you know arrived on the proxy number — that pairing, inbound present / event absent, is the signature to capture for support (section 6). Note the inverse discrimination too: a first-direction proxy.message.forwarded followed by silence in the reverse direction is not a webhook gap — the reply leg itself is failing, so work section 5. Webhook deliveries are at-least-once and signed; dedupe by the event id and verify the HMAC before acting, exactly as for message status events. The adjacent discipline is on Troubleshooting: webhook event dedup and Troubleshooting: signature verification failures.

5. Sender-side mask leg failures: voice bag vs message

When a session reads active, is within TTL, and a participant’s inbound traffic still does not reach the counterparty, the session layer has already done its job — the failure is on the sender side of the leg Orbit placed to the other participant. Split the diagnosis by channel, because the two legs fail differently: Message leg (SMS). The forwarded SMS runs through the same billed message path as any other send: wallet balance and rate limits apply, the proxy session id is attached to the message record, and the send produces the same lifecycle receipts and failures as an ordinary outbound message. Work it exactly like one:
  1. Find the forwarded send in the Delivery log — the proxy-attributed message row.
  2. A row stuck at sent or submitted_no_receipt is a receipt problem → Troubleshooting: message sent but no delivery receipt.
  3. A terminal undelivered or failed row carries the carrier verdict → decode it on Troubleshooting: message undelivered or failed.
  4. A send that never got past queueing → Troubleshooting: message queued.
One sender-side shape is unique to masking: to inject a reply from your own service — a dispatcher answering, a completion notice — you send an outbound SMS to the proxy number, never to either participant’s real number. The session match keys off the destination. A reply addressed to a participant number bypasses the session entirely: it either delivers unmasked (a privacy leak you built yourself) or, more often, simply fails to thread. Address replies to the proxy_number you stored at create. Voice leg (the voice bag). A voice call between participants is a separate bridge: the participant dials the proxy number, and the platform bridges the call to the counterparty, presenting the proxy number. SMS forwarding succeeding while voice fails is the tell that these are independent legs — do not debug the session when only one leg is down. Work the voice side in order:
  1. Both directions fail. The inbound call to the proxy number is not resolving — confirm the caller is dialing the proxy_number from one of the two participant numbers (a third number never bridges, by design).
  2. One direction fails. The outbound leg to the counterparty is a call-leg failure — decode it like any bridged-call failure on Troubleshooting: voice call quality and Troubleshooting: routing and channel misconfiguration.
  3. Calls blocked only during certain hours. If the participants are in the US, a federal dialing window applies to outbound voice legs and has no opt-out — see Troubleshooting: TCPA window blocked calls before treating it as a session fault.

6. What to bundle before contacting support

Gather this before you open a ticket — it is the difference between a one-reply resolution and a diagnosis loop:
  • Your tenant ID — shown in the dashboard under Settings → Organization, and returned by GET /api/v1/me as organizationId.
  • The session id (proxySession_…) and the proxy number the session held.
  • The full session object from GET /api/v1/proxy/sessions/:id — status, expires_at, and created_at let support place the failure against the lifecycle without a back-and-forth.
  • Timestamps with direction. “Participant A texted the proxy number at 12:31Z, nothing forwarded” beats “messages not working.” For a voice leg, note which direction failed.
  • The webhook evidence. A captured failing-or-missing delivery: your endpoint’s logged request for proxy.message.forwarded with its signature headers, or the observed absence of that event alongside a delivered inbound message. Include the event id if a delivery did arrive — duplicate deliveries are expected under at-least-once semantics.
  • The forwarded message id (msg_…) from the Delivery log when the sender-side SMS leg is the failing part, or the call id when it is the voice leg.
A wide signal changes the escalation shape: one stuck session is a per-session fault; many sessions failing to forward at the same time, across participants and destinations, is a platform-side signal — say so explicitly in the ticket and lead with the timestamps.

See also