OpseerOpseer
Docs
Dashboard

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).

AttributeServer KeyClient Key
Prefixopseer_sk_…opseer_ck_…
PermissionFull Push APIpush:send only
EnvironmentBackend, Edge Functions, cron, CI/CDMobile app, browser JS, extensions
ExposurePrivate (env vars, secret manager)Public (bundled with app)
Blast radius if leakedFuture management actionsPush spam within daily quota only
Max per project22

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

HeaderValueRequired
AuthorizationBearer <api_key>Yes
Content-Typeapplication/jsonYes

Request Body

FieldTypeRequiredDescription
targetobjectYesTargeting definition (see subfields)
target.type"token" \| "topic" \| "multicast"YesTarget method
target.tokensstring[]When type is token or multicastFCM registration tokens. Max 500 for multicast
target.topicstringWhen type is topicFCM topic name. Must match ^[a-zA-Z0-9-_.~%]{1,900}$
titlestringYesNotification title. Up to 1000 chars (FCM recommends ~500 for Android)
bodystringYesNotification body. Up to 4,000 chars
dataRecord<string, string>NoCustom key-value payload passed to the app. Total payload ≤ 4KB. Values must be strings (FCM restriction)

Response — 200 OK

FieldTypeDescription
oktrueSuccess flag
message_idstringFCM message ID. Present for single-target sends; omitted for multicast
sent_countnumberNumber of requests successfully delivered to FCM
failure_countnumberNumber of failed tokens (for multicast)
failuresFailure[]Per-token failure details for multicast. Omitted for single-target
usageUsageCurrent usage information

`Failure` object:

FieldTypeDescription
tokenstringThe failed FCM token
error_codestringFCM error code (e.g. registration-token-not-registered)
messagestringHuman-readable error description

`Usage` object:

FieldTypeDescription
todaynumberCumulative usage today (includes this request)
limitnumberDaily limit for your plan
remainingnumberRemaining quota
resets_atstring (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

StatusCodeWhenRetry
400MALFORMED_JSONRequest body is not valid JSON
401MISSING_AUTHAuthorization header missing or not Bearer ...
401INVALID_KEYAPI key not recognized (typo, revoked, wrong environment)
404FIREBASE_NOT_CONFIGUREDProject has no active Firebase integration
422INVALID_REQUESTRequest validation failed. See message for the cause
422INVALID_TOKENFCM token malformed or not registered for your project
422INVALID_TOPICTopic name violates FCM naming rules
422PAYLOAD_TOO_LARGEtitle + body + data exceeds 4KB
422TOO_MANY_TOKENSMulticast exceeds 500 tokens
429RATE_LIMITEDDaily quota exceeded✅ Honor Retry-After
500FCM_ERRORFirebase returned an unexpected error. See details.fcm_error⚠️ Depends on cause
500INTERNAL_ERROROpseer 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

PlanMonthly priceDaily requests
Free$0100 (trial)
Starter$91,000
Plus$2410,000
Max$49100,000
Resets every day at UTC 00:00 (09:00 KST).

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

ItemRetention
API Key (active)Indefinite (until revoked)
API Key (revoked)Hidden from UI immediately; DB row kept 1 year then deleted
API usage counters90 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 load

Limits & 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.