
Fixing "403: Domestic Anchored Terms Not Met" on Self-Hosted LiveKit + Plivo Outbound Calls
A real debugging story: why self-hosted LiveKit + Plivo outbound calls fail with a 403 "Domestic Anchored Terms Not Met" error, why AI tools couldn't solve it alone, and the actual infrastructure fix that worked.
A real debugging story — 10 days, dozens of dead ends, two AI assistants that couldn't crack it alone, and the one change that actually fixed it
If you landed here from Google, you're probably staring at this exact error in your LiveKit SIP logs:
TwirpError(code=permission_denied, message=unexpected status from INVITE response: sip status: 403: Domestic Anchored Terms Not Met)
And you're probably also confused, because:
- The same Plivo account and the same Plivo number work fine on LiveKit Cloud.
- The exact same self-hosted setup works fine with Twilio.
- It's only Plivo + self-hosted LiveKit that throws this 403.
This article walks through why that happens, why it's so easy to chase the wrong fix for days, and the actual change that resolves it — based on a real self-hosting case from the LiveKit Community Discord, plus LiveKit's own SIP troubleshooting documentation.
---
The setup that triggers this
- Self-hosted LiveKit
- Self-hosted LiveKit SIP
- Self-hosted Egress
- Deployed on DigitalOcean, Bangalore region
- Outbound trunk configured with Plivo
- ✅ Twilio outbound calls work
- ❌ Plivo outbound calls fail with the 403 above
This combination matters, and we'll come back to why.
---
What "Domestic Anchored Terms Not Met" actually means
This isn't a bug, and it isn't a typo or a corrupted SIP header. It's a regulatory rejection, not a technical one.
Plivo's own hangup code reference lists "Domestic Anchored Terms Not Met" as a generic error covering fatal network conditions and signaling issues from the terminating gateway. In practice, this code shows up specifically in one scenario: calls connected to or from India where the telecom regulator requires that signaling for domestic (India-to-India) traffic must stay anchored inside India.
LiveKit's own troubleshooting docs confirm this directly — the 403 indicates an authentication or permission error, but is also returned when regional requirements aren't met, and this commonly occurs in regions where regulations require calls to remain within national borders. If a call is routed through infrastructure outside the country, the SIP provider returns this error to flag a domestic compliance violation.
In other words: Plivo isn't rejecting your account, your credentials, or your number. It's rejecting where your SIP signaling is physically coming from.

This is the same rule you'll find documented anywhere a voice platform serves India traffic — it's not specific to LiveKit. Other platforms integrating with Plivo's India numbers state the same requirement explicitly: handling calls to or from India requires your server deployment to physically be in India, and calls will fail with this exact "Domestic Anchored Terms not met" error otherwise.
---
Why Twilio worked and Plivo didn't, on the same server
This is the part that sends most people down the wrong rabbit hole — including ten days of mine.
If the rule is "signaling must originate in India," and the DigitalOcean droplet is already in Bangalore, shouldn't every provider be fine? Not necessarily. Different SIP trunking providers enforce India's domestic-anchoring rules with different strictness, depending on how their own carrier routes and number types are configured. Twilio's routing path for the specific number and trunk used may not have triggered the same compliance check, while Plivo's did. This isn't a flaw in either provider — it's just that one happened to enforce the rule on this call path and the other didn't.
This is exactly why "the same account works on LiveKit Cloud" is also not a contradiction. It points to the real difference: how each platform decides where a call is geographically originated from.
---
Why LiveKit Cloud "just works" and self-hosted doesn't
LiveKit Cloud has a first-class feature for this called region pinning, built specifically for telephony compliance:
To originate calls from the same region as the destination phone number, you set the destination_country parameter on an outbound trunk. This applies region pinning to all calls made through that trunk — when set, outbound calls originate from a server within the specified country, and if no supported region matches, the parameter simply has no effect.
Plivo's own integration docs for LiveKit are explicit about this: if you're handling calls to or from India, you must select India as your LiveKit region — calling it a regulatory requirement, not an optional optimization.
That's a dashboard setting and an API parameter on LiveKit Cloud. There's nothing to deploy, nothing to migrate — LiveKit Cloud has nodes in multiple regions already, and destination_country just tells it which one to use for that call.
Self-hosted LiveKit does have an equivalent field — it's just not where most people look for it. It's not in livekit-sip's config at all. It's a top-level field in your core livekit.yaml, called region:
# livekit.yaml
region: blr1 # or whatever label you choose for this node
node_selector:
kind: regionaware
LiveKit's own config-sample.yaml documents this directly: it's the "Region of the current node," and it's required if you're using the regionaware node selector. It exists primarily to support multi-node, Redis-backed deployments where LiveKit picks the closest/best node for a room. Almost nobody runs a single-node self-hosted box with this set, because on a single node it looks pointless — there's only one node, so what is there to "select" between?
That assumption is exactly what cost ten days here. Even on a single-node deployment, setting region in livekit.yaml is what told the stack — and, downstream, the SIP signaling path — that this node is anchored in India. Leave it unset, and the node has no declared region at all, which is a very different thing from "the node happens to be running on a VM physically in Bangalore."

