---
title: "Tap to Call: Turning Our AI Voice Interview into a Native WhatsApp Call"
date: 2026-10-17
author: Jonathan Muszkat
canonical: https://me.jonymusky.com/blog/whatsapp-call-interviews-voice-bridge
---

# Tap to Call: Turning Our AI Voice Interview into a Native WhatsApp Call

At Selenios, candidates apply to jobs without leaving WhatsApp. They message the company's number, fill in a form inside the chat, and get scored. The next step is a screening interview with a voice agent built on ElevenLabs Agents. Until this week, that step meant a link: open the browser, allow the microphone, talk to the agent.

That works, but it is the one moment the candidate has to leave the chat, and it is where things go wrong: the in-app browser, the microphone permission, a laptop that is not at hand. So we asked a simple question. The candidate is already on WhatsApp, and WhatsApp makes phone calls. Why not make the interview a call?

There is a second reason, and in Latin America it may be the bigger one. For many people, WhatsApp is the app that always works: some mobile plans include it while the rest of the data is limited or billed separately. A step that happens in the browser is a step some candidates cannot take on the plan they have. Whether a call is included depends on the carrier and the plan, but the more of the process stays inside WhatsApp, the fewer candidates we lose along the way.

Now the invitation carries a button. The candidate taps it, WhatsApp places a call, and the same agent answers, with the same questions and the same scoring. The web link stays in the same message as an alternative. The first production interview, on an internal application, ran 3 minutes and 26 seconds and landed in the pipeline like any web interview: transcript, score, next stage.

This post is about what that took. No provider could carry the audio for us, so we built a small service on AWS. I will cover the shape of it, what a call looks like second by second, how we made sure we only answer candidates we invited, and the AWS and IAM gotchas that cost us time.

## Nobody carries the audio for you

