Push API
Send FCM push notifications from any environment — backend, mobile app, or browser — through a single REST endpoint. Opseer holds your Firebase service account so you never have to deploy a push-only backend.
Last updated: 2026-05-18
Opseer Push API sends Firebase Cloud Messaging (FCM) notifications on your behalf. Connect your Firebase service account once in the dashboard, and from then on any environment — mobile, web, backend — can send push with a single HTTP POST and an API key.
Base URL: https://api.opseer.com/v1 · One endpoint (POST /push/send) · Beta
Authentication
All requests must include an API key in the Authorization header using the Bearer scheme.
Authorization: Bearer opseer_sk_a1b2c3d4e5f6...API Key types
Each project issues two key types. Choose based on where the key lives — a trusted environment (server) or an untrusted one (app binary, browser bundle).
| Attribute | Server Key | Client Key |
|---|---|---|
| Prefix | opseer_sk_… | opseer_ck_… |
| Permission | Full Push API | push:send only |
| Environment | Backend, Edge Functions, cron, CI/CD | Mobile app, browser JS, extensions |
| Exposure | Private (env vars, secret manager) | Public (bundled with app) |
| Blast radius if leaked | Future management actions | Push spam within daily quota only |
| Max per project | 2 | 2 |
Both types can coexist and share the same project quota.
Creating and revoking keys
Keys are created and revoked in the Opseer dashboard at Project Settings → API Keys. The API does not expose a /keys endpoint; sensitive credentials require explicit human authorization through the UI.
- Full key is shown only once at creation. Opseer stores only the SHA-256 hash — if lost, revoke and create a new one
- Revocation takes effect immediately on the next request
- Revoked keys are hidden from the UI and retained in DB for 1 year (for quota integrity and audit), then deleted by a scheduled cleanup
POST /push/send
Send an FCM push notification immediately to a single token, a topic, or up to 500 tokens (multicast).
Headers
| Header | Value | Required |
|---|---|---|
Authorization | Bearer <api_key> | Yes |
Content-Type | application/json | Yes |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
target | object | Yes | Targeting definition (see subfields) |
target.type | "token" \| "topic" \| "multicast" | Yes | Target method |
target.tokens | string[] | When type is token or multicast | FCM registration tokens. Max 500 for multicast |
target.topic | string | When type is topic | FCM topic name. Must match ^[a-zA-Z0-9-_.~%]{1,900}$ |
title | string | Yes | Notification title. Up to 1000 chars (FCM recommends ~500 for Android) |
body | string | Yes | Notification body. Up to 4,000 chars |
data | Record<string, string> | No | Custom key-value payload passed to the app. Total payload ≤ 4KB. Values must be strings (FCM restriction) |
Response — 200 OK
| Field | Type | Description |
|---|---|---|
ok | true | Success flag |
message_id | string | FCM message ID. Present for single-target sends; omitted for multicast |
sent_count | number | Number of requests successfully delivered to FCM |
failure_count | number | Number of failed tokens (for multicast) |
failures | Failure[] | Per-token failure details for multicast. Omitted for single-target |
usage | Usage | Current usage information |
`Failure` object:
| Field | Type | Description |
|---|---|---|
token | string | The failed FCM token |
error_code | string | FCM error code (e.g. registration-token-not-registered) |
message | string | Human-readable error description |
`Usage` object:
| Field | Type | Description |
|---|---|---|
today | number | Cumulative usage today (includes this request) |
limit | number | Daily limit for your plan |
remaining | number | Remaining quota |
resets_at | string (ISO 8601) | Next reset time (UTC 00:00) |
The same information is included in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers.
Example
Request:
curl -X POST https://api.opseer.com/v1/push/send \
-H "Authorization: Bearer opseer_sk_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"target": { "type": "token", "tokens": ["cVGT3wxTT1..."] },
"title": "New comment",
"body": "Jina replied to your post",
"data": { "post_id": "12345" }
}'Response (200 OK):
{
"ok": true,
"message_id": "projects/my-app/messages/0:1618...",
"sent_count": 1,
"failure_count": 0,
"usage": {
"today": 42,
"limit": 100,
"remaining": 58,
"resets_at": "2026-04-22T00:00:00Z"
}
}Multicast partial failure response:
{
"ok": true,
"sent_count": 12,
"failure_count": 1,
"failures": [
{
"token": "cVGT3wxTT1-...",
"error_code": "registration-token-not-registered",
"message": "Requested entity was not found."
}
],
"usage": { "today": 54, "limit": 100, "remaining": 46, "resets_at": "2026-04-22T00:00:00Z" }
}Tokens returned in the failures array should be removed from your database. They become invalid when the app is uninstalled or notification permission is revoked, and will never succeed again.
Errors
All errors follow a consistent JSON shape.
{
"ok": false,
"error": "INVALID_TOKEN",
"message": "The FCM token is not registered for your Firebase project.",
"details": { /* optional, error-specific context */ }
}Error Codes
| Status | Code | When | Retry |
|---|---|---|---|
| 400 | MALFORMED_JSON | Request body is not valid JSON | ❌ |
| 401 | MISSING_AUTH | Authorization header missing or not Bearer ... | ❌ |
| 401 | INVALID_KEY | API key not recognized (typo, revoked, wrong environment) | ❌ |
| 404 | FIREBASE_NOT_CONFIGURED | Project has no active Firebase integration | ❌ |
| 422 | INVALID_REQUEST | Request validation failed. See message for the cause | ❌ |
| 422 | INVALID_TOKEN | FCM token malformed or not registered for your project | ❌ |
| 422 | INVALID_TOPIC | Topic name violates FCM naming rules | ❌ |
| 422 | PAYLOAD_TOO_LARGE | title + body + data exceeds 4KB | ❌ |
| 422 | TOO_MANY_TOKENS | Multicast exceeds 500 tokens | ❌ |
| 429 | RATE_LIMITED | Daily quota exceeded | ✅ Honor Retry-After |
| 500 | FCM_ERROR | Firebase returned an unexpected error. See details.fcm_error | ⚠️ Depends on cause |
| 500 | INTERNAL_ERROR | Opseer server error | ✅ Exponential backoff |
Rate Limits
A **daily quota per account** (project owner). Not per-key, not per-project. No per-minute, per-IP limiting.
- Per-key or per-project limits would be gameable by creating multiple keys or projects → we enforce at account level
- Per-minute and IP-based limits have high false-positive rates behind shared NAT / carrier CGNAT and add implementation complexity → excluded
- Abuse is instead contained by the daily ceiling, scoped Client Keys, and instant revocation
Daily quota by plan
| Plan | Monthly price | Daily requests |
|---|---|---|
| Free | $0 | 100 (trial) |
| Starter | $9 | 1,000 |
| Plus | $24 | 10,000 |
| Max | $49 | 100,000 |
What counts toward quota
- A successful /push/send is 1 unit regardless of multicast size (500-token multicast = 1 unit)
- Requests rejected with 422 INVALID_REQUEST do not count (FCM was never called)
- Authentication failures (401) do not count (rejected before FCM is called)
- Requests that reached FCM but failed there (e.g. all tokens invalid) do count
429 response format
{
"ok": false,
"error": "RATE_LIMITED",
"message": "Daily quota exceeded. Upgrade your plan or wait until reset.",
"details": {
"today": 101,
"limit": 100,
"resets_at": "2026-04-22T00:00:00Z",
"retry_after_seconds": 43200
}
}The same value is returned in the `Retry-After` HTTP header (RFC 7231).
Security & Best Practices
- Use HTTPS for all requests. Plaintext HTTP is rejected
- Keep Server Keys in environment variables or secret managers. Never commit. Use CI secret scanning (GitHub secret scanning, Gitleaks)
- Client Keys are public by design — do not rely on obfuscation; rely on scope and daily quota
- Revoke immediately on suspicion of compromise — propagation is instant
- Monitor the dashboard usage graph. Persistent spikes above baseline are a signal to investigate
- When rotating, overlap old and new keys for at least one deploy cycle to avoid gaps
- Use separate keys per environment (production / staging)
How Opseer handles your service account
When you connect Firebase, your service account JSON is encrypted with AES-256-GCM immediately and persisted only as an encrypted blob. It is decrypted in memory only at request time, never logged in plaintext, and never transmitted anywhere other than Google FCM servers.
Data Retention
| Item | Retention |
|---|---|
| API Key (active) | Indefinite (until revoked) |
| API Key (revoked) | Hidden from UI immediately; DB row kept 1 year then deleted |
| API usage counters | 90 days, then auto-deleted |
| Push payloads (title, body, data) | Not stored (streamed to FCM and discarded) |
| FCM responses (message_id, failures) | Not stored (returned in response only) |
If you need long-term audit of push history, log the message_id and delivery result from the response body on your side. Opseer is not a push log archive.
Integration examples
Next.js API Route / Supabase Edge Function (Server Key)
// app/api/notify/route.ts
// Set OPSEER_API_KEY in .env.local / Vercel env
export async function POST(req: Request) {
const { authorFcmToken, commenterName, postId } = await req.json()
const res = await fetch('https://api.opseer.com/v1/push/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPSEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: { type: 'token', tokens: [authorFcmToken] },
title: 'New comment',
body: `${commenterName} commented on your post`,
data: { type: 'comment', post_id: postId },
}),
})
const result = await res.json()
if (!result.ok) {
return Response.json({ ok: false, error: result.error }, { status: res.status })
}
return Response.json({ ok: true, message_id: result.message_id })
}React Native / Browser (Client Key)
const OPSEER_CLIENT_KEY = 'opseer_ck_xxxxxxxxxxxxxxxxxxxx' // bundled with app
export async function sendPushToUser(
fcmToken: string,
title: string,
body: string,
data?: Record<string, string>,
) {
const res = await fetch('https://api.opseer.com/v1/push/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPSEER_CLIENT_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: { type: 'token', tokens: [fcmToken] },
title, body,
...(data && { data }),
}),
})
const result = await res.json()
if (!result.ok) throw new Error(result.error)
return result
}Swift / iOS
struct OpseerPush {
static let apiKey = "opseer_ck_xxxxxxxxxxxxxxxxxxxx"
static let baseURL = URL(string: "https://api.opseer.com/v1")!
static func send(fcmToken: String, title: String, body: String) async throws {
let url = baseURL.appendingPathComponent("push/send")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"target": ["type": "token", "tokens": [fcmToken]],
"title": title,
"body": body,
]
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
}
}Kotlin / Android
object OpseerPush {
private const val API_KEY = "opseer_ck_xxxxxxxxxxxxxxxxxxxx"
private const val BASE_URL = "https://api.opseer.com/v1"
private val client = OkHttpClient()
suspend fun send(fcmToken: String, title: String, body: String) = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("target", JSONObject().apply {
put("type", "token")
put("tokens", listOf(fcmToken))
})
put("title", title)
put("body", body)
}
val request = Request.Builder()
.url("$BASE_URL/push/send")
.addHeader("Authorization", "Bearer $API_KEY")
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw RuntimeException("Push failed: ${response.code}")
}
}
}cURL
curl -X POST https://api.opseer.com/v1/push/send \
-H "Authorization: Bearer opseer_sk_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"target": {"type":"topic","topic":"all_users"},
"title": "New post available",
"body": "Check out the latest update!",
"data": {"type": "announcement"}
}'Migrating from Firebase Admin SDK
If you already use firebase-admin, the migration is typically a drop-in replacement of SDK calls with fetch.
Before:
import admin from 'firebase-admin'
admin.initializeApp({
credential: admin.credential.cert(require('./service-account.json')),
})
await admin.messaging().send({
token: authorFcmToken,
notification: { title: 'New comment', body: commenterMessage },
data: { post_id: '12345' },
})After:
await fetch('https://api.opseer.com/v1/push/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPSEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: { type: 'token', tokens: [authorFcmToken] },
title: 'New comment',
body: commenterMessage,
data: { post_id: '12345' },
}),
})
// No SDK to initialize, no service account file to loadLimits & Roadmap
Current limits (Phase 1)
- One endpoint: POST /push/send (immediate delivery)
- Max 500 tokens per multicast request (FCM limit)
- Max 4KB per notification payload (FCM limit)
- 2 Server Keys + 2 Client Keys per project
- Daily quota only (no per-minute rate limit)
Roadmap
- Phase 2 — POST /push/schedule (scheduled delivery), GET /push/{id} (status lookup)
- Phase 3 — Delivery webhooks, templates with variable interpolation
- Future — Alert dispatch API, Remote Config read/write API
Push API is in Beta. The endpoint path (/api/v1/push/send) is stable; response schemas may evolve with additive-only changes. Breaking changes will ship as /api/v2 with a 6-month deprecation window.
FAQ
Can a mobile app send push directly using only the Firebase Client SDK?
No. The Client SDK only receives push. Sending requires a service account that is only usable from a trusted server environment. Opseer Push API fills this gap.
Can I call FCM directly with my own service account instead?
Yes, and at very high volume it may be more efficient. Opseer is optimized for indie developers and small teams who value zero setup and unified observability.
Why does a 500-token multicast count as 1 unit?
Opseer's cost is dominated by the FCM call. A multicast is a single FCM call regardless of token count, so charging linearly would disconnect the quota from actual cost. 1 request = 1 unit is simple and fair.
Can projects within the same account have different quotas?
Not currently. The daily quota is per account and shared across all projects. For strict isolation (e.g. client work), create separate Opseer accounts.
Do failed pushes consume quota?
Requests that reach FCM count toward quota even if FCM reports delivery failure. Requests rejected before FCM (validation, auth) do not. The rule aligns quota consumption with actual compute and network cost.
What if I lose my Server Key?
Revoke it from the dashboard and create a new one. Opseer stores only the hash, so recovery is not possible — save keys to your secret manager immediately at creation time.
What happens when I send to an invalidated FCM token?
Single-target: 422 INVALID_TOKEN. Multicast: included in the failures array. Remove these tokens from your database — they will not be valid again until the user reinstalls your app.
Design decisions
Key design choices behind the API, with rationale. Not required reading to integrate, but useful if you want to understand behavior at a deeper level.
Bearer tokens (over query params or X-API-Key)
Query parameters leak into server logs, proxy logs, and browser history — unfit for secrets. X-API-Key works but is non-standard. Bearer tokens are the OAuth 2.0 convention, supported by most HTTP clients and proxies out of the box. Security is equivalent over HTTPS; portability favors Bearer.
Two key types (over a single key with ACLs)
Custom claims and granular scopes are powerful but fragile. A scope misconfiguration can inadvertently give a client-safe key administrative permissions. Hard-coded key types enforced at authentication time make this class of mistake impossible. Flexibility decreases slightly; the trade-off is correct for a security-critical surface.
Per-account daily quota only (over per-key, per-minute, or per-IP)
Per-key or per-project limits are gameable via multiple keys or projects. Per-minute / burst limiters add time-bucket complexity and false positives in shared NAT and CGNAT environments. A daily cap at the account level gives a clean, predictable cost ceiling without any of those problems.
Fixed 2+2 keys instead of plan-tiered limits
One project typically needs 1 operational key plus 1 rotation slot per type. When you need more services, the natural unit is a new project (all plans have unlimited projects). Tier differentiation is concentrated on daily throughput, not key counts.
Multicast = 1 quota unit
A multicast request is a single FCM call regardless of token count. Charging per-token would create a linear cost increase on the user side disconnected from the actual cost we bear. "1 request = 1 unit" is simple and fair.
No per-push history stored
Individual push titles, bodies, targets, and outcomes are not persisted. Rationale: doubled write volume and storage cost. Instead, the usage counter (api_key_usage) tracks per-key, per-day totals. Users who need per-push audit should log the message_id and delivery result from the response body on their own side. This may be revisited as an opt-in feature in Phase 2.