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

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.

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.

  1. Download the file for your system into a new folder, and rename it to pgw, or pgw.exe on 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
  2. Create the settings files. pgw init writes .env with a new master key and bootstrap token, config.yaml, and tenant.yaml.
    ./pgw init
  3. In tenant.yaml, replace <pbx-host> with your PBX’s address, uncomment your app, and put its credential file next to tenant.yaml. In config.yaml, check that hostname is the address that your phone uses to reach this computer. Then start pgw.
    ./pgw serve
  4. 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
  5. 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.

Linux

Runs on any 64-bit Linux.

Windows

For testing on a Windows PC.

Checksums

Verify the download before you install it.

sha256sum --check \ --ignore-missing SHA256SUMS

Releases

0.3.2November 10, 2026Latest
  • 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_device to register each device instead.
  • If you upgrade a managed mode setup from 0.3.1, set PGW_MANAGED_REGISTRATION=per_device first 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 .env with a new master key and bootstrap token, config.yaml, and tenant.yaml. pgw commands read .env in the current folder, and pgw bootstrap saves 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 with pgw config sync.
  • Set sip_log with log_level: debug to log every SIP message that pgw sends and receives, with secrets masked. Each message is a block of plain text, or one JSON line with sip_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-Status to 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.
0.3.1October 30, 2026
  • Test fixes only. The gateway behaves as in 0.3.0.
0.3.0October 29, 2026
  • 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.
0.2.0October 16, 2026
  • 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_aor devices per account, 10 by default.
0.1.0October 13, 2026
  • 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