Backend / Protocols / Matrix / 02_sygnal_push_gateway.md

Sygnal: the Matrix push gateway

Updated 4 interview angles 6 min read source
On this page8
  1. The path of a push
  2. Registering a pusher
  3. The mechanism: what a push contains
  4. Running Sygnal
  5. Where it breaks in production
  6. The trade not taken: push straight from the homeserver
  7. Related
  8. Interview angle

Sygnal: the Matrix push gateway

Article.

Verified 2026-10

Verified 2026-10-15: Sygnal 0.17.0 (2025-11-05, Python 3.10+), now maintained at element-hq/sygnal; the Matrix push gateway API.

A phone app cannot hold a /sync connection open in the background: the operating system kills it. To wake the app when a message arrives, Matrix uses the platform push services, Firebase Cloud Messaging for Android and APNs for iOS, through a push gateway. Sygnal is the reference gateway, written in Python on Twisted.

The question is why the homeserver does not call FCM itself, what a push actually contains, and what fails silently when push breaks.

The path of a push

text
event in a room
  │ push rules: notify Bob?
  ▼
Bob's homeserver
  │ POST /_matrix/push/v1/notify
  ▼
Sygnal: the app publisher's gateway and credentials
  ├─► FCM  ─► Android phone
  └─► APNs ─► iPhone
        app wakes, syncs, decrypts, shows it
  1. The client registers a pusher on its homeserver: its FCM or APNs token as the pushkey, the gateway’s URL, and the app’s app_id.
  2. Push rules on the homeserver evaluate each event for each user (mentions, DMs, keywords, muted rooms) and decide whether to notify.
  3. The homeserver calls the gateway with the notification and the target devices.
  4. Sygnal translates the request into an FCM or APNs call, using the app publisher’s credentials, and reports which tokens were rejected.

Why a separate gateway

The FCM service account and the APNs key belong to whoever publishes the app, not to the people running homeservers. With a gateway, any homeserver, run by anyone, can push to the app’s users by calling the publisher’s Sygnal, and the credentials never leave the publisher. It also means one service to rotate keys in, rate-limit and monitor.

Registering a pusher

The client tells its homeserver where to push:

http
POST /_matrix/client/v3/pushers/set
Authorization: Bearer <access token>

{
  "kind": "http",
  "app_id": "org.example.chat.android",
  "pushkey": "<the FCM registration token>",
  "app_display_name": "Example Chat",
  "device_display_name": "Bob's Pixel",
  "lang": "en",
  "data": {
    "url": "https://push.example.org/_matrix/push/v1/notify",
    "format": "event_id_only"
  }
}

app_id is how Sygnal finds the right credentials, so it must match a key in Sygnal’s configuration exactly.

The mechanism: what a push contains

The gateway receives what the homeserver chooses to send. A local stand-in for Sygnal implementing the real /_matrix/push/v1/notify contract, called with both payload formats for two devices, one of whose tokens FCM no longer accepts:

python
def notify(fmt):
    n = {"event_id": event_id, "room_id": room_id, "counts": {"unread": 3},
         "devices": [{"app_id": app_id, "pushkey": k, "data": {"format": fmt}}
                     for k in pushers]}
    if fmt != "event_id_only":
        n |= {"type": ev_type, "sender": sender, "content": content}
    return post(gateway_url, {"notification": n})       # -> {"rejected": [...]}
text
         full: gateway saw ['content', 'counts', 'event_id', 'room_id',
                               'sender', 'type']
               message body reached the gateway: True
               rejected 'fcm-token-old-tablet'
                 -> homeserver deletes the uninstalled tablet pusher
event_id_only: gateway saw ['counts', 'event_id', 'room_id']
               message body reached the gateway: False
pushers left: ['phone']

Two things are visible:

  • The full format sends the message to Google or Apple. In an unencrypted room, the body goes to the gateway and on to the push provider. With event_id_only, the push carries only the event ID, room ID and unread counts; the app wakes, fetches the event from its own homeserver, decrypts it locally and builds the notification. That is the format to use.
  • rejected is a contract. The gateway answers with the pushkeys the provider refused (uninstalled app, expired token), and the spec requires the homeserver to stop sending to them and delete those pushers. Without it, a user’s pusher list grows with every reinstall.

