Browser Calling
May 21, 2026 · View on GitHub
Logrole can place outbound voice calls from the browser using Twilio's Voice JS SDK. Calls bridge browser audio to the PSTN through a Twilio phone number on your account, which doubles as the caller ID.
A user with the can_make_calls permission visits /dial, types
a phone number, and presses Call. Their microphone connects to Twilio,
Twilio dials the recipient, and the two audio legs are bridged.
How it works
Browser (Voice JS SDK)
│ device.connect({params: {To: "+15551234"}})
▼
Twilio fetches POST /dial/voice (signed with X-Twilio-Signature)
│ Logrole returns <Dial callerId="...">+15551234</Dial>
▼
Twilio bridges browser audio ↔ outbound PSTN leg
Three endpoints are involved:
| Route | Who calls it | Auth |
|---|---|---|
/dial | Browser | Logrole session + can_make_calls user permission |
/dial/token | Browser | Logrole session + can_make_calls user permission |
/dial/voice | Twilio | X-Twilio-Signature HMAC against your Twilio AuthToken |
/dial/token mints a short-lived (1h) Twilio Access Token
that lets the browser register with Twilio's media servers. /dial/voice
is the TwiML webhook Twilio hits when the browser initiates a
call — it returns the <Dial> TwiML that tells Twilio who to bridge the
call to. Logrole verifies every /dial/voice request using the same
X-Twilio-Signature mechanism Twilio uses for SMS/call status callbacks.
One-time setup
You'll need three things from Twilio, in addition to the AccountSid + AuthToken Logrole already uses for the log viewer.
1. A Twilio phone number, used as caller ID
Browsers don't have phone numbers, but the <Dial> TwiML Logrole emits
requires a callerId — a verified Twilio number on your account.
Logrole reuses the existing default_sending_phone_number config key
(the same number the "Send Message" form uses) for this.
If you don't have default_sending_phone_number set and your account
has exactly one Twilio number, Logrole picks it automatically. Otherwise
set it explicitly:
default_sending_phone_number: "+14155550199"
2. A Twilio API Key + Secret
Access tokens are signed with an API Key, not your account's
AuthToken directly. The API Key SID starts with SK...; the secret is
shown once at creation time.
Create one at https://www.twilio.com/console/voice/runtime/api-keys
(or wherever Twilio is hosting the console these days) and add both to
config.yml:
twilio_api_key: SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
twilio_api_secret: <the-secret>
If you're configuring via environment variables (i.e. using
logrole_write_config_from_env), set TWILIO_API_KEY and
TWILIO_API_SECRET.
3. A TwiML Application pointing at /dial/voice
Browser-initiated calls don't hit our /dial/voice URL directly. The
Voice SDK refers to a TwiML Application by SID, and that
application's "Voice URL" is what Twilio fetches. So we need a TwiML App
whose Voice URL is https://<your-logrole-host>/dial/voice (or
https://<host><base_path>/dial/voice if logrole is mounted under a
URL prefix).
Logrole ships a helper that creates the app for you. Once steps 1 and 2 above are done, run:
logrole_create_twiml_app --config=config.yml
It reads AccountSid/AuthToken/public_host/base_path from your config, calls the Twilio REST API, and prints the new SID to stdout in YAML form:
twilio_twiml_app_sid: APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Paste that line into config.yml. If you'd rather create the TwiML App
by hand, the Twilio console works fine — just set its Voice URL to
https://<public_host><base_path>/dial/voice with HTTP method POST.
To rotate later, run again with --twiml-app-sid=AP... to update the
existing app's Voice URL in place.
4. Grant can_make_calls to your users
can_make_calls defaults to true for all users. If you've defined
custom permission groups, add it to each group that should
be allowed to dial:
policy:
- name: support
permissions:
can_make_calls: true
# ...
Verifying it works
Once all four values (default_sending_phone_number, twilio_api_key,
twilio_api_secret, twilio_twiml_app_sid) are set, the "Place a call
from your browser" link appears at the top of the Calls page for users
with can_make_calls. The /dial page should say "Ready (identity
browser-...)" once it has fetched a token and initialized the SDK.
A handful of things commonly go wrong on first install:
-
403 on /dial/voice from Twilio: the
X-Twilio-Signaturedoesn't match. The signature is computed over the exact URL Twilio sent the request to. If a proxy in front of logrole rewrites the path or host, or if you set the TwiML App's Voice URL tohttp://but Twilio is reaching logrole overhttps://, the verifier will reject. Use thesigned_urlfield in logrole's warning log to see what URL it tried to verify against, and adjust either the proxy orpublic_hostso they agree. -
"Could not initialize: token request failed: 403": the user doesn't have
can_make_calls. -
No microphone permission: browsers gate getUserMedia behind a permission prompt and require a secure context (HTTPS or localhost). Calls placed over plain HTTP from a non-localhost origin won't work.
-
Static assets missing on /static/js/twilio-voice-sdk.js: the JS bundle is built at
make assetstime. If yougo install ./...without runningmake assetsfirst, the embedded bindata won't contain it. Runmake assets(which runsnpm ciand esbuild) and rebuild.
Permissions reference
Add to policy entries or policy_file:
can_make_calls: true # or false; defaults to true
The can_make_calls permission gates /dial and /dial/token.
There's no permission gate on /dial/voice — it's authenticated by
the X-Twilio-Signature header instead.
Settings reference
| YAML key | Environment var | Description |
|---|---|---|
twilio_api_key | TWILIO_API_KEY | API Key SID (SK...). Required to enable browser calling. |
twilio_api_secret | TWILIO_API_SECRET | API Key secret. Required. |
twilio_twiml_app_sid | TWILIO_TWIML_APP_SID | TwiML App SID (AP...). Required. |
default_sending_phone_number | n/a (no env mapping) | E.164 caller ID. Required. |
If any of those four are missing, the routes are not registered and the
"Place a call" link is hidden. Logrole logs Browser calling disabled: set ... at startup so you can confirm.
Embedding the dialer in another Go app
If you're running another app — say, a personal home server — and want
the same dialer there without running logrole, import the
browsercall package:
import "github.com/kevinburke/logrole/browsercall"
h, err := browsercall.New(browsercall.Config{
AccountSid: "AC...",
APIKey: "SK...",
APISecret: "...",
TwiMLAppSid: "AP...",
AuthToken: "...",
CallerID: "+14155550199",
ScriptURL: "/static/twilio-voice-sdk.js",
Authorize: yourAuthFn, // returns false to send 403
})
if err != nil { /* ... */ }
mux.Handle("/phone/", http.StripPrefix("/phone", h))
The package routes /new, /token, and /voice under whatever prefix
it's mounted at. By default the dialer page is a complete self-contained
HTML document; pass Config.DialerLayout to wrap the dialer fragment
in your own app's chrome instead.
Two things you have to supply yourself:
-
The Twilio SDK bundle. The package does not ship a copy of
@twilio/voice-sdk; the embedder is responsible for bundling it (e.g. via esbuild) and serving the result atConfig.ScriptURL. Look at this repo'spackage.json+js/twilio-voice-sdk.jsfor a minimal example. -
Authentication for /new and /token. The bundled handler does not enforce a session; supply
Config.Authorize(afunc(*http.Request) bool) or front the routes with your own auth middleware. The/voicewebhook is independently authenticated byX-Twilio-SignatureagainstConfig.AuthToken—Authorizeis not consulted there.
A few optional config knobs worth noting:
Config.TokenIdentity func(*http.Request) string— by default each token gets a fresh random identity. Override to bind tokens to a known authenticated user.Config.PublicHost— set when reverse proxies rewrite theHostheader, soX-Twilio-Signatureverification has the right URL.Config.AllowHTTP— accepthttp://URLs in the signature check. For local development only.
Embedding the dialer in an existing page
Two integration patterns:
-
Dedicated route, your chrome. Set
Config.DialerLayoutto a function that wraps the rendered fragment in your app's header, navigation, etc. The bundledHandlerstill owns the URL but the page looks like the rest of your site. logrole uses this internally to keep its navbar. -
Inline in an existing page. Use
Handler.Fragment(tokenPath)to render just the dialer's body HTML (the form, status pane, and SDK<script>tag). Splice it into a larger template you render yourself. You still need to mount theHandlersomewhere so its/tokenand/voiceroutes are reachable —tokenPathshould point at that mount's/token.
See the godoc examples for runnable snippets:
ExampleNew, ExampleHandler_Fragment, and
ExampleConfig_DialerLayout.
See godoc for the complete API.
Cost and rate limits
Each browser-placed call is a regular Twilio outbound call — the same per-minute rate as any other outbound call from the same number. Twilio's terms of service and acceptable use policy apply.