The obvious first option was ElevenLabs' own WhatsApp integration. It works by importing the phone number: ElevenLabs becomes the number's provider. Our numbers are registered with [Kapso](https://kapso.com), the WhatsApp provider we use on top of Meta's Cloud API, and a number cannot belong to two providers at once. Moving every client's number to get calls would have broken everything else we do on WhatsApp.

Kapso supports WhatsApp calling. It forwards the call events to a webhook of yours and relays the actions you send back: `pre_accept`, `accept`, `reject`, `terminate`. What it does not do is carry the audio. A WhatsApp call is a WebRTC session between Meta and a media server, and the media server is yours to run.

Kapso publishes an open-source starter that does exactly this, in Python with [Pipecat](https://github.com/pipecat-ai/pipecat): it answers the call, terminates WebRTC and connects the audio to an ElevenLabs agent. It already solves the hard parts: the quirks of Meta's SDP, the order of `pre_accept` and `accept`, starting the agent only once media is connected, and the ElevenLabs Agents protocol. Our backend is Node, and porting the starter would have meant rebuilding WebRTC plumbing without a reference. We forked it instead and turned it into a small service, the voice bridge, that does signalling and audio and nothing else.

## Check your numbers before you promise anything

Meta has requirements for calling, and they decide who can have the feature at all:

- **No coexistence.** Many businesses share their number between the WhatsApp Business app and the Cloud API. On those numbers, calls ring in the app and never reach the API.

- **A messaging limit of at least 2,000** conversations a day. A number in the lowest tier does not qualify.

- **Calling enabled** explicitly on each number.

There is also a pricing detail that shaped the scope. Calls the user starts, from a call button, are free and need neither a call permission request nor a payment method on the account. Calls the business starts need the user's permission first and are billed. Since the candidate always starts the call from our invitation, we only built that half.

Before designing anything, we wrote a read-only script that walked every connected number and classified it against those requirements. Several clients turned out to use coexistence. That answer shaped the product: the feature is enabled per client, the integrations page explains when a number cannot take calls and why, and clients without it keep receiving exactly the invitation they received before.

## The shape of it

Who talks to whom during a WhatsApp interview call
The candidate&#x27;s phone calls through Meta. Kapso forwards the call events to the voice bridge, a service we run on AWS Fargate, and relays its accept and terminate actions. The audio goes directly between Meta and the bridge over WebRTC, and between the bridge and the ElevenLabs agent over a WebSocket. The bridge asks the Selenios backend whether to answer and reports the call&#x27;s events. ElevenLabs sends the transcript to the backend after the call, and the backend sends the invitation with the call button through Kapso.

Candidate&#x27;s phone
WhatsApp

Meta
WhatsApp Cloud API

Kapso
WhatsApp provider

voice-bridge
Python + Pipecat, AWS Fargate

ElevenLabs
voice agent

Selenios backend
every business rule
- call

call events

WebRTC audio
Opus over UDP, direct

signed webhook in,
accept and
terminate out

audio over
WebSocket

answer this call?
call events

post-call webhook: transcript, scoring

invitation with call button

audio

signalling and data

what we built

The bridge only carries signalling and audio. Every decision about the candidate stays in the backend, and the post-call path is the same one the web interview already used.

Two rules held the design together.

**The bridge has no business rules.** When a call arrives, it asks the backend one question: should I answer this, and if so, with which agent and which variables? The backend answers with the same code that starts a web interview. If the bridge had its own idea of who may be interviewed, the two would drift.

**The bridge can go down without losing anything.** Kapso sends call events through Meta's raw webhook for the number, and that webhook carries every event, candidates' messages included. The bridge returns 200 and drops everything that is not a call, without logging the body. Messages keep reaching the backend through their own, separate webhook. If the bridge is down, the button stops working and the link in the same message still does.

Reusing the web interview turned out to be the most important decision on the backend. Starting an interview runs about 700 lines of checks: whether the interview is still open, retake rules, the score gate, a snapshot of the questions, and a signed ElevenLabs URL with the interview's variables. Copying that logic would have been fast. Refactoring it on the way would have been risky. Instead, a thin adapter calls the web interview's own handlers in process and captures their response. There is a concrete reason this matters: when ElevenLabs sends the transcript after the call, our webhook discards it as an abandoned attempt unless its conversation is the one recorded on the application. A second implementation would have drifted exactly there, and the symptom would have been interviews that happened and never got scored.

## Anatomy of a call

The candidate taps the button. Meta sends a `connect` event with the SDP offer and the button's payload, which we set to a random token when we sent the invitation. The payload is at the top level of the call object, and Kapso does not keep it in its call log: the webhook is the only place you see it.

- The bridge verifies the signature and asks the backend whether to answer. If the answer is no, it rejects the call before any media exists.

- If yes, it builds the SDP answer and sends `pre_accept`, then `accept`. Meta only accepts a SHA-256 DTLS fingerprint, so the others the WebRTC library offers are stripped from the answer.

- Once media connects, the bridge opens the ElevenLabs session with the same agent and variables as the web. Our newer agents let the model write the first turn, and they wait for a signal message; the bridge sends the same one the browser does. On agents with a fixed greeting, that signal makes them greet twice, so the backend tells the bridge which kind it is.

- When the interview is over, the agent ends the call with its own tool. The bridge waits for the goodbye audio to finish and sends `terminate`. From there, ElevenLabs' post-call webhook reaches the backend exactly as it did for web interviews.

From the tap to the agent&#x27;s first word
Measured on the test calls. About 6.8 s pass between the call webhook and pre_accept, which the candidate hears as ringing. Then 1.6 s to accept, 0.55 s until the ElevenLabs session is ready and 0.18 s to the agent&#x27;s first audio.
Seconds since the call webhook arrived
- 0 s

2 s

4 s

6 s

8 s

10 s
Before we answer
the candidate hears ringing

6.8 s
pre_accept to accept
media connects at accept

1.6 s
Agent session ready
ElevenLabs WebSocket

0.55 s
Agent&#x27;s first audio

0.18 s

Test calls before launch. In the first production interview, pre_accept to accept took 1.3 s and the agent spoke about 0.2 s after its session was ready.

Most of the wait happens before we answer. About 6.8 seconds pass between the `connect` webhook and `pre_accept`. To the candidate, it sounds like the phone ringing, which is what phones do, so it does not feel broken. We suspect the time goes into gathering network candidates while building the SDP answer, and we are still measuring it. Once the call is accepted, everything is quick. Media goes direct, with no TURN server: a Fargate task with a public IP is reachable from Meta. The agent's session is ready in about half a second, and its first audio follows in under two tenths.

## Invite-only, in layers

The requirement was blunt: if anyone calls the number, it should not work. A business number is public, and a button is just a message that can be forwarded. So every call goes through the same checks, and most of them run before any audio exists.

The checks a call goes through before anyone answers
Meta hides the chat&#x27;s call icon. The WAF drops anything that is not a signed webhook. The bridge verifies the HMAC and drops anything that is not a call. The backend requires the capability, an unexpired invitation for the number, the invited candidate as the caller, a matching button token, the call limits and the web interview&#x27;s own gates. Only then the bridge answers and audio starts.
Layer
Check
What it stops

Meta
call icon hidden in the chat
calls not started from our button
WAF
only signed webhooks pass
internet noise, scanners

Bridge
HMAC over the raw body
forged webhooks

only call events, body never logged
candidate messages (PII) in logs
Backend
feature on for the client
clients without the feature
an unexpired invitation for this number
anyone who calls the number
the caller is the invited candidate
a forwarded button
button token matches its hash
guessed or reused payloads
one call at a time, call and rate limits
retry loops and abuse
the web interview&#x27;s own gates
completed or closed interviews

Only then: pre_accept, accept, audio

Every check before the backend's answer runs without opening audio. A rejected call never connects media, and the backend's rejection reasons are short codes, never personal data.

A few details that are easy to get wrong:

**The button's token** is random, at least 128 bits, stored only as a SHA-256 hash, and never logged.

- **The caller check** compares against the WhatsApp ID Meta returns when we send the invitation, not the phone number as it was typed in the application. The same Argentine mobile can be written with or without a 9, and Meta's ID is canonical.

- **Rate-limit keys are hashed**, because the limiter stores its records in the database, and those records should not contain phone numbers.

- **One invitation, several calls.** If a call drops, the candidate taps the same button and the interview resumes, one call at a time and up to a limit. Every step is idempotent per call ID, because webhooks get delivered twice.

- **Meta's layer is defense in depth.** We hide the call icon in the chat, so the button is the only way to start a call. We verified on the first test call that the button still works with the icon hidden. The real control is in the backend, so this layer is only an extra one.

## Button or link: the 24-hour window

The first thing the test calls taught us: the message with the call button is a session message. WhatsApp only lets a business send those within 24 hours of the user's last message, and Kapso refuses the send before it reaches Meta. Most invitations go out right after the candidate applies, so they are inside the window. A delayed notification, or a recruiter who moves a candidate forward three days later, is not.

Which invitation a candidate receives
If the client does not have the feature, or its number cannot take calls, the candidate gets the text with the web link, as before. If it can and the conversation is inside the 24-hour window, one message carries the call button and the link. Outside the window the plan is a template with a call button; today it is the web link.

Interview invitation on WhatsApp
-
Feature on and number ready?
no coexistence, limit 2,000+, calling on

no

Text with the web link, as before

yes

Inside the 24-hour window?

no

Template with a call button
planned; today the web link

yes

One message: call button + web link
the candidate picks

Every branch that is not the call button sends exactly the message that went out before the feature existed.

For the cases outside the window, we picked the fallback that changes nothing: the web link, as before. The next step is a template with a call button: templates are the messages WhatsApp does allow outside the window. The same rule applies to clients without the feature: their candidates receive the same message, byte for byte. We even dropped a customizable text we had added for the call version, so the message editor of clients who cannot use calls shows no new field.

## AWS, without a platform

A web backend is built to serve HTTP requests. WebRTC needs UDP on ports open to the internet, so the bridge needed a home of its own, and we did not want to add a hosting platform just for it.

**ECS Fargate, public subnet, public IP.** The security group allows HTTP only from the load balancer and UDP only on the media ports. The task runs as a non-root user, with a read-only filesystem, every Linux capability dropped, no shell access, and a task role with no permissions at all.

- **The webhook enters through an ALB with TLS and a WAF.** The WAF drops anything that does not look like a signed webhook, so the internet's background noise never reaches the container. The smoke tests cover every layer, with valid and invalid requests.

- **A fixed range for media ports.** The WebRTC library takes its UDP ports from the kernel's ephemeral range, so the security group had to open all of it. Fargate lets a task definition set `net.ipv4.ip_local_port_range`, so we pinned it to a thousand ports and opened exactly those. The bridge logs the range it sees at boot and warns if it does not match. That change is rolling out as I write this.

- **Alarms go to Slack through AWS Chatbot**, with a guardrail policy that denies everything: the bot can tell us something is wrong, but it cannot act on the account.

## Security as code

The bridge lives in its own repository, with the same rules as the rest of our infrastructure. Most of the time on this project went here, and most of it is reusable.

- **Deploys from GitHub Actions with OIDC**, with no long-lived keys. Production deploys need an approval and only run from `main`. Actions are pinned by commit SHA.

- **One role per environment**, generated from the same template. Staging cannot touch anything in production, not even tag a task definition.

- **A permissions boundary on everything CloudFormation creates.** It can only create roles that carry the boundary, and it cannot remove it or attach an administrator policy.

- **IAM has tests.** A script runs IAM Access Analyzer, which must report no warnings, and around ninety policy simulator cases: things that must be allowed, and things that must be denied, such as escalation paths, other services' resources and the other environment. It runs in CI and before every change to the roles.

- **The template has tests too**, for what IAM cannot express: the task runs only our image, receives secrets only from its environment's path, and attaches only to its own target group.

- **Secrets live in SSM Parameter Store**, encrypted with a KMS key per environment that only the task's execution role can use. Not even an administrator of the account can read the values. The script that loads them never puts a value on the command line, where it would show up in `ps` and in the shell history.

## The gotchas

None of these were hard once found. All of them cost more time than they should have, mostly because the error message pointed somewhere else.

- **zsh and ARNs.** In zsh, `$VAR:r` is a history modifier: `:r` removes a file extension. So `arn:aws:iam::$ACCOUNT:role/deploy` silently became something else. The value went into a GitHub secret, which shows as `***`, and the error was only "Request ARN is invalid". Use `${VAR}` always, and validate the shape before you store it.

- **The AWS CLI on macOS does not read `file:///dev/stdin`.** Piping the secret's JSON in looked like the safest way to keep it off the command line. It fails with "Invalid JSON received". We ended up with a temporary file, readable only by its owner, removed on exit.

- **CloudWatch Logs authorizes against `log-group:NAME:*`,** not against the bare name. Our policy listed the WAF's log group without the `:*`, and the first deploy rolled back on a permission error to tag it. The fix came with tests for both forms of the ARN.

- **Resources with a retain policy outlive a failed create.** The rollback left the access-log bucket and the log group behind, and the next attempt failed with "already exists". A stack in `ROLLBACK_COMPLETE` cannot be updated either: you delete it and create it again.

- **AWS Chatbot allows one configuration per Slack channel in an account.** We wanted the bridge's alarms in the channel our maps services already use, and the first production deploy rolled back. The bridge got its own channel.

- **EC2 has a character set for security group rule descriptions.** The deploy that pinned the media ports rolled back because a description said `task's`, and EC2 does not accept apostrophes there. cfn-lint did not catch it. A template test does now.

## The copy mattered too

The first production invitation said "complete the interview at the following link", with the link, and then a button that said "Start interview". Two ways to start, and the text pointed at the one we wanted people to use less. We rewrote it to offer the call first and the web as the alternative, and the button now says "Call now". When a message gives people two options, it should say which one is which.

## If you are thinking about doing this

- **Classify your numbers first.** Coexistence and messaging limits decide who can have the feature. Find out with a read-only script before you design anything.

- **Keep the media service dumb.** Signalling and audio in one small service, every decision in the backend that already makes them.

- **Reuse the existing flow instead of rebuilding it.** Especially the part that decides which conversation counts.

- **Make it invite-only from day one.** A public number with an AI agent on it is an open line. Check the invitation, the caller and the token before any audio exists.

- **Never log webhook bodies** that can carry personal data. Log IDs, and log where a field arrived, never its value.

- **Test your IAM like code,** with cases that must be denied, not only cases that must work.

- **Spike with explicit go and no-go questions.** Ours had six, from "does the audio reach Fargate?" to "does the button need the 24-hour window?". Two of the answers changed the design.

## What's next

The template with a call button, so invitations outside the 24-hour window can carry it too. The nearly seven seconds of ringing, once we know where they go. Load tests, and scaling out when the volume asks for it. And real costs per interview, now that there are calls to measure.

The candidate's side of all of this is a button that says "Call now". That is how it should be.