Encrypted rooms

In an end-to-end encrypted room the homeserver cannot read the event, so it cannot fully evaluate push rules on content (keywords, mentions) and cannot put content in a push. The client receives event_id_only, decrypts, and runs the content rules itself to decide whether to show, sound or highlight.

On iOS that work happens in a Notification Service Extension with a few seconds of budget. Sygnal’s documentation also notes that VoIP pushes cannot be triggered this way, since a call can begin from an encrypted event.

Running Sygnal

yaml
# sygnal.yaml
http:
  bind_addresses: ["127.0.0.1"]
  port: 5000

apps:
  org.example.chat.android:          # must equal the pusher's app_id
    type: gcm
    api_version: v1                   # the legacy server-key API is gone
    project_id: example-chat-prod
    service_account_file: /etc/sygnal/fcm-service-account.json

  org.example.chat.ios:
    type: apns
    keyfile: /etc/sygnal/AuthKey_ABC123.p8   # token auth: no yearly certificate
    key_id: ABC123
    team_id: TEAM123456
    topic: org.example.chat                  # the iOS bundle ID
    platform: production                     # sandbox for development builds

Put it behind Nginx with TLS like any public endpoint; homeservers anywhere may call it. If it returns an error, the homeserver retries with exponential backoff, so a gateway outage delays notifications rather than losing them immediately.

Where it breaks in production

  • The APNs certificate expires. Certificate auth expires every year, and on that day every iOS push fails while Android works. Use token auth (a .p8 key, which does not expire) and monitor APNs error rates per app.
  • Sandbox vs production. A development build’s token sent to the production APNs endpoint (or the reverse) is rejected as a bad device token; one app_id per environment avoids it.
  • The FCM service account is rotated or disabled. Every Android push fails with an authentication error while the homeserver sees only gateway errors. Alert on Sygnal’s provider error rate, not just its uptime.
  • Rejected keys ignored. A homeserver or a custom gateway that drops the rejected list keeps pushing to dead tokens; the provider may throttle a sender with a high invalid-token rate.
  • Content in pushes. A pusher registered without event_id_only sends unencrypted message bodies through Google and Apple. Make the format the client’s default.
  • Notifications that arrive late or never. Push delivery is best effort: Android’s battery optimisation and Doze defer normal-priority messages. Message notifications need high priority, which the platforms allow only for messages the user actually sees.

The trade not taken: push straight from the homeserver

The homeserver could hold FCM and APNs credentials and push directly. That works for one company running one homeserver for one app, and fails as soon as the app is used with homeservers you do not run: they would need your credentials. The gateway keeps app credentials with the app publisher and gives every homeserver one HTTP contract.

A custom gateway is a reasonable choice if Sygnal does not fit, provided it implements the same contract, rejected included.

Interview angle 4

  • “How does a Matrix message reach a phone that is asleep?” - push rules on the homeserver decide to notify, the homeserver calls the app’s push gateway (/_matrix/push/v1/notify), and Sygnal forwards it to FCM or APNs with the app publisher’s credentials. The app wakes, syncs and shows it.
    • Follow-up: why not have the homeserver call FCM directly? The credentials belong to the app publisher; a gateway lets homeservers run by anyone push to the app without them.
  • “What is in the push payload?” - with event_id_only, only the event ID, room ID and counts; the app fetches and decrypts the event itself. The full format sends the message body through Google or Apple, as shown in a local run of the contract.
    • Follow-up: and in an encrypted room? The server cannot read it, so the client decrypts and evaluates the content push rules locally.
  • “A user reinstalled the app three times. What happens to the old tokens?” - the gateway returns them in rejected, and the homeserver must delete those pushers.
  • “All iOS notifications stopped today. First guess?” - an expired APNs certificate; move to token-based auth with a .p8 key.