Ray
PricingDocsSign inStart free
Browse the docs

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

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.

View as Markdownllms.txtAPI reference

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

  1. 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.
  2. 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.
  3. 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_terminal webhooks.
  • Fan-out sends (2 or more targets) fire only send.completed; when totals.failed_terminal is above 0, page through GET /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.

PreviousEmail with SMTPNextSlack notifications

On this page

  • Ray stores no device tokens
  • What you need from Firebase
  • Channel config
  • Template content
  • Recipient
  • Send a push
  • Dead tokens
  • Errors
Ray

Notification delivery for teams that already have providers.

Product

PricingDocsAPI referenceMCP serverTypeScript SDKAgent skillsllms.txt

Legal

TermsPrivacyRefunds

Contact

[email protected]

© 2026 L’Atelier Gege LLC