Build a WebRTC Softphone with Go
Build a browser-based SIP phone with Go, JsSIP, and Asterisk.
Introduction
30 Second Summary
Every time you join a browser-based call or click a "call" button on a website, protocols like SIP and WebRTC are working behind the scenes.
In this project, you will build a browser-based softphone that connects to a local Asterisk PBX using WebRTC. You'll create a Go HTTP server, configure SIP endpoints, and wire up a dial pad that makes real audio calls between browser tabs.
What You'll Build
A browser-based WebRTC softphone served by a Go backend that registers to a local Asterisk PBX via SIP over WebSocket and can make audio calls between extensions.
By the end of this project, you'll have:
- A Go HTTP server that serves a static web client on port 8080.
- A fully configured Asterisk PBX with WebSocket transport and WebRTC-enabled PJSIP endpoints.
- A browser-based softphone with a dial pad that can make and receive audio calls using JsSIP.
- Secret Mission: Add DTMF tone support so pressing dial pad buttons during an active call sends digits to the far end.
Are there any prerequisites?
You'll need basic familiarity with Go and web development (HTML/JS). You also need access to a Linux machine (or WSL2 on Windows) because Asterisk runs on Linux.
Set Up Go and Asterisk
Before you can build the softphone, you need two foundational tools installed on your machine. Go is the language your HTTP server will be written in. Asterisk is the open-source PBX that handles all SIP call routing and WebRTC media bridging.
In this step you will install both tools, start the Asterisk service, and enable its built-in HTTP server so it can accept WebSocket connections from your browser client later.
In this step, get ready to:
- Install Go and verify it with go version.
- Install Asterisk on Linux (or WSL2) and verify it with asterisk -V.
- Enable the Asterisk HTTP server for WebSocket connections on port 8088.
Install Go
Go is the programming language you will use to build the HTTP server that serves your softphone client. You need the Go toolchain installed so you can compile and run Go programs.
- Check whether Go is already installed by running this command in your terminal:
go version
✔️ I see a version number
Go is installed and ready. You can move on to installing Asterisk.
ⓧ Command not found
Go is not installed yet. Follow the instructions below for your operating system.
macOS
- Open https://go.dev/dl/ in your browser.
- Download the macOS .pkg installer for your architecture (Apple Silicon or Intel).
- Open the downloaded .pkg file and follow the prompts. It installs Go to /usr/local/go and adds it to your PATH automatically.
- Open a new Terminal window and verify by running:
go version
You should see a version number printed, confirming Go is installed.
Windows
- Open https://go.dev/dl/ in your browser and download the Windows MSI installer.
- Run the MSI installer and follow the prompts. It adds Go to your PATH automatically.
- Open a new Command Prompt or PowerShell window and verify by running:
go version
You should see a version number printed, confirming Go is installed.
Linux
- Open https://go.dev/dl/ in your browser and download the Linux tarball for your architecture (amd64 is most common).
- Extract the tarball to /usr/local by running this command (replace the filename with your downloaded file):
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf ~/Downloads/go*.linux-amd64.tar.gz
- Add Go to your PATH by running:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile
source ~/.profile
- Verify the installation by running:
go version
You should see a version number printed, confirming Go is installed.
Install Asterisk on Linux or WSL2
Asterisk is a Linux application that acts as your PBX, handling SIP registration and routing audio between callers. If you are on Windows, you will run Asterisk inside WSL2 (Windows Subsystem for Linux).
What is WSL2?
WSL2 gives Windows users a full Linux environment without a separate virtual machine. Asterisk requires Linux, so Windows users should open their WSL2 Ubuntu terminal for all Asterisk commands in this project.
- Install Asterisk on Ubuntu/Debian by running:
sudo apt update && sudo apt install -y asterisk
What does this command do?
The first part (sudo apt update) refreshes your package list so you get the latest available version. The second part (sudo apt install -y asterisk) installs Asterisk and all its dependencies.
The -y flag auto-confirms the installation prompt so you do not have to type "yes" manually.
- Verify the installation by running:
asterisk -V
✔️ I see a version number
Asterisk is installed. You can move on to enabling the HTTP server.
ⓧ Command not found or an error
- Make sure you ran the install command above without typos.
- If you see a permissions error, ensure you are using sudo.
- On WSL2, make sure you are inside the Ubuntu terminal, not PowerShell.
- Start the Asterisk service by running:
sudo systemctl start asterisk
- Open the Asterisk CLI to confirm the required modules are loaded by running:
sudo asterisk -rvvv
- Inside the Asterisk CLI, verify the PJSIP and WebSocket modules are loaded by running:
module show like res_pjsip.so
You should see a line showing res_pjsip.so with a status of Running. Now check the WebSocket modules:
module show like res_pjsip_transport_websocket.so
module show like res_http_websocket.so
Both modules should show Running status. These three modules enable Asterisk to handle SIP over WebSocket connections from your browser.
Module not showing as Running?
If a module is missing, it may not be included in your Asterisk package. On Debian/Ubuntu, the standard asterisk package includes all PJSIP modules. If you compiled from source, ensure you enabled PJSIP and HTTP WebSocket support during the build.
help me troubleshoot missing Asterisk PJSIP modules
- Exit the Asterisk CLI by typing:
exit
Enable the Asterisk HTTP server for WebSocket
Asterisk has a built-in HTTP server that your browser will connect to over WebSocket. You need to enable it and configure it to listen on port 8088.
- Open the Asterisk HTTP configuration file in a text editor by running:
sudo nano /etc/asterisk/http.conf
- Replace the contents of the file (or add the following under the [general] section) with this configuration:
[general]
enabled=yes
bindaddr=0.0.0.0
bindport=8088
What does this configuration do?
- enabled=yes turns on the built-in HTTP server so Asterisk can serve WebSocket connections.
- bindaddr=0.0.0.0 tells the server to listen on all network interfaces.
- bindport=8088 sets the listening port. Your browser's JsSIP client will connect to ws://localhost:8088/ws later in the project.
- Save the file by pressing Ctrl+O then Enter, and exit nano by pressing Ctrl+X.
- Reload the Asterisk HTTP module to apply the changes by running:
sudo asterisk -rx "module reload http"
- Verify the HTTP server is active by opening the Asterisk CLI:
sudo asterisk -rvvv
- Inside the CLI, check the HTTP server status by running:
http show status
You should see output confirming the HTTP server is enabled on port 8088. Exit the CLI by typing exit.
✔️ Awesome, I've got everything!
Great. Your Go installation is verified, Asterisk is running, and the HTTP server is listening on port 8088 for WebSocket connections.
ⓧ I'd like to double check the full code
Here is the complete http.conf file you should have:
[general]
enabled=yes
bindaddr=0.0.0.0
bindport=8088
HTTP server not showing as enabled?
Make sure enabled=yes is directly under the [general] heading with no leading spaces or commented-out lines above it.
If the reload did not pick up your changes, restart Asterisk entirely:
sudo systemctl restart asterisk
help me fix my Asterisk HTTP server configuration
Your development environment is ready. Next up, you will configure Asterisk's PJSIP engine with WebRTC-enabled endpoints so your browser clients can register and make calls.
Configure Asterisk for WebRTC
In the previous step you installed Go and Asterisk, and confirmed that the HTTP server is listening on port 8088. Now it's time to tell Asterisk how to handle browser-based phone calls.
For a browser to call another browser through Asterisk, three pieces need to be in place: a WebSocket transport so browsers can reach Asterisk, PJSIP endpoints that represent each phone, and a dialplan that routes calls between them.
In this step, get ready to:
- Configure a PJSIP WebSocket transport and two WebRTC-enabled endpoints.
- Write a dialplan that routes calls between extensions 1001 and 1002.
- Reload Asterisk and verify your configuration from the CLI.
Configure PJSIP WebSocket transport and endpoints
Asterisk needs to know that browsers will connect over WebSocket instead of regular UDP. It also needs an endpoint definition for each softphone that describes how to handle authentication and media encryption.
- Open /etc/asterisk/pjsip.conf in a text editor with root permissions by running:
sudo nano /etc/asterisk/pjsip.conf
- Replace the entire contents of the file with the following configuration:
; WebSocket transport - allows browser clients to connect
[transport-ws]
type=transport
protocol=ws
bind=0.0.0.0
; Endpoint for extension 1001
[1001]
type=endpoint
transport=transport-ws
context=webrtc
disallow=all
allow=ulaw
allow=alaw
webrtc=yes
dtls_auto_generate_cert=yes
auth=auth1001
aors=1001
[auth1001]
type=auth
auth_type=userpass
username=1001
password=1001pass
[1001]
type=aor
max_contacts=1
; Endpoint for extension 1002
[1002]
type=endpoint
transport=transport-ws
context=webrtc
disallow=all
allow=ulaw
allow=alaw
webrtc=yes
dtls_auto_generate_cert=yes
auth=auth1002
aors=1002
[auth1002]
type=auth
auth_type=userpass
username=1002
password=1002pass
[1002]
type=aor
max_contacts=1
What does this configuration do?
The [transport-ws] section defines a WebSocket transport bound to all interfaces. This is what lets browser clients reach Asterisk's SIP stack over the HTTP WebSocket path (/ws on port 8088).
Each endpoint (1001 and 1002) uses webrtc=yes, which is a shortcut that enables DTLS-SRTP encryption, ICE, RTCP multiplexing, and AVPF. These are all requirements for WebRTC audio to flow correctly.
The auth sections define username and password credentials. The AOR (Address of Record) sections limit each endpoint to one active registration at a time via max_contacts=1.
It is normal for multiple sections in pjsip.conf to share the same name as long as the type value differs. The [1001] name appears twice because one is an endpoint and the other is an AOR.
- Save the file and exit the editor (in nano, press Ctrl+O then Enter to save, then Ctrl+X to exit).
You won't see a change yet. The configuration takes effect after reloading Asterisk in the verification substep.
✔️ Awesome, I've got everything!
Great. Make sure you saved pjsip.conf before moving on.
ⓧ I'd like to double check the full code
; WebSocket transport - allows browser clients to connect
[transport-ws]
type=transport
protocol=ws
bind=0.0.0.0
; Endpoint for extension 1001
[1001]
type=endpoint
transport=transport-ws
context=webrtc
disallow=all
allow=ulaw
allow=alaw
webrtc=yes
dtls_auto_generate_cert=yes
auth=auth1001
aors=1001
[auth1001]
type=auth
auth_type=userpass
username=1001
password=1001pass
[1001]
type=aor
max_contacts=1
; Endpoint for extension 1002
[1002]
type=endpoint
transport=transport-ws
context=webrtc
disallow=all
allow=ulaw
allow=alaw
webrtc=yes
dtls_auto_generate_cert=yes
auth=auth1002
aors=1002
[auth1002]
type=auth
auth_type=userpass
username=1002
password=1002pass
[1002]
type=aor
max_contacts=1
Seeing permission denied?
Make sure you opened the file with sudo. Asterisk config files in /etc/asterisk/ are owned by root.
help me fix permission errors editing Asterisk config files
Configure the dialplan
The dialplan tells Asterisk what to do when a call arrives for a given extension number. You need a context that matches the context=webrtc setting in your endpoints.
- Open /etc/asterisk/extensions.conf by running:
sudo nano /etc/asterisk/extensions.conf
- Add the following context at the bottom of the file:
[webrtc]
exten => 1001,1,Dial(PJSIP/1001)
exten => 1002,1,Dial(PJSIP/1002)
What does this dialplan do?
The [webrtc] context name matches the context=webrtc value on each endpoint. When a call comes in from one of your WebRTC phones, Asterisk looks in this context for routing instructions.
Each exten => line says: when someone dials extension 1001, ring the PJSIP endpoint named 1001. The same logic applies to 1002. This creates the simplest possible two-party calling setup.
- Save the file and exit the editor.
You won't see a change yet. The next substep reloads the config and verifies everything.
✔️ Awesome, I've got everything!
Great. Make sure you saved extensions.conf before moving on.
ⓧ I'd like to double check the full code
[webrtc]
exten => 1001,1,Dial(PJSIP/1001)
exten => 1002,1,Dial(PJSIP/1002)
Verify the configuration
Now reload Asterisk so it picks up the new config files, then confirm both endpoints and the WebSocket transport are recognized.
- Reload the Asterisk configuration by running:
sudo asterisk -rx "core reload"
- Check that both endpoints are listed by running:
sudo asterisk -rx "pjsip show endpoints"
- You should see 1001 and 1002 in the output with their AORs listed.
- Check that the WebSocket transport is active by running:
sudo asterisk -rx "pjsip show transports"
- You should see transport-ws listed with protocol ws.
Endpoints not showing up?
Double check that pjsip.conf has no typos in the section names or type= values. A misspelled type=endpoint or missing type=auth will cause Asterisk to silently skip the section.
If you see an error about the transport, make sure the res_pjsip_transport_websocket module is loaded. You confirmed this in Step 1.
help me troubleshoot missing PJSIP endpoints in Asterisk
Your Asterisk PBX is now fully configured for WebRTC calls. Next up, you'll build the Go HTTP server that serves the browser-based softphone client.
Build the Go HTTP Server
Your Asterisk PBX is configured and ready to handle WebRTC calls. Now you need a way to deliver the browser-based softphone to users.
Go's standard library includes a production-ready HTTP file server. In this step, you'll use it to serve static web files that will become your softphone client.
In this step, get ready to:
- Initialize a Go module for the project.
- Create an HTTP server that serves static files.
- Test that the server delivers a placeholder page to the browser.
Initialize the Go project and create the server
Every Go project starts with a module. The module name acts as the import path for your packages.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir webrtc-phone
cd webrtc-phone
- Initialize the Go module by running:
go mod init webrtc-phone
- Confirm the files exist by running:
ls
You should see go.mod in the output. This file tracks your module name and Go version.
Don't see go.mod?
Make sure you ran go mod init from inside the webrtc-phone directory. Run pwd to check your current location.
If go is not recognized, revisit Step 1 to ensure Go is installed and on your PATH.
help me debug why go mod init isn't creating a go.mod file
Now you'll create the server itself. Go's net/http package provides http.FileServer, which serves files from a directory with zero external dependencies.
- Create a file called main.go in your webrtc-phone folder, then paste the following code into it:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
// Serve all files from the ./static directory
fs := http.FileServer(http.Dir("./static"))
http.Handle("/", fs)
// Start the server on port 8080
fmt.Println("Server started on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
What does this code do?
- http.FileServer(http.Dir("./static")) creates a handler that serves any file found in the static directory relative to where you run the program.
- http.Handle("/", fs) maps all incoming requests to that file server handler.
- log.Fatal(http.ListenAndServe(":8080", nil)) starts listening on port 8080. If the server crashes, log.Fatal prints the error and exits.
- Save main.go.
Why Go instead of Nginx or Apache?
Nginx or Apache could serve static files too. But wrapping the server in Go gives you a single binary you can extend with backend call logic later. You could add call event webhooks, authentication middleware, or a REST API without introducing another tool.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go in the root of your webrtc-phone folder.
ⓧ I'd like to double check the full code
Here is the complete main.go file:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
fs := http.FileServer(http.Dir("./static"))
http.Handle("/", fs)
fmt.Println("Server started on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Create the static directory structure
The file server points at a ./static directory that doesn't exist yet. You need to create it and add a placeholder page so the server has something to serve.
- Create the static directory by running:
mkdir static
- Create a placeholder index.html file inside the static folder, then paste the following code into it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Softphone</title>
</head>
<body>
<h1>WebRTC Softphone</h1>
<p>Placeholder page - phone UI coming soon.</p>
</body>
</html>
What does this file do?
This is a temporary page that proves your server can deliver HTML to the browser. You'll replace it with the full softphone UI in the next step.
- Save static/index.html.
- Confirm your file structure by running:
ls static
You should see index.html in the output.
Test the server in the browser
Everything is in place. Time to start the server and confirm it delivers your placeholder page.
- Start the Go server by running:
go run main.go
You should see this output in your terminal:
Server started on http://localhost:8080
- Open a web browser (Chrome or Firefox).
- Navigate to http://localhost:8080 in the address bar.
You should see a page with the heading "WebRTC Softphone" and the text "Placeholder page - phone UI coming soon."
Don't see the page?
- Check your terminal for error messages. If you see a "no such file or directory" error for ./static, make sure you created the static folder inside webrtc-phone (not somewhere else).
- If port 8080 is already in use, you'll see an "address already in use" error. Stop whatever process is using port 8080, or change ":8080" in main.go to another port like ":8081".
- Make sure the browser URL is http://localhost:8080 (not https).
help me troubleshoot why my Go server isn't serving the page on localhost:8080
Stop the server when done
Press Ctrl+C in the terminal to stop the server. You'll restart it later after building the full UI.
Your Go server is running and serving files. Next up, you'll build the actual softphone UI with HTML, CSS, and JsSIP.
Build the WebRTC Softphone Client
Your Go server is up and Asterisk is configured with WebRTC-enabled endpoints. Now it is time to build the actual phone that runs in the browser.
In this step, you will create an HTML dial pad interface, style it with CSS, and wire it up to Asterisk using JsSIP. By the end, two browser tabs will be able to call each other through your local PBX.
In this step, get ready to:
- Build the softphone HTML structure with a dial pad, status display, and call controls.
- Style the phone with a dark theme, 3-column grid dial pad, and visual call states.
- Integrate JsSIP to register with Asterisk and make browser-to-browser audio calls.
Create the HTML structure
The softphone needs a status display, a number input, a 12-button dial pad (0-9, *, #), call and hangup controls, and a hidden audio element for receiving remote media.
- Open static/index.html in your editor and replace all of the placeholder content with the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Softphone</title>
<!-- Link to the stylesheet we'll create next -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="phone">
<h2>WebRTC Phone</h2>
<!-- Status display shows registration and call state -->
<div id="status" class="status">Disconnected</div>
<!-- Number input where dialed digits appear -->
<input type="text" id="number" placeholder="Enter extension" readonly>
<!-- 12-button dial pad grid -->
<div class="dialpad">
<button class="digit" data-digit="1">1</button>
<button class="digit" data-digit="2">2</button>
<button class="digit" data-digit="3">3</button>
<button class="digit" data-digit="4">4</button>
<button class="digit" data-digit="5">5</button>
<button class="digit" data-digit="6">6</button>
<button class="digit" data-digit="7">7</button>
<button class="digit" data-digit="8">8</button>
<button class="digit" data-digit="9">9</button>
<button class="digit" data-digit="*">*</button>
<button class="digit" data-digit="0">0</button>
<button class="digit" data-digit="#">#</button>
</div>
<!-- Call controls -->
<div class="controls">
<button id="callBtn" class="call-btn">Call</button>
<button id="hangupBtn" class="hangup-btn" disabled>Hang Up</button>
</div>
<!-- Hidden audio element receives the remote media stream -->
<audio id="remoteAudio" autoplay></audio>
</div>
<!-- JsSIP library for SIP over WebSocket -->
<script src="https://cdn.jsdelivr.net/npm/jssip@3.13.8/dist/jssip.min.js"></script>
<!-- Our phone logic -->
<script src="phone.js"></script>
</body>
</html>
What does this HTML do?
The div.phone container holds the entire phone UI. The #status div will show whether you are registered with Asterisk or on an active call.
The data-digit attribute on each button stores which digit it represents. JavaScript reads this attribute when a button is clicked.
The hidden <audio> element is where WebRTC attaches the remote caller's audio stream so you can hear them.
- Save static/index.html.
- Refresh http://localhost:8080 in your browser. You should see the text "WebRTC Phone", a "Disconnected" status bar, the dial pad buttons, and the Call/Hang Up controls. They will not be styled yet.
Page still shows the old placeholder?
Make sure you saved the file and did a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows). If the page is blank, check your terminal for errors from the Go server.
Help me debug why my HTML page is not loading.
Add CSS styling
The phone needs a dark theme with a centered card layout, a 3-column grid for the dial pad, and color-coded status indicators for registration and in-call states.
- Create a new file called style.css inside your static folder by running:
touch static/style.css
- Open static/style.css in your editor and add the base layout styles:
/* Reset default margins and padding */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Center the phone card on a dark background */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
/* Phone container card */
.phone {
background: #16213e;
border-radius: 20px;
padding: 30px;
width: 320px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.phone h2 {
color: #e94560;
text-align: center;
margin-bottom: 15px;
}
/* Status bar with color states */
.status {
color: #a0a0a0;
text-align: center;
margin-bottom: 15px;
font-size: 14px;
padding: 8px;
border-radius: 8px;
background: #0f3460;
}
/* Green text when registered */
.status.registered {
color: #4caf50;
}
/* Orange text during a call */
.status.in-call {
color: #ff9800;
}
/* Extension number input */
#number {
width: 100%;
padding: 12px;
font-size: 24px;
text-align: center;
border: none;
border-radius: 10px;
background: #0f3460;
color: #ffffff;
margin-bottom: 15px;
}
What do these styles do?
The reset removes browser defaults so the layout is consistent. The body flexbox centers the phone card vertically and horizontally on a dark navy background.
The .status.registered and .status.in-call classes swap the status bar text color. JavaScript will toggle these classes based on the phone's connection state.
- Save static/style.css and refresh your browser.
- You should see a dark page with a centered navy card. The title, status bar, and number input are visible, but the buttons below are still unstyled.
Styles not applying?
Check that style.css is saved in the static folder (not in the project root). The <link> tag in your HTML references href="style.css" which resolves relative to the HTML file's location.
Help me figure out why my CSS is not loading.
- Below the #number styles in static/style.css, add the dial pad and controls styles:
/* 3-column grid for the 12 dial pad buttons */
.dialpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 15px;
}
/* Round digit buttons */
.digit {
padding: 18px;
font-size: 20px;
border: none;
border-radius: 50%;
background: #0f3460;
color: #ffffff;
cursor: pointer;
transition: background 0.2s;
}
.digit:hover {
background: #e94560;
}
.digit:active {
background: #c0392b;
}
/* Call and Hang Up button row */
.controls {
display: flex;
gap: 10px;
}
.call-btn,
.hangup-btn {
flex: 1;
padding: 14px;
font-size: 16px;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: bold;
}
.call-btn {
background: #4caf50;
color: white;
}
.call-btn:hover {
background: #45a049;
}
.hangup-btn {
background: #f44336;
color: white;
}
.hangup-btn:hover {
background: #d32f2f;
}
/* Disabled state for buttons */
.hangup-btn:disabled,
.call-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
What do these styles do?
The .dialpad grid lays out the 12 buttons in 3 columns automatically. Each .digit button is a circle (border-radius: 50%) that turns red on hover.
The green Call button and red Hang Up button sit side by side in a flex row. The :disabled state dims whichever button is not active so the learner knows which action is available.
- Save static/style.css and refresh your browser.
- You should see a fully styled phone card with round dial pad buttons in a 3x4 grid, a green Call button, and a dimmed red Hang Up button.
Buttons look wrong or misaligned?
Make sure the CSS for .dialpad comes after the #number block. Also verify you have exactly 12 <button> elements with class="digit" in your HTML. The grid expects 12 items to fill 4 rows of 3.
Help me fix my dial pad layout.
✔️ Awesome, I've got everything!
Great. Make sure you have saved static/style.css before moving on to the JavaScript.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.phone {
background: #16213e;
border-radius: 20px;
padding: 30px;
width: 320px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.phone h2 {
color: #e94560;
text-align: center;
margin-bottom: 15px;
}
.status {
color: #a0a0a0;
text-align: center;
margin-bottom: 15px;
font-size: 14px;
padding: 8px;
border-radius: 8px;
background: #0f3460;
}
.status.registered {
color: #4caf50;
}
.status.in-call {
color: #ff9800;
}
#number {
width: 100%;
padding: 12px;
font-size: 24px;
text-align: center;
border: none;
border-radius: 10px;
background: #0f3460;
color: #ffffff;
margin-bottom: 15px;
}
.dialpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 15px;
}
.digit {
padding: 18px;
font-size: 20px;
border: none;
border-radius: 50%;
background: #0f3460;
color: #ffffff;
cursor: pointer;
transition: background 0.2s;
}
.digit:hover {
background: #e94560;
}
.digit:active {
background: #c0392b;
}
.controls {
display: flex;
gap: 10px;
}
.call-btn,
.hangup-btn {
flex: 1;
padding: 14px;
font-size: 16px;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: bold;
}
.call-btn {
background: #4caf50;
color: white;
}
.call-btn:hover {
background: #45a049;
}
.hangup-btn {
background: #f44336;
color: white;
}
.hangup-btn:hover {
background: #d32f2f;
}
.hangup-btn:disabled,
.call-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Integrate JsSIP for SIP signaling
JsSIP is a JavaScript library that speaks SIP over WebSocket. It connects your browser to Asterisk's WebSocket endpoint, registers as a SIP extension, and manages call sessions using WebRTC for the audio.
- Create a new file called phone.js inside your static folder by running:
touch static/phone.js
- Open static/phone.js in your editor and add the setup and registration code:
(function () {
// Cache DOM references
var numberInput = document.getElementById("number");
var statusEl = document.getElementById("status");
var callBtn = document.getElementById("callBtn");
var hangupBtn = document.getElementById("hangupBtn");
var remoteAudio = document.getElementById("remoteAudio");
var digitButtons = document.querySelectorAll(".digit");
// Track the active call session
var currentSession = null;
// Connect to Asterisk's WebSocket endpoint
var socket = new JsSIP.WebSocketInterface("ws://localhost:8088/ws");
var configuration = {
sockets: [socket],
uri: "sip:1001@localhost",
password: "1001pass",
display_name: "Extension 1001"
};
// Create the SIP User Agent
var ua = new JsSIP.UA(configuration);
// Update status display on registration events
ua.on("registered", function () {
statusEl.textContent = "Registered";
statusEl.className = "status registered";
});
ua.on("unregistered", function () {
statusEl.textContent = "Unregistered";
statusEl.className = "status";
});
ua.on("registrationFailed", function (e) {
statusEl.textContent = "Registration Failed: " + e.cause;
statusEl.className = "status";
});
What does this code do?
The entire file is wrapped in an IIFE (immediately invoked function expression) to keep variables out of the global scope.
The JsSIP.WebSocketInterface points to Asterisk's HTTP server on port 8088 at the /ws path. The configuration object tells JsSIP which SIP identity to use (extension 1001) and the password you set in pjsip.conf.
The three registration event handlers update the on-screen status so you can see whether the phone is connected to Asterisk.
You will not see a change yet because ua.start() has not been called. The next chunks add the call handling logic and start the UA.
- Below the registrationFailed handler, add the incoming/outgoing call session handler:
// Handle new incoming or outgoing call sessions
ua.on("newRTCSession", function (data) {
var session = data.session;
// Reject if already in a call
if (currentSession) {
session.terminate();
return;
}
currentSession = session;
// Attach remote audio stream when the peer connection is ready
session.on("peerconnection", function (e) {
var pc = e.peerconnection;
pc.addEventListener("track", function (ev) {
if (ev.streams && ev.streams[0]) {
remoteAudio.srcObject = ev.streams[0];
}
});
});
// Auto-answer incoming calls
if (data.originator === "remote") {
statusEl.textContent = "Incoming call from " + session.remote_identity.uri.user;
statusEl.className = "status in-call";
session.answer({ mediaConstraints: { audio: true, video: false } });
}
session.on("accepted", function () {
statusEl.textContent = "In Call";
statusEl.className = "status in-call";
callBtn.disabled = true;
hangupBtn.disabled = false;
});
session.on("ended", function () {
statusEl.textContent = "Call Ended";
statusEl.className = "status registered";
callBtn.disabled = false;
hangupBtn.disabled = true;
currentSession = null;
});
session.on("failed", function (e) {
statusEl.textContent = "Call Failed: " + e.cause;
statusEl.className = "status registered";
callBtn.disabled = false;
hangupBtn.disabled = true;
currentSession = null;
});
});
What does the session handler do?
The newRTCSession event fires for both outgoing calls you make and incoming calls from other extensions. If you are already on a call, it rejects the new one.
The peerconnection event gives access to the underlying WebRTC peer connection. When a remote audio track arrives, it gets attached to the hidden <audio> element so you hear the caller.
For incoming calls (originator === "remote"), the phone auto-answers immediately. In a production app you would show an "Accept" button instead.
Still no visible change. The final chunk wires up the UI buttons and starts the User Agent.
- Below the newRTCSession handler, add the UI event listeners and start the UA to complete the file:
// Dial pad: append digit to the number input
digitButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
var digit = btn.getAttribute("data-digit");
numberInput.value += digit;
});
});
// Call button: initiate an outgoing call to the dialed extension
callBtn.addEventListener("click", function () {
var target = numberInput.value.trim();
if (!target) return;
var options = {
mediaConstraints: { audio: true, video: false },
rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false }
};
ua.call("sip:" + target + "@localhost", options);
statusEl.textContent = "Calling " + target + "...";
statusEl.className = "status in-call";
callBtn.disabled = true;
hangupBtn.disabled = false;
});
// Hang Up button: terminate the active session
hangupBtn.addEventListener("click", function () {
if (currentSession) {
currentSession.terminate();
}
});
// Start the User Agent (connects to Asterisk and registers)
ua.start();
})();
What does this code do?
The digit button listeners read the data-digit attribute and append it to the number input field. When you click Call, ua.call() initiates a SIP INVITE to the target extension through Asterisk.
The ua.start() call at the bottom opens the WebSocket connection and sends a SIP REGISTER message to Asterisk. Once registered, your browser is a live SIP endpoint.
- Save static/phone.js.
- Refresh http://localhost:8080 in your browser. The status bar should change from "Disconnected" to "Registered" in green within a few seconds.
Status stays Disconnected or shows Registration Failed?
Verify that Asterisk is running and its HTTP server is active on port 8088. From the Asterisk CLI, run http show status to confirm. Also check that the password in phone.js (1001pass) matches what you configured in pjsip.conf.
Open the browser's Developer Tools console (F12) to look for WebSocket connection errors. If you see a connection refused message, Asterisk's HTTP server may not be listening on the correct address.
Help me debug why my softphone won't register with Asterisk.
✔️ Awesome, I've got everything!
Great. Make sure static/phone.js is saved and you see "Registered" in green.
ⓧ I'd like to double check the full code
(function () {
var numberInput = document.getElementById("number");
var statusEl = document.getElementById("status");
var callBtn = document.getElementById("callBtn");
var hangupBtn = document.getElementById("hangupBtn");
var remoteAudio = document.getElementById("remoteAudio");
var digitButtons = document.querySelectorAll(".digit");
var currentSession = null;
var socket = new JsSIP.WebSocketInterface("ws://localhost:8088/ws");
var configuration = {
sockets: [socket],
uri: "sip:1001@localhost",
password: "1001pass",
display_name: "Extension 1001"
};
var ua = new JsSIP.UA(configuration);
ua.on("registered", function () {
statusEl.textContent = "Registered";
statusEl.className = "status registered";
});
ua.on("unregistered", function () {
statusEl.textContent = "Unregistered";
statusEl.className = "status";
});
ua.on("registrationFailed", function (e) {
statusEl.textContent = "Registration Failed: " + e.cause;
statusEl.className = "status";
});
ua.on("newRTCSession", function (data) {
var session = data.session;
if (currentSession) {
session.terminate();
return;
}
currentSession = session;
session.on("peerconnection", function (e) {
var pc = e.peerconnection;
pc.addEventListener("track", function (ev) {
if (ev.streams && ev.streams[0]) {
remoteAudio.srcObject = ev.streams[0];
}
});
});
if (data.originator === "remote") {
statusEl.textContent = "Incoming call from " + session.remote_identity.uri.user;
statusEl.className = "status in-call";
session.answer({ mediaConstraints: { audio: true, video: false } });
}
session.on("accepted", function () {
statusEl.textContent = "In Call";
statusEl.className = "status in-call";
callBtn.disabled = true;
hangupBtn.disabled = false;
});
session.on("ended", function () {
statusEl.textContent = "Call Ended";
statusEl.className = "status registered";
callBtn.disabled = false;
hangupBtn.disabled = true;
currentSession = null;
});
session.on("failed", function (e) {
statusEl.textContent = "Call Failed: " + e.cause;
statusEl.className = "status registered";
callBtn.disabled = false;
hangupBtn.disabled = true;
currentSession = null;
});
});
digitButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
var digit = btn.getAttribute("data-digit");
numberInput.value += digit;
});
});
callBtn.addEventListener("click", function () {
var target = numberInput.value.trim();
if (!target) return;
var options = {
mediaConstraints: { audio: true, video: false },
rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false }
};
ua.call("sip:" + target + "@localhost", options);
statusEl.textContent = "Calling " + target + "...";
statusEl.className = "status in-call";
callBtn.disabled = true;
hangupBtn.disabled = false;
});
hangupBtn.addEventListener("click", function () {
if (currentSession) {
currentSession.terminate();
}
});
ua.start();
})();
Test a browser-to-browser call
To prove the system works end-to-end, you will open a second browser tab registered as extension 1002 and dial it from the first tab.
- Open a second browser tab to http://localhost:8080.
- In the second tab, open the Developer Tools console (F12) and run this command to re-register as extension 1002:
Why a second extension?
Both tabs load the same phone.js which is hardcoded to register as 1001. To simulate a second phone, you override the URI and password in the console. In a production app, you would pass the extension as a parameter or prompt the user.
- In the second tab's console, create a second UA by running:
// Override: register as extension 1002 in this tab
var socket2 = new JsSIP.WebSocketInterface("ws://localhost:8088/ws");
var ua2 = new JsSIP.UA({
sockets: [socket2],
uri: "sip:1002@localhost",
password: "1002pass",
display_name: "Extension 1002"
});
ua2.on("registered", function() {
document.getElementById("status").textContent = "Registered as 1002";
document.getElementById("status").className = "status registered";
});
ua2.on("newRTCSession", function(data) {
if (data.originator === "remote") {
data.session.on("peerconnection", function(e) {
e.peerconnection.addEventListener("track", function(ev) {
if (ev.streams && ev.streams[0]) {
document.getElementById("remoteAudio").srcObject = ev.streams[0];
}
});
});
data.session.answer({ mediaConstraints: { audio: true, video: false } });
}
});
ua2.start();
- Wait for the second tab's status to show "Registered as 1002".
- Switch back to the first tab. Use the dial pad to enter 1002, then click Call.
- Your browser will ask permission to use the microphone. Click Allow.
- The first tab's status should change to "In Call" and you should be able to speak and hear audio between the two tabs.
No audio between the tabs?
Browser tabs on the same machine may produce feedback. Use headphones or mute one tab's speakers. The important test is that the status shows "In Call" on both sides and the Asterisk CLI (core show channels) shows active channels.
Call fails or hangs immediately?
In the Asterisk CLI, run pjsip show endpoints and confirm both 1001 and 1002 show a contact. If one is missing, its registration failed silently.
Also check that your extensions.conf dialplan routes extension 1002 to the PJSIP endpoint. A missing or mismatched extension in the [webrtc] context will cause the call to fail.
Help me debug why my browser-to-browser call is not connecting.
You now have a fully functional browser-based softphone. Next up, you will extend it with DTMF tone support in the Secret Mission.
Secret mission
Add DTMF Support
Your softphone can make and receive calls, but what happens when you need to navigate an automated phone menu? Right now, pressing dial pad buttons during a call does nothing useful. Add in-call DTMF digit sending so your phone can interact with IVR systems and other endpoints that listen for tone input.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Asterisk installation and configuration files on your Linux/WSL2 machine (pjsip.conf, extensions.conf, http.conf modifications).
- Go project directory (webrtc-phone) on your local filesystem.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your softphone.
- Asterisk continues running as a local development PBX. You can register additional extensions or modify the dialplan at any time.
- Your Go server keeps serving the softphone client on http://localhost:8080 as long as the process is running.
- Go remains installed on your machine for future projects.
Pause - I'll come back to this later
Shut down running processes to free up system resources while keeping all your files intact.
- Stop Asterisk by running:
- Stop the Go server by pressing Ctrl+C in the terminal where it is running.
sudo systemctl stop asterisk
When you're ready to resume, start Asterisk again with:
sudo systemctl start asterisk
- Then restart your Go server with go run main.go from inside the webrtc-phone directory.
Delete - I don't want to use this again
Remove all project resources and start fresh. This fully uninstalls Asterisk and deletes your project files.
- Stop Asterisk and remove it completely (including configuration files) by running:
sudo systemctl stop asterisk
sudo apt purge -y asterisk
Why purge instead of remove?
Using apt purge removes both the Asterisk binaries and the configuration files under /etc/asterisk/. If you use apt remove instead, the modified config files (pjsip.conf, extensions.conf, http.conf) will remain on your system.
- Delete the Go project directory by running:
rm -rf ~/Desktop/webrtc-phone
- Confirm the directory is gone by running:
ls ~/Desktop/webrtc-phone
You should see a "No such file or directory" message confirming the folder was removed.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional browser-based softphone that makes and receives real audio calls through an Asterisk PBX.
You've learned how to:
- Built a Go HTTP server that serves a static WebRTC softphone client from a single binary.
- Configured Asterisk PJSIP with a WebSocket transport and WebRTC-enabled endpoints for browser-to-browser audio calling.
- Created a browser-based SIP phone with a functional dial pad using JsSIP for signaling and native WebRTC for media.
- Secret Mission: Added in-call DTMF digit sending so dial pad buttons interact with interactive voice menus during active calls.
Ready to quiz yourself?