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, 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: 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
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
connectevent 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, thenaccept. 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.
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.
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.
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
psand 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:ris a history modifier::rremoves a file extension. Soarn:aws:iam::$ACCOUNT:role/deploysilently 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_COMPLETEcannot 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.