This is the detail that made this bug so hard to diagnose from AI chat assistants and forum threads — almost all the official documentation, troubleshooting guides, and integration examples for "region pinning" talk about destination_country on LiveKit Cloud, and barely anyone writes about the plain region field in self-hosted livekit.yaml in the same breath. The two look unrelated until you've actually hit this error. That's exactly why generic suggestions like "check your SIP credentials" or "check your trunk config" went nowhere for ten days — nobody was pointing at this one line in livekit.yaml.
---
The actual fix for self-hosted deployments
The fix is a config change, not an infrastructure migration — but it's a config change in a file most people don't think to touch for this:
- Add
regionto yourlivekit.yaml, with a value that identifies this node as being in India (any label works —blr1,in-blr1,india— LiveKit doesn't validate it against a real geography database, it's a declaration, not a lookup):
```yaml
region: blr1
node_selector:
kind: regionaware
```
Restart your livekit-server process after adding this — it's read at startup, not hot-reloaded.
- Set
use_external_ip: trueunderrtc:in the samelivekit.yaml, so the node correctly advertises its real public IP rather than an internal/NAT address. LiveKit's troubleshooting docs note this is the most common cause of self-hosted servers failing to correctly advertise their public IP in SDP, and it compounds the region problem if left unset. - Confirm your DigitalOcean droplet's actual public IP is genuinely within India, not just that the console label says "Bangalore." The
region:field above tells LiveKit what to declare — your VM's real network location is what Plivo ultimately sees on the wire, and the two need to agree. - Restart the SIP service after the LiveKit core config change, not just
livekit-server— in some self-hosted setupslivekit-sipreads node region metadata from the core server on connect, so a stale SIP process can keep using the old (unset) region even after you've fixedlivekit.yaml. - Re-test with a brand-new outbound call, not a retry of a failed one — SIP trunk providers sometimes cache routing decisions per registered IP for a period of time.
In the case this article is based on, the fix that worked was exactly step 1: adding region: to livekit.yaml. Everything else — credentials, trunk JSON, SIP headers, Plivo dashboard settings — had already been triple-checked and ruled out across ten days. The single line that was missing was a one-word config field nobody connects to a Plivo 403 unless they already know to look for it.
---
What ten days on one error actually taught me
Here's the part most "I fixed it" posts skip, and the part I actually want this article to be remembered for.
I asked ChatGPT. I asked Claude. I ran every suggestion they gave me — changed SIP settings, rebuilt trunk configs, re-checked credentials a dozen times, read LiveKit's docs front to back, opened a thread in the LiveKit community, and reached out to LiveKit directly. None of it produced the fix on its own. Both AI tools, when I floated the idea of changing the deployment region, told me the same thing: this shouldn't be a region issue. They weren't wrong about the general mechanism — they just couldn't connect it to my specific, self-hosted setup the way the docs only describe for LiveKit Cloud.
I tried it anyway. Not because an AI tool told me to — because ten days of research had given me a hunch, and the only way to know was to test it myself. That's the change that worked. And only after it worked did I go back and explain it to both tools, walk through why a self-hosted India deployment needs to be anchored the same way LiveKit Cloud's region pinning anchors it — so the explanation was complete, for the next person who asks them the same question.
That's the actual point of this article:
- AI tools are genuinely useful for narrowing down known issues, drafting configs, and explaining documentation faster than reading it cold. I used them throughout this entire process, and I'm not pretending otherwise.
- They are not a substitute for the work. They didn't have my specific deployment in front of them. They didn't know my droplet's real network path. They couldn't run the test call. Only I could do that.
- The fix came from research, community discussion, persistence, and a willingness to test an idea everyone — including the AI tools I was using — said probably wouldn't matter.
A developer who stops at "the AI said this shouldn't be the issue" never finds fixes like this one. A developer who keeps a problem alive for ten days, reads the primary docs instead of just summaries, asks in communities, tries the thing nobody expected to work, and then writes it up so the next person doesn't burn ten days too — that's the difference between using a tool and outsourcing your thinking to it.
If you're stuck on something similar right now: this is your permission to keep going. The fix you need might not be the first five things anyone — human or AI — tells you to try.
---
The broader lesson, if you're calling into any regulated country
This isn't an India-only quirk, and it isn't a Plivo-only quirk — it's worth internalizing as a general rule for any telephony integration:
An outbound call should be anchored from the same country it's calling into. If you're calling a number in India, your SIP signaling (and ideally your media path) should originate from infrastructure inside India. If you're calling Brazil, Germany, or any other country with similar telecom residency rules, the same logic applies to that country instead.
LiveKit's own guidance on regional deployments frames this exactly the same way: SIP pinning controls where PSTN ingress and egress occur with respect to LiveKit, and if your telephony traffic must remain in a specific country to comply with local telecom regulations, you must pin your SIP traffic accordingly — and you need to select a carrier that supports that region.
On LiveKit Cloud, that's a config field on the trunk (destination_country). On self-hosted LiveKit, it's also a config field — just on the node itself (region in livekit.yaml) instead of on the trunk. It's easy to assume "my droplet says Bangalore" is the same thing as "my LiveKit node has declared itself anchored in India," when those are two different layers of the stack — one is where the VM physically sits, the other is what your config actually tells LiveKit and, downstream, your SIP signaling.
---
Quick troubleshooting checklist
If you're seeing 403: Domestic Anchored Terms Not Met on self-hosted LiveKit + Plivo:
- [ ] Add
region: <your-region-label>tolivekit.yaml(top-level field, not underrtc:orsip:) - [ ] Add
node_selector: { kind: regionaware }alongside it - [ ] Restart
livekit-serverso the new config is actually loaded - [ ] Restart
livekit-sipas well, after the core server restart - [ ] Set
use_external_ip: trueunderrtc:inlivekit.yaml - [ ] Confirm your droplet's real public IP is genuinely within the destination country
- [ ] Re-test with a brand-new outbound call after the change, not a cached/retried one
If you've already burned days re-checking credentials, trunk JSON, and SIP headers like I did — and everything checks out — check livekit.yaml for a missing region: field before anything else. It's a one-line config gap wearing a carrier-compliance error's clothes.
---
If this saved you from another 10 days of debugging, feel free to share it — there isn't a clear, self-hosted-specific writeup of this anywhere else yet, and the official docs (correctly) focus on the Cloud-side fix.
Comments
Loading comments...