Sygnal: the Matrix push gateway
Article.
Verified 2026-10Verified 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
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- The client registers a pusher on its homeserver: its FCM or APNs token
as the
pushkey, the gateway’s URL, and the app’sapp_id. - Push rules on the homeserver evaluate each event for each user (mentions, DMs, keywords, muted rooms) and decide whether to notify.
- The homeserver calls the gateway with the notification and the target devices.
- 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:
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:
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": [...]} 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. rejectedis 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
# 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 buildsPut 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
.p8key, 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_idper 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
rejectedlist 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_onlysends 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.
Related
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
.p8key.