RFC 8599 push gateway without patching your PBX
pgw sits between the SIP client and your SIP server. It holds each incoming INVITE, wakes the app through APNs, FCM, or Web Push, and forwards the call when the app registers again.
pgw serve | jq -r -f call.jq 21:56:27.568 <- INVITE sip:alice@127.0.0.1:53022;transport=tls SIP/2.0 21:56:27.569 holding invite 21:56:27.570 -> SIP/2.0 100 Trying 21:56:27.609 push call 200 21:56:27.610 -> SIP/2.0 180 Ringing 21:56:27.610 invite held 21:56:27.615 <- REGISTER sip:127.0.0.1 SIP/2.0 21:56:27.618 -> REGISTER sip:127.0.0.1 SIP/2.0 21:56:27.619 <- SIP/2.0 401 Unauthorized 21:56:27.620 -> SIP/2.0 401 Unauthorized 21:56:27.622 <- REGISTER sip:127.0.0.1 SIP/2.0 21:56:27.626 -> REGISTER sip:127.0.0.1 SIP/2.0 21:56:27.628 <- SIP/2.0 200 OK 21:56:27.631 registered 21:56:27.631 -> SIP/2.0 200 OK 21:56:27.633 -> INVITE sip:alice@127.0.0.1:53027;transport=tls SIP/2.0 21:56:27.637 <- SIP/2.0 200 OK 21:56:27.638 -> SIP/2.0 200 OK
Works with
- Asterisk 22
- FreeSWITCH 1.10
- Kamailio
- Linphone
- APNs
- Firebase Cloud Messaging
- Web Push
Calls that wait for the app
iOS and Android suspend a VoIP app seconds after it leaves the screen. A suspended app can’t answer an INVITE, so the call fails. pgw keeps the app registered, and when a call comes in, it wakes the app and waits for it.
The call waits for the app
pgw holds the INVITE, up to 30 seconds by default, while a push wakes the app. The caller hears ringing as soon as Apple or Google accepts the push.
Missed calls still show up
If the caller hangs up first, the app gets a push with the caller’s name and number. If another phone answered, pgw sends nothing.
Every device rings
In managed mode, pgw registers one contact per user and rings the phone, the desk app, and the browser at once. The first device to answer takes the call, and the others stop ringing.
Browsers get calls too
A browser softphone gets a Web Push while its tab is closed. pgw makes the app’s VAPID key itself, so there’s no account to set up.
Passwords stay at your PBX
By default the app answers the PBX’s challenge itself, and pgw never stores a SIP password. If you want pgw to register for each user, managed mode keeps the passwords encrypted.
Push tokens stay private
pgw strips the pn-* parameters before the REGISTER
reaches your PBX. Tokens never appear in a log or an API response.
No code changes on the PBX
Asterisk sends calls back through pgw with a standard Path header. FreeSWITCH and other PBXs without Path reach the app through pgw’s own contact address.
One binary to run
A static Go binary with SQLite built in. It exposes Prometheus metrics and health checks, and records every push it sends.
Configuration in a file
Export a tenant’s apps, PBXs, and users to YAML without secrets, and keep it in Git. One command applies a change, all of it or none of it.
From REGISTER to ringing
Your app adds three RFC 8599 parameters to its REGISTER and reads a JSON push with six fields. You manage the rest from the CLI or the REST API.
REGISTER sip:example.com SIP/2.0 From: <sip:alice@example.com>;tag=1928301774 To: <sip:alice@example.com> Contact: <sip:alice@192.0.2.10:52814;transport=tls> ;pn-provider=apns ;pn-param=ABCDE12345.com.example.phone.voip ;pn-prid=<voip-token> Expires: 3600
Register the app
The app puts its push parameters in the Contact header, the same way Linphone does. pgw saves them and forwards the REGISTER to your PBX without them. The PBX’s challenge goes back to the app, which answers it as usual.
{
"pgw_event": "call",
"call_id": "a84b4c76e66710@pbx.example.com",
"from_uri": "sip:1001@example.com",
"from_display": "Front desk",
"to_aor": "sip:alice@example.com",
"call_str": ""
}
Read the push
On iOS, the fields arrive as top-level keys of a VoIP push. On
Android, they arrive as the data of a high-priority FCM message.
Besides call, pgw sends missed,
refresh, and test events.
# Add an Android app with its Firebase credentials pgw app create --platform android \ --bundle-id com.example.phone \ --fcm-project-id example-phone \ --fcm-project-number 123456789012 \ --fcm-service-account-file service-account.json # List devices, then send one a silent test push pgw binding list pgw binding test-push --binding <key>
Manage it from the CLI
Add apps and PBXs, list registered devices, and send one device a silent test push. The test prints Apple’s or Google’s answer, so a wrong key shows up before a real call does.
curl -X POST http://127.0.0.1:8080/api/v1/apps \ -H "Authorization: Bearer $PGW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "ios", "bundle_id": "com.example.phone", "apns_team_id": "ABCDE12345", "apns_key_id": "KEY1234567", "apns_key_pem": "<contents of the .p8 key>", "apns_environment": "sandbox" }'
Or call the REST API
Everything the CLI does, over JSON. The API listens on
127.0.0.1:8080 by default, and an OpenAPI 3.1 document
describes it.
REGISTER sip:example.com SIP/2.0 Contact: <sip:alice@df7jal23ls0d.invalid;transport=ws> ;pn-provider=webpush ;+sip.instance="<urn:uuid:5b6c1b2e-...>" SIP/2.0 200 OK Feature-Caps: *;+sip.pns="webpush" ;+sip.vapid="<public key>"
Wake a browser
The browser asks pgw for the push key over secure WebSocket,
subscribes with it, and registers again with the subscription in
pn-prid. When a call comes in, the service worker shows
a notification, and the call rings when the user opens the app.
# Write the tenant's setup to a file, without secrets pgw config export --out pgw.yaml # Edit it, preview the change, then apply it pgw config sync pgw.yaml --dry-run + user pbx.example.com/1003 ~ upstream pbx.example.com keepalive_seconds ~ user pbx.example.com/1002 password +1 ~2 -0 pgw config sync pgw.yaml
Keep it in a file
Secrets stay out of the YAML file as references to files or environment variables, so the file is safe to commit. A sync changes everything in the plan or nothing.
Also in the box
All of it is in the same binary, with nothing else to install.
- Users from Kamailio’s subscriber table
- ES256 Bearer tokens
- Browser apps over WSS
- Web Push to Chrome, Edge, Firefox, and Safari
- One contact per user at the PBX
- Ban after 10 wrong passwords
- Silent test push
- SIP message log with secrets masked
- Configuration export and sync
- REST API with OpenAPI 3.1
- Embedded SQLite
Running in five commands
You need a computer on the same network as your test phone, a PBX that the computer can reach, and a debug build of your app.
-
Download the file for your system into a new folder, and rename it to
pgw, orpgw.exeon Windows. On Linux and macOS, make it executable. On macOS, also remove the mark that a browser puts on a download, or macOS refuses to run the file.chmod +x pgw
xattr -d com.apple.quarantine pgw
-
Create the settings files.
pgw initwrites.envwith a new master key and bootstrap token,config.yaml, andtenant.yaml../pgw init
-
In
tenant.yaml, replace<pbx-host>with your PBX’s address, uncomment your app, and put its credential file next totenant.yaml. Inconfig.yaml, check thathostnameis the address that your phone uses to reach this computer. Then start pgw../pgw serve
-
In a second terminal, in the same folder, create the first tenant. The command prints the tenant’s API key once, and saves it in
.env. pgw stores only its hash../pgw bootstrap --name test
-
Apply
tenant.yaml../pgw config sync tenant.yaml
pgw starts with a self-signed certificate, so the app must skip
certificate checks, which a debug build allows and a store build
doesn’t. Before you build Linphone, set
verify_server_certs=0 and
verify_server_cn=0 under [sip] in its
default settings file, linphonerc_default on Android,
and add your SIP domain to
push_notification_domains under [net]. In
the app, add your SIP account over TLS, with the outbound proxy
sip:<hostname>:5061;transport=tls and push
notifications on. For production, follow the
getting-started guide, which installs a certificate from a public certificate authority
and runs pgw under systemd.
Download pgw 0.3.2
One static binary for Linux, macOS, and Windows, on x86-64 or ARM64. No installer, no database server, nothing else to run.
Checksums
Verify the download before you install it.
Releases
-
Wake browser apps with Web Push. pgw makes each web app’s VAPID
key and sends pushes only to the hosts in
webpush_hosts. -
In managed mode, pgw registers one contact per user by default
and rings every device of the user at once. The first device to
answer takes the call, and the others get a CANCEL and no
missed-call push. Set
managed_registration: per_deviceto register each device instead. -
If you upgrade a managed mode setup from 0.3.1, set
PGW_MANAGED_REGISTRATION=per_devicefirst to keep registering each device, unless the PBX has room for one more contact per user. -
In managed mode, pgw sends iOS and Android apps a best-effort
refresh push before their expiry. Turn it off with
managed_refresh_push. -
Try pgw on your own computer with
pgw init, which writes.envwith a new master key and bootstrap token,config.yaml, andtenant.yaml. pgw commands read.envin the current folder, andpgw bootstrapsaves the API key there. -
Write a tenant’s apps, upstreams, and managed mode users to a
YAML file with
pgw config export, and apply a changed file withpgw config sync. -
Set
sip_logwithlog_level: debugto log every SIP message that pgw sends and receives, with secrets masked. Each message is a block of plain text, or one JSON line withsip_log_format: json. -
When pgw forwards a call to a woken app that never responds, it
now handles the call like any other call that ended early. It
sends the missed-call push and adds
X-Pgw-Call-Statusto the response to the app’s next REGISTER. - Fixed races where pgw could forward a held call or send a call push after the call had ended, or send a missed-call push after another device had answered.
- Test fixes only. The gateway behaves as in 0.3.0.
- Look up SIP users in Kamailio’s subscriber database.
- Authenticate app REGISTERs with a Bearer token, checked against your identity provider’s JWKS.
-
Accept browser apps over secure WebSocket with
app_wss_listen. - Revoke all of a user’s devices from the CLI or API.
- Managed mode, where pgw checks the SIP password and renews the registration at the PBX itself.
- pgw probes each upstream with OPTIONS every 30 seconds and reports whether it’s reachable.
- Send one device a silent test push from the CLI or API.
-
Keep at most
max_bindings_per_aordevices per account, 10 by default.
- Hold incoming calls and wake iOS and Android apps through APNs and FCM.
- Passthrough mode, where the app answers the PBX’s challenge and pgw stores no SIP password.
- Ban a source that fails REGISTER authentication too often.
- Prometheus metrics, health checks, and logs that never contain push tokens.
Frequently asked questions
Why does a mobile softphone need a push gateway?
iOS and Android suspend a VoIP app seconds after it leaves the screen. A suspended app can’t keep its SIP registration alive or receive a call. A push gateway stays registered for the app, holds each call, and wakes the app with a push from Apple or Google.
Does pgw relay audio or video?
No. pgw handles signaling only. Media flows directly between the app and the PBX.
Which PBXs work with pgw?
Asterisk 22 with PJSIP and FreeSWITCH 1.10 are tested. Other SIP
registrars that pgw can reach should work too. Use the default
contact strategy if the registrar keeps the Path header, and the
masquerade strategy if it doesn’t. You change the
PBX’s configuration, not its code.
Does pgw store SIP passwords?
Not in passthrough mode, the default. The app answers the PBX’s authentication challenge itself. In managed mode, pgw stores each user’s password encrypted, so that it can keep the registration at the PBX itself.
Can I use Linphone from the App Store or Google Play?
No. Pushes to that app need Belledonne Communications’ push credentials. You build the open-source Linphone app with your own bundle ID or package name and your own credentials. You change no code.
Which apps work with pgw?
Any SIP app that sends RFC 8599 push parameters, connects over TLS or secure WebSocket, and is built with push credentials that you register in pgw. On iOS, the app must report every VoIP push to CallKit. A browser app needs no push credentials: pgw makes its VAPID key.
Can a browser softphone get calls while its tab is closed?
Yes, through Web Push. The push is empty, so the service worker shows a notification without the caller’s name. When the user opens it, the app registers again and pgw delivers the call. A browser gets no missed-call push and no refresh push, so in passthrough mode a closed page’s registration ends at its expiry. In managed mode, pgw keeps it while the push subscription works.
What happens when a user has several devices?
Each device keeps its own binding. In managed mode, pgw registers one contact for the user and rings every device at once, so a PBX that keeps one contact per user still reaches all of them. In passthrough mode, each device registers at the PBX, and the PBX decides which devices ring.
Can I test pgw without a phone?
Yes. The Android emulator receives real FCM pushes, and Firebase Cloud Messaging is free. The iOS Simulator can’t receive VoIP pushes.
What happens when pgw restarts?
pgw keeps its bindings in SQLite, so it knows every device after a restart. When pgw stops, it answers each held call with 503. Apps reconnect and register again on their own.
Can I run two pgw instances for high availability?
No. pgw runs as a single instance, and two instances must never use the same data. pgw locks its data directory at startup, so a second process on the same directory stops.
Try it with your own PBX
Download pgw, point your app at it, and make a test call.
Download pgw