FCM Push Notifications
Category: Backend & APIs
This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.
Send Firebase Cloud Messaging (FCM) push notifications to mobile devices straight from your database — no app server and no Firebase Admin SDK. Register device tokens, send to a single device, or fan a notification out to every device a user owns.
This is a drop-in replacement for a Supabase + FCM setup: your device tokens live in Postgres (exactly as they did in Supabase), and Air Pipe mints the FCM OAuth token and delivers the message — the job a Supabase Edge Function used to do.
How the send works under the hood (no Admin SDK needed):
- Air Pipe mints a short-lived OAuth2 access token from your Firebase
service account, scoped to
firebase.messaging. - An HTTP action POSTs the FCM v1 message to
https://fcm.googleapis.com/v1/projects/<project>/messages:sendwith that token as a bearer credential.
What's included
| File | Purpose |
|---|---|
config.yml | Air Pipe config — all endpoints |
schema.sql | device_tokens table |
seed.yml | POST /api/seed — creates the table + sample rows |
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /api/seed | Create the device_tokens table and load sample rows |
POST | /devices/register | Store/refresh an FCM token for a user (upsert) |
POST | /devices/unregister | Remove a token (logout / stale token) |
POST | /fcm/preview | Build & return the exact FCM message — no credentials, nothing sent |
POST | /fcm/send | Send a push to one device token |
POST | /notify/user | Look up all of a user's devices and fan out the notification |
Setup
1. Run the schema
psql $DATABASE_URL -f schema.sql
Or call POST /api/seed once after deploying — it creates the table (and loads
two placeholder rows for user_123).
2. Get a Firebase service account
In the Firebase console: Project settings → Service accounts → Generate new
private key. This downloads a JSON file. You need the whole JSON plus your
project id (it's the project_id field inside that JSON).
3. Set the managed variables
| Variable | Value |
|---|---|
DATABASE_URL | Postgres connection string (your device-token store) |
FIREBASE_SERVICE_ACCOUNT | The full service-account JSON, pasted as a string |
FIREBASE_PROJECT_ID | Your Firebase project id, e.g. my-app-12345 |
Self-hosting? Instead of pasting the JSON into
FIREBASE_SERVICE_ACCOUNT, you can point the credential at a file on disk — setglobal.google_credentials.firebase.fileto the path of the service-account JSON and drop thejson:line.
Try it
You don't need Firebase credentials to try the request-building — /fcm/preview
returns the exact body that would be sent:
# 1. Preview the FCM v1 message (no credentials, nothing delivered)
curl -X POST https://<your-host>/fcm/preview \
-H 'content-type: application/json' \
-d '{"token":"DEVICE_TOKEN","title":"Order shipped","body":"Your order #1024 is on its way."}'
# -> { "message": { "token": "...", "notification": {...}, "android": {...}, "apns": {...} } }
Once your Firebase variables are set:
# 2. Register a device (call this from your app on launch/login)
curl -X POST https://<your-host>/devices/register \
-H 'content-type: application/json' \
-d '{"user_id":"user_123","token":"REAL_DEVICE_TOKEN","platform":"android"}'
# 3. Send a push to that one device
curl -X POST https://<your-host>/fcm/send \
-H 'content-type: application/json' \
-d '{"token":"REAL_DEVICE_TOKEN","title":"Order shipped","body":"On its way 🚚"}'
# -> { "name": "projects/my-app-12345/messages/0:1500..." } (success)
# 4. Notify every device a user owns (the Supabase replacement)
curl -X POST https://<your-host>/notify/user \
-H 'content-type: application/json' \
-d '{"user_id":"user_123","title":"Flash sale","body":"50% off for the next hour"}'
# -> a per-device array of FCM responses
/notify/user returns the FCM response for every device, so you can see
exactly which sends succeeded and which failed.
Migrating from Supabase
| Supabase piece | Here |
|---|---|
| Postgres table of device tokens | device_tokens (same data — point DATABASE_URL at your existing DB) |
| Edge Function that calls FCM | /fcm/send and /notify/user |
pg_cron / scheduled push | call /notify/user from any cron, or Air Pipe's scheduler |
| Firebase Admin SDK | not needed — the OAuth token is minted for you |
To migrate, point DATABASE_URL at your existing Postgres (Supabase's database is
just Postgres) and your device_tokens rows work as-is. If your column names
differ, adjust the INSERT/SELECT statements in config.yml.
Notes & limits
- iOS: FCM delivers to Apple devices through its
apnsblock — no separate APNs integration needed./fcm/sendalready sets sensibleapnsheaders. - Custom data payload: add a
"data"object to themessageinconfig.ymlto ship key/value data alongside (or instead of) the visible notification. Alldatavalues must be strings. - Stale tokens: FCM returns
UNREGISTERED/INVALID_ARGUMENTfor tokens that belong to uninstalled apps. The per-device response from/notify/usersurfaces these so you can delete them via/devices/unregister. Automatic pruning is a planned enhancement.
Getting a device token (for testing)
FCM only delivers to a registration token, and a token is bound to your
Firebase project. A token from a generic third-party "FCM tester" app will
not work — it belongs to that app's project, and you'll get
SENDER_ID_MISMATCH / INVALID_ARGUMENT. Get a token from a client registered
to your project:
- Your mobile app: call
FirebaseMessaging.getToken()(Android / iOS / Flutter / React Native) and log or display it. - No app yet? Stand up a tiny web page that uses the Firebase JS SDK's
getToken({ vapidKey }), served over HTTPS (Firebase Hosting is the quickest — it's the same project). Open it on your phone, grant notification permission, and copy the token it prints. Web tokens work with the same/fcm/sendroute. You'll need a Web Push certificate (VAPID key) from Project settings → Cloud Messaging for the web flow.
Then register it with POST /devices/register and send.
Troubleshooting
The /fcm/send and /notify/user responses include FCM's per-device reply, so
the status/error tells you exactly what to fix:
| Symptom | Cause | Fix |
|---|---|---|
401 UNAUTHENTICATED | FIREBASE_SERVICE_ACCOUNT is wrong / for another project | use the service-account JSON for this project; the token is minted with the firebase.messaging scope |
400 INVALID_ARGUMENT — "not a valid FCM registration token" | bad, truncated, or expired token | re-fetch the token on the device and re-register |
403 SENDER_ID_MISMATCH | token belongs to a different Firebase project | the token must come from a client registered to your project / FIREBASE_PROJECT_ID |
404 / UNREGISTERED | app uninstalled or token rotated | remove it via /devices/unregister |
Sent OK (200) but no banner on web | the web page was in the foreground | background the tab/app — foreground web messages are delivered to the page's onMessage handler, not shown as a system banner |
| No banner on Android | OS notifications disabled | Settings → Apps → (your app / Chrome) → Notifications |
A
200with a{"name": "projects/.../messages/..."}means FCM accepted the message for delivery. Whether a banner appears then depends on the device (foreground/background, OS notification settings) — not on this pack.
Configuration
config.yml
name: FcmPushNotifications
description: >-
Send Firebase Cloud Messaging (FCM) push notifications to mobile devices,
straight from your database. Register device tokens, send to a single device,
or fan out a notification to every device a user owns — no app server, no
Firebase Admin SDK. A drop-in replacement for a Supabase + FCM setup: the
device tokens live in Postgres and Air Pipe mints the FCM OAuth token and
delivers the message.
docs: true
# Required managed variables:
# DATABASE_URL — Postgres connection string (your device-token store)
# FIREBASE_SERVICE_ACCOUNT — the full Firebase service-account JSON, as a string.
# Firebase console → Project settings → Service accounts
# → "Generate new private key". Paste the whole JSON.
# FIREBASE_PROJECT_ID — your Firebase project id (e.g. my-app-12345). It also
# appears as "project_id" inside the service-account JSON.
#
# How the FCM send works (no Admin SDK needed):
# 1. google.create_token mints a short-lived OAuth2 access token from the
# service account, scoped to https://www.googleapis.com/auth/firebase.messaging
# 2. an HTTP action POSTs the FCM v1 message to
# https://fcm.googleapis.com/v1/projects/<project>/messages:send
# with that token as a bearer credential.
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
google_credentials:
firebase:
json: "a|ap_var::FIREBASE_SERVICE_ACCOUNT|"
interfaces:
# ---------------------------------------------------------------------------
# POST /devices/register
# ---------------------------------------------------------------------------
# Call this from your mobile app on launch / login and whenever FCM rotates
# the registration token. Upserts on the token so re-registering is idempotent
# and re-points a token at the current user.
devices/register:
output: http
method: POST
summary: Register a device token
description: >-
Store (or refresh) an FCM registration token for a user. Upserts on the
token, so it is safe to call on every app launch.
tags: [fcm, devices, push]
request_example:
user_id: "user_123"
token: "fcm-registration-token-from-the-device"
platform: "android"
response_example:
id: 1
user_id: "user_123"
token: "fcm-registration-token-from-the-device"
platform: "android"
actions:
- name: ValidateRegister
input: a|body|
assert:
http_code_on_error: 422
error_message: "user_id and token are required"
tests:
- value: user_id
is_not_null: true
is_not_empty: true
description: "Owning user id"
- value: token
is_not_null: true
is_not_empty: true
description: "FCM registration token from the device"
- name: UpsertDevice
run_when_succeeded:
actions: [ValidateRegister]
http_code_on_error: 422
database: main
query: |
INSERT INTO device_tokens (user_id, token, platform)
VALUES ($1, $2, COALESCE($3, 'android'))
ON CONFLICT (token) DO UPDATE SET
user_id = EXCLUDED.user_id,
platform = COALESCE(EXCLUDED.platform, device_tokens.platform),
last_seen = NOW()
RETURNING id, user_id, token, platform, created_at, last_seen;
params:
- a|ValidateRegister::user_id|
- a|ValidateRegister::token|
- a|ValidateRegister::platform->default(null)|
post_transforms:
- extract_value: "[0]"
# ---------------------------------------------------------------------------
# POST /devices/unregister
# ---------------------------------------------------------------------------
# Call on logout, or when FCM tells you a token is no longer valid.
devices/unregister:
output: http
method: POST
summary: Unregister a device token
description: Remove an FCM registration token (e.g. on logout).
tags: [fcm, devices, push]
request_example:
token: "fcm-registration-token-from-the-device"
response_example:
removed: "fcm-registration-token-from-the-device"
actions:
- name: ValidateUnregister
input: a|body|
assert:
http_code_on_error: 422
error_message: "token is required"
tests:
- value: token
is_not_null: true
is_not_empty: true
- name: DeleteDevice
run_when_succeeded:
actions: [ValidateUnregister]
http_code_on_error: 422
database: main
query: |
DELETE FROM device_tokens WHERE token = $1
RETURNING token AS removed;
params:
- a|ValidateUnregister::token|
post_transforms:
- extract_value: "[0]"
# ---------------------------------------------------------------------------
# POST /fcm/preview (no credentials required)
# ---------------------------------------------------------------------------
# Builds and returns the exact FCM v1 message body that /fcm/send would POST,
# WITHOUT calling Google or FCM. Use it to test the pack end-to-end and to
# inspect/iterate on your payload before wiring up Firebase credentials.
fcm/preview:
output: http
method: POST
summary: Preview the FCM message (no send)
description: >-
Validate the input and return the exact FCM v1 `message` envelope that
would be delivered. No Firebase credentials needed — nothing is sent.
tags: [fcm, push, preview]
request_example:
token: "fcm-registration-token-from-the-device"
title: "Order shipped"
body: "Your order #1024 is on its way."
response_example:
message:
token: "fcm-registration-token-from-the-device"
notification:
title: "Order shipped"
body: "Your order #1024 is on its way."
android:
priority: "high"
actions:
- name: ValidatePreview
input: a|body|
assert:
http_code_on_error: 422
error_message: "token, title and body are required"
tests:
- value: token
is_not_null: true
is_not_empty: true
- value: title
is_not_null: true
is_not_empty: true
- value: body
is_not_null: true
is_not_empty: true
- name: BuildMessage
run_when_succeeded:
actions: [ValidatePreview]
http_code_on_error: 422
input: a|ValidatePreview|
post_transforms:
- add_attribute:
message:
token: "a|ValidatePreview::token|"
notification:
title: "a|ValidatePreview::title|"
body: "a|ValidatePreview::body|"
android:
priority: "high"
apns:
headers:
apns-priority: "10"
payload:
aps:
sound: "default"
# Drop the raw input fields so the response is exactly the FCM body.
- remove_keys:
- token
- title
- body
# ---------------------------------------------------------------------------
# POST /fcm/send
# ---------------------------------------------------------------------------
# Send a push to a single device token. Mints an OAuth token from the service
# account and POSTs the FCM v1 message. Returns FCM's response (the message
# name on success, or FCM's error body).
fcm/send:
output: http
method: POST
summary: Send a push to one device
description: >-
Deliver an FCM push notification to a single registration token. Requires
FIREBASE_SERVICE_ACCOUNT and FIREBASE_PROJECT_ID.
tags: [fcm, push, send]
request_example:
token: "fcm-registration-token-from-the-device"
title: "Order shipped"
body: "Your order #1024 is on its way."
response_example:
name: "projects/my-app-12345/messages/0:1500415314455276%31bd1c9631bd1c96"
actions:
- name: ValidateSend
input: a|body|
assert:
http_code_on_error: 422
error_message: "token, title and body are required"
tests:
- value: token
is_not_null: true
is_not_empty: true
- value: title
is_not_null: true
is_not_empty: true
- value: body
is_not_null: true
is_not_empty: true
# Mint a short-lived FCM OAuth2 access token from the service account.
- name: MintToken
run_when_succeeded:
actions: [ValidateSend]
http_code_on_error: 422
hide_data_on_success: true
google:
credential: firebase
# create_token_raw performs the OAuth2 JWT-bearer exchange and returns
# a raw access token (so `bearer_auth` wraps it once). `audience` must
# be Google's token endpoint for the exchange.
create_token_raw:
audience: https://oauth2.googleapis.com/token
scopes:
- https://www.googleapis.com/auth/firebase.messaging
- name: SendPush
run_when_succeeded:
actions: [MintToken]
http_code_on_error: 502
http:
url: "https://fcm.googleapis.com/v1/projects/a|ap_var::FIREBASE_PROJECT_ID|/messages:send"
method: POST
bearer_auth: a|MintToken|
headers:
content-type: application/json
body: |
{
"message": {
"token": "a|ValidateSend::token->json_escape|",
"notification": {
"title": "a|ValidateSend::title->json_escape|",
"body": "a|ValidateSend::body->json_escape|"
},
"android": { "priority": "high" },
"apns": {
"headers": { "apns-priority": "10" },
"payload": { "aps": { "sound": "default" } }
}
}
}
# ---------------------------------------------------------------------------
# POST /notify/user
# ---------------------------------------------------------------------------
# The Supabase replacement: look up every device a user has registered, then
# fan out the same notification to all of them concurrently. This is what you
# would have done with a Supabase Edge Function + the FCM API.
notify/user:
output: http
method: POST
summary: Notify all of a user's devices
description: >-
Load every registered device token for a user from Postgres and deliver
the notification to each one (fanned out concurrently). Returns the FCM
response for every device so you can see per-device success/failure.
tags: [fcm, push, multicast]
request_example:
user_id: "user_123"
title: "Order shipped"
body: "Your order #1024 is on its way."
response_example:
- name: "projects/my-app-12345/messages/0:1500415314455276%31bd1c96"
actions:
- name: ValidateNotify
input: a|body|
assert:
http_code_on_error: 422
error_message: "user_id, title and body are required"
tests:
- value: user_id
is_not_null: true
is_not_empty: true
- value: title
is_not_null: true
is_not_empty: true
- value: body
is_not_null: true
is_not_empty: true
- name: GetTokens
run_when_succeeded:
actions: [ValidateNotify]
http_code_on_error: 422
database: main
query: |
SELECT token FROM device_tokens WHERE user_id = $1;
params:
- a|ValidateNotify::user_id|
- name: AssertHasDevices
run_when_succeeded:
actions: [GetTokens]
http_code_on_error: 500
input: a|GetTokens|
assert:
http_code_on_error: 404
error_message: "No registered devices for this user"
tests:
- value: count()
is_greater_than: 0
- name: MintToken
run_when_succeeded:
actions: [AssertHasDevices]
http_code_on_error: 404
hide_data_on_success: true
google:
credential: firebase
# create_token_raw performs the OAuth2 JWT-bearer exchange and returns
# a raw access token (so `bearer_auth` wraps it once). `audience` must
# be Google's token endpoint for the exchange.
create_token_raw:
audience: https://oauth2.googleapis.com/token
scopes:
- https://www.googleapis.com/auth/firebase.messaging
# Fan out: one FCM send per device token, up to 20 concurrently.
# Inside a lookup, each sub-action only sees its per-item row (a|body::...|),
# NOT parent actions. `lookup_inherit` injects the access token and the
# notification text into every item so SendOne can read them as a|body::...|.
- name: FanOut
run_when_succeeded:
actions: [MintToken]
http_code_on_error: 502
lookup: a|GetTokens|
lookup_concurrency: 20
item_timeout: 10s
lookup_inherit:
access_token: a|MintToken|
title: a|ValidateNotify::title|
body: a|ValidateNotify::body|
actions:
- name: SendOne
http:
url: "https://fcm.googleapis.com/v1/projects/a|ap_var::FIREBASE_PROJECT_ID|/messages:send"
method: POST
bearer_auth: a|body::access_token|
headers:
content-type: application/json
body: |
{
"message": {
"token": "a|body::token->json_escape|",
"notification": {
"title": "a|body::title->json_escape|",
"body": "a|body::body->json_escape|"
},
"android": { "priority": "high" }
}
}
seed.yml
name: FcmPushNotificationsSeed
description: >-
Creates the device_tokens table (idempotent) and loads a couple of sample
rows for user "user_123" so you can exercise /notify/user immediately.
The sample tokens are placeholders — replace them by registering a real
device via POST /devices/register to receive an actual push.
docs: true
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
interfaces:
# POST /api/seed
# Creates the table and inserts sample device tokens. Safe to re-run.
api/seed:
output: http
method: POST
summary: Seed device_tokens
description: Create the device_tokens table and load sample rows. Idempotent.
tags: [fcm, seed]
response_example:
seeded: true
actions:
- name: CreateTable
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS device_tokens (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
platform TEXT NOT NULL DEFAULT 'android',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_device_tokens_user ON device_tokens (user_id);
- name: SeedRows
run_when_succeeded:
actions: [CreateTable]
http_code_on_error: 500
database: main
hide_data_on_success: true
query: |
INSERT INTO device_tokens (user_id, token, platform) VALUES
('user_123', 'SAMPLE_TOKEN_ANDROID_replace_me', 'android'),
('user_123', 'SAMPLE_TOKEN_IOS_replace_me', 'ios')
ON CONFLICT (token) DO NOTHING;
- name: Done
run_when_succeeded:
actions: [SeedRows]
http_code_on_error: 500
database: main
query: |
SELECT COUNT(*)::int AS device_count FROM device_tokens;
post_transforms:
- extract_value: "[0]"
schema.sql
-- FcmPushNotifications — device-token registry
--
-- One row per (device) FCM registration token. A user can have many devices,
-- so `user_id` is indexed but not unique; `token` is unique so that
-- /devices/register can upsert on it.
CREATE TABLE IF NOT EXISTS device_tokens (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
platform TEXT NOT NULL DEFAULT 'android', -- android | ios | web
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_device_tokens_user ON device_tokens (user_id);