Channels
Push notifications with Firebase Cloud Messaging
Send mobile and web push notifications through your Firebase project with Ray. Service account setup, device tokens and topics, content and errors.
The fcm_push channel sends push notifications to Android, iOS and web apps through Firebase Cloud Messaging (FCM HTTP v1 API), using a service account from your own Firebase project. API calls go to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY.
| Channel type | fcm_push |
| Template kind | fcm_basic |
| Recipient | { "deviceToken": "..." } or { "topic": "..." } |
| Pacing | None |
| Suppression | None |
Ray stores no device tokens
Ray is a relay: it has no device registry, subscriber list or "register device" step. Your app gets the FCM registration token from the Firebase client SDK, stores it in your own database, and passes it on every POST /send. Keeping tokens fresh (they rotate, and uninstalls invalidate them) stays in your app, just as you pass an email address rather than pre-registering it.
If you are coming from OneSignal, Knock or a similar platform that keeps the registry for you, the model is inverted: there is no "send to user" call. Send to each of the user's tokens (fan-out with targets[] works well for a user with several devices), or use an FCM topic. externalUserId on a send only labels it for your feed; it never changes who receives the push.
What you need from Firebase
- A Firebase project with your apps added (Android, Apple, Web) and the FCM client SDK integrated so each device obtains a registration token. For iOS, upload your APNs key in Firebase Project settings, Cloud Messaging.
- A service account key. In the Firebase console open Project settings, Service accounts, and click Generate new private key. This downloads a JSON file. (Equivalent in Google Cloud: IAM and Admin, Service Accounts, pick the account, Keys, Add key, JSON.) The Firebase Admin SDK service account already has permission to send messages.
- Make sure the Firebase Cloud Messaging API (V1) is enabled for the project (Firebase Project settings, Cloud Messaging).
Channel config
Create the config in the dashboard: Channels, New channel, Firebase Cloud Messaging, and paste the whole service account JSON. Ray reads three fields from it and stores the JSON encrypted:
| Field | Required | Notes |
|---|---|---|
| Name | yes | Internal label, for example mobile-prod. |
project_id |
yes | Firebase project id. |
client_email |
yes | Service account email. |
private_key |
yes | PEM private key from the JSON. |
When editing, leave the JSON blank to keep the stored credentials, or paste a new file to rotate. Ray exchanges the key for short-lived OAuth tokens and calls FCM directly.
Find the config's id with GET /channels; its kind is fcm_push.
Template content
| Field | Type | Notes |
|---|---|---|
title |
string, 1 to 200 | Notification title. |
body |
string, 1 to 2000 | Notification body. |
imageUrl |
URL | Optional image shown in the notification. {{variables}} are substituted. |
data |
object of string to string | Optional key-value payload delivered to your app. Values may contain {{variables}}. |
Values are inserted without escaping (push text is plain text). title and body map to FCM's notification, imageUrl to notification.image, and data to the message data.
Recipient
Exactly one of:
{ "deviceToken": "fMEP0vJqS0qWb2y4Xx...device-registration-token" }
{ "topic": "news" }
| Field | Notes |
|---|---|
deviceToken |
An FCM registration token from your client app. |
topic |
An FCM topic name: letters, digits and _ - . % ~ /. Devices subscribe to topics in your app via the FCM SDK. |
Passing both or neither fails with 400: recipient: exactly one of `deviceToken` or `topic` is required.
Send a push
curl -X POST https://ray-api.gege.mn/send \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-shipped-A-1042" \
-d '{
"channelConfigId": "3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
"content": {
"title": "Order {{orderId}} shipped",
"body": "Arriving {{eta}}.",
"data": { "screen": "order", "orderId": "{{orderId}}" }
},
"params": { "orderId": "A-1042", "eta": "Thursday" },
"recipient": { "deviceToken": "fMEP0vJqS0qWb2y4Xx...device-registration-token" },
"externalUserId": "user_123",
"showInFeed": true
}'
Send to every device of a user in one call:
const tokens: string[] = await db.deviceTokens.forUser("user_123"); // your storage
const res = await fetch("https://ray-api.gege.mn/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RAY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-shipped-A-1042",
},
body: JSON.stringify({
channelConfigId: "3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", // an fcm_basic template
params: { orderId: "A-1042", eta: "Thursday" },
targets: tokens.map((deviceToken) => ({ recipient: { deviceToken }, externalUserId: "user_123" })),
}),
});
if (res.status !== 202) throw new Error(`Ray ${res.status}: ${await res.text()}`);
targets[] accepts up to 1000 entries. Note that showInFeed with several targets for the same user creates one feed entry per target; for a single feed entry, send feed with a multi-channel deliveries[] request or a separate feed-only send.
The delivery row's providerMessageId is the FCM message id. delivered means FCM accepted the message, not that the device displayed it.
Dead tokens
FCM rejects tokens of uninstalled apps or expired registrations with UNREGISTERED (or NOT_FOUND). Ray marks that delivery failed_terminal and does not suppress the token, because it keeps no token list. Remove dead tokens from your database:
- Single-recipient sends: subscribe to
notification.failed_terminalwebhooks. - Fan-out sends (2 or more targets) fire only
send.completed; whentotals.failed_terminalis above 0, page throughGET /sends/{sendId}and inspect the failed rows.
// After a send.completed event (or any time) for a push send
const res = await fetch(`https://ray-api.gege.mn/sends/${sendId}?limit=200`, {
headers: { Authorization: `Bearer ${process.env.RAY_API_KEY}` },
});
const { rows } = (await res.json()) as {
rows: Array<{ status: string; recipient: { deviceToken?: string }; providerError: { message?: string } | null }>;
};
for (const row of rows) {
const message = row.providerError?.message ?? "";
if (row.status === "failed_terminal" && /UNREGISTERED|NOT_FOUND/.test(message) && row.recipient.deviceToken) {
await db.deviceTokens.delete(row.recipient.deviceToken); // your storage
}
}
Follow nextCursor for sends with more than 200 rows.
Errors
Errors appear on the delivery row as providerError: { "name", "message" }. For FCM send errors name is FcmSendError and message looks like FCM send failed: 404 Not Found UNREGISTERED.
| Result | FCM errors |
|---|---|
failed_terminal (not retried) |
UNREGISTERED, NOT_FOUND, INVALID_ARGUMENT, SENDER_ID_MISMATCH, THIRD_PARTY_AUTH_ERROR, any other HTTP 4xx from FCM except 401 (this includes 429 QUOTA_EXCEEDED), and service account token exchange failures with HTTP 401, 403 or other 4xx except 400 |
failed_retryable (retried) |
HTTP 401 from the send call (expired access token), HTTP 5xx, token exchange HTTP 400 (often clock skew) or 5xx, network errors and timeouts |
Common fixes:
SENDER_ID_MISMATCH: the token belongs to a different Firebase project than the service account.THIRD_PARTY_AUTH_ERROR: the APNs key or certificate in Firebase is missing or invalid (iOS).- Token exchange 401 or 403 (
FcmTokenExchangeError): the service account key was deleted or lacks permission; upload a fresh JSON key. INVALID_ARGUMENT: a malformed token or topic, or a data payload FCM rejects.
Request errors from POST /send are listed in Errors.