OpseerOpseer
Docs
Dashboard

Push API

모바일 앱, 웹, 서버 어디서든 REST API 하나로 FCM 푸시를 발송할 수 있습니다. Opseer가 Firebase 서비스 계정을 대신 관리하므로 푸시 전용 서버를 따로 운영하지 않아도 됩니다.

Last updated: 2026-05-18

Opseer Push API로 Firebase Cloud Messaging(FCM) 푸시를 보냅니다. 대시보드에서 Firebase 서비스 계정을 한 번 연결하면, 이후 모바일·웹·서버 어디서든 API Key를 실어 HTTP POST 한 번으로 발송 가능합니다.

Base URL: https://api.opseer.com/v1 · 현재 엔드포인트 1개(POST /push/send) · 베타

인증

모든 요청은 Authorization 헤더에 Bearer 스킴으로 API Key를 포함해야 합니다.

Authorization: Bearer opseer_sk_a1b2c3d4e5f6...

API Key 종류

프로젝트마다 두 종류의 키를 발급합니다. 키가 놓이는 환경(서버 vs 앱/브라우저)에 따라 선택합니다.

항목Server KeyClient Key
Prefixopseer_sk_…opseer_ck_…
권한Push API 전체push:send
사용 환경백엔드, Edge Function, cron, CI/CD모바일 앱, 브라우저 JS, 확장 프로그램
노출 가정비공개 (환경 변수·시크릿 매니저)공개 (앱 바이너리에 번들됨)
유출 시 피해 범위향후 관리 기능 전체일일 쿼터 내 푸시 스팸만
프로젝트당 최대2개2개

두 종류를 동시에 보유할 수 있고, 같은 프로젝트의 쿼터를 공유합니다.

키 생성과 폐기

키는 Opseer 대시보드의 Project Settings → API Keys 페이지에서 발급·폐기합니다. API에는 키 생성 엔드포인트가 없습니다(민감 자산은 UI를 통한 명시적 허가 필요).

  • 전체 키는 발급 시점에 한 번만 표시됩니다. Opseer는 SHA-256 해시만 저장하므로 분실 시 복구 불가 — 폐기 후 재발급
  • 폐기는 다음 요청부터 즉시 적용됩니다
  • 폐기된 키는 UI에서 즉시 숨김, DB에는 1년간 보관 후 스케줄 정리 (쿼터 정합성 보장용)

POST /push/send

단일 토큰, 토픽, 또는 multicast(최대 500개 토큰)에 FCM 푸시를 즉시 발송합니다.

Headers

HeaderValueRequired
AuthorizationBearer <api_key>Yes
Content-Typeapplication/jsonYes

Request Body

FieldTypeRequiredDescription
targetobjectYes발송 대상 정의. 아래 세부 필드 참조
target.type"token" \| "topic" \| "multicast"Yes타겟 방식
target.tokensstring[]type이 token·multicast일 때FCM 등록 토큰 배열. multicast는 최대 500개
target.topicstringtype이 topic일 때FCM 토픽명. 정규식 ^[a-zA-Z0-9-_.~%]{1,900}$
titlestringYes알림 제목. 1000자 이내 (FCM에서 Android는 약 500자 권장)
bodystringYes본문. 4,000자 이내
dataRecord<string, string>No앱에 전달할 커스텀 key-value. 전체 페이로드 4KB 이내. FCM 제약으로 문자열 값만 허용

Response — 200 OK

FieldTypeDescription
oktrue성공 플래그
message_idstringFCM 메시지 ID. 단일 타겟일 때만, multicast는 생략
sent_countnumberFCM에 성공적으로 전달된 요청 수
failure_countnumber실패한 토큰 수 (multicast 시)
failuresFailure[]multicast 부분 실패 상세. 단일 타겟일 때는 생략
usageUsage현재 사용량 정보

`Failure` 객체:

FieldTypeDescription
tokenstring실패한 FCM 토큰
error_codestringFCM 에러 코드 (예: registration-token-not-registered)
messagestring사람이 읽을 수 있는 메시지

`Usage` 객체:

FieldTypeDescription
todaynumber오늘 누적 사용량 (이 요청 포함)
limitnumber플랜별 일일 한도
remainingnumber남은 쿼터
resets_atstring (ISO 8601)다음 리셋 시각 (UTC 00:00)

동일한 정보가 `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` 응답 헤더에도 포함됩니다.

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": "새 댓글",
    "body": "지나님이 댓글을 남겼어요",
    "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 부분 실패 응답:

{
  "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" }
}

multicast 응답의 failures 배열에 담긴 토큰은 본인 DB에서 삭제하세요. 앱 삭제나 알림 권한 해제 시 무효 처리된 토큰이며, 남겨두면 이후 multicast에서 쿼터만 낭비됩니다.

Errors

모든 에러는 일관된 JSON 구조로 반환됩니다.

{
  "ok": false,
  "error": "INVALID_TOKEN",
  "message": "The FCM token is not registered for your Firebase project.",
  "details": { /* 에러별 추가 컨텍스트가 있을 때 포함 */ }
}

Error Codes

StatusCode발생 조건Retry
400MALFORMED_JSON요청 본문이 JSON 파싱 불가
401MISSING_AUTHAuthorization 헤더 없음 또는 Bearer 로 시작 안 함
401INVALID_KEYAPI Key 인식 불가 (오타·폐기·환경 혼용)
404FIREBASE_NOT_CONFIGURED프로젝트에 활성 Firebase 연동 없음
422INVALID_REQUEST요청 검증 실패. message에 원인 명시
422INVALID_TOKENFCM 토큰이 잘못됐거나 본인 Firebase 프로젝트에 미등록
422INVALID_TOPIC토픽명이 FCM 규칙과 불일치
422PAYLOAD_TOO_LARGEtitle + body + data 총합이 4KB 초과
422TOO_MANY_TOKENSmulticast 토큰이 500개 초과
429RATE_LIMITED일일 쿼터 초과✅ Retry-After 헤더 존중
500FCM_ERRORFirebase 예외. details.fcm_error에 원본⚠️ 원인에 따라 다름
500INTERNAL_ERROROpseer 서버 에러✅ 지수 백오프

Rate Limits

계정(프로젝트 소유자) 단위로 **일일 쿼터만** 적용합니다. 키별·프로젝트별 아님, 분당/IP 기반 아님.

  • 키나 프로젝트 단위로 한도를 두면 여러 개 만들어 우회 가능 → 계정으로 묶음
  • 분당·burst·IP 기반은 공유 NAT·CGNAT 환경에서 오탐이 잦고 구현 복잡도가 큼 → 제외
  • 대신 일일 상한 + Client Key 스코프 제한 + 즉시 폐기로 어뷰징 통제

플랜별 일일 쿼터

플랜월 요금일일 요청
Free$0100 (체험용)
Starter$91,000
Plus$2410,000
Max$49100,000
매일 UTC 00:00(한국 시간 오전 9시)에 리셋됩니다.

쿼터 포함 규칙

  • /push/send 성공 요청은 multicast 토큰 수와 무관하게 1건으로 계산 (500 토큰 multicast도 1 쿼터)
  • 422 INVALID_REQUEST로 거부된 요청은 쿼터 제외 (FCM 호출 안 일어남)
  • 401 인증 실패도 쿼터 제외 (FCM 호출 전 거부)
  • FCM까지 도달 후 실패(예: 모든 토큰 무효)는 쿼터 포함

429 응답 포맷

{
  "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
  }
}

RFC 7231에 따라 동일한 값을 `Retry-After` HTTP 헤더로도 전달합니다.

Security & Best Practices

  • 모든 요청은 HTTPS. 평문 HTTP는 거부
  • Server Key는 환경 변수·시크릿 매니저에 보관. git 커밋 금지. CI 시크릿 스캐너 사용 권장 (GitHub secret scanning, Gitleaks 등)
  • Client Key는 공개 자산으로 취급. 난독화 X, 스코프 제한과 일일 쿼터에 의존
  • 유출 의심 시 대시보드에서 즉시 폐기 → 다음 요청부터 즉시 거부
  • 대시보드 사용량 그래프 주기적 확인. 평소 대비 지속적 스파이크는 조사 신호
  • 로테이션 시 구·신 키를 최소 한 배포 주기 오버랩 → 발송 갭 방지
  • 환경(production·staging)마다 별도 키 사용

Opseer의 서비스 계정 취급

Firebase 연동 시 서비스 계정 JSON을 업로드하면 AES-256-GCM으로 즉시 암호화해 저장합니다. DB에는 암호화된 blob만 존재하며, 푸시 요청이 올 때만 메모리에서 복호화합니다. 평문은 로그에 남기지 않고 Google FCM 외 다른 서버로 전송하지 않습니다.

Data Retention

대상보관 주기
API Key (active)영구 (폐기 전까지)
API Key (revoked)UI 즉시 숨김, DB 1년 후 삭제
API 사용량 카운터90일 후 자동 삭제
푸시 페이로드 (title, body, data)저장 안 함 (FCM 스트리밍 후 폐기)
FCM 응답 (message_id, failures)저장 안 함 (요청 응답으로만 반환)

발송 푸시를 장기 감사하려면 응답 본문의 message_id와 발송 결과를 본인 시스템에 기록해두세요. Opseer는 푸시 로그 아카이브 역할을 하지 않습니다.

환경별 예제

Next.js API Route / Supabase Edge Function (Server Key)

// app/api/notify/route.ts
// .env.local 에 OPSEER_API_KEY 설정

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: '새 댓글',
      body: `${commenterName}님이 댓글을 남겼어요`,
      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 / 브라우저 (Client Key)

const OPSEER_CLIENT_KEY = 'opseer_ck_xxxxxxxxxxxxxxxxxxxx' // 앱 번들에 내장

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": "새 게시글이 올라왔어요",
    "body": "최신 업데이트를 확인해보세요",
    "data": {"type": "announcement"}
  }'

Firebase Admin SDK에서 옮기기

firebase-admin 기반 코드가 있다면 SDK 호출을 fetch로 바꾸는 수준으로 마이그레이션됩니다.

이전:

import admin from 'firebase-admin'

admin.initializeApp({
  credential: admin.credential.cert(require('./service-account.json')),
})

await admin.messaging().send({
  token: authorFcmToken,
  notification: { title: '새 댓글', body: commenterMessage },
  data: { post_id: '12345' },
})

이후:

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: '새 댓글',
    body: commenterMessage,
    data: { post_id: '12345' },
  }),
})
// SDK 초기화·서비스 계정 파일 로딩 불필요

제약과 로드맵

Phase 1 현재 제약

  • 엔드포인트 1개: POST /push/send (즉시 발송)
  • Multicast 요청당 최대 500 토큰 (FCM 제약)
  • 페이로드 최대 4KB (FCM 제약)
  • 프로젝트당 Server Key 2개 + Client Key 2개
  • 일일 쿼터만 적용 (분당 rate limit 없음)

로드맵

  • Phase 2 — POST /push/schedule(예약 발송), GET /push/{id}(상태 조회)
  • Phase 3 — 발송 결과 웹훅, 변수 보간 템플릿
  • 이후 — Alert dispatch API, Remote Config 읽기/쓰기 API

Push API는 베타입니다. 엔드포인트 경로(/api/v1/push/send)는 안정적이며, 응답 스키마는 추가형(additive-only) 변경만 발생합니다. Breaking change는 /api/v2로 6개월 deprecation 공지 후 이전합니다.

FAQ

모바일 앱이 Firebase Client SDK만으로 푸시를 보낼 수 있나요?

불가능합니다. Client SDK는 푸시 수신만 지원합니다. 발송은 서비스 계정이 필요해 서버에서만 가능하며, 이 간극을 Opseer Push API가 메웁니다.

Firebase 서비스 계정으로 직접 FCM을 호출해도 되나요?

가능합니다. 볼륨이 매우 크면 그 편이 더 합리적일 수 있습니다. Opseer는 제로 세팅과 통합 관측을 우선하는 인디 개발자·소규모 팀 타겟입니다.

500개 토큰 multicast가 왜 1건으로 계산되나요?

Opseer 비용은 FCM 호출이 지배합니다. multicast도 단일 FCM 호출이므로 실제 비용이 1건 분량입니다. 토큰 단위로 계산하면 실제 비용과 무관한 증가를 사용자가 부담하게 됩니다.

같은 계정의 프로젝트마다 쿼터를 다르게 설정할 수 있나요?

불가능합니다. 일일 쿼터는 계정 단위이며 모든 프로젝트가 공유합니다. 프로젝트 간 엄격한 격리가 필요하면 Opseer 계정을 별도로 만드세요.

실패한 푸시도 쿼터를 소모하나요?

FCM에 도달한 요청은 FCM이 실패 응답을 주더라도 쿼터를 소모합니다. FCM 호출 전에 거부된 요청(검증·인증 에러)은 소모하지 않습니다. 원칙은 "실제 발생한 컴퓨팅·네트워크 비용과 쿼터 소모 일치"입니다.

Server Key를 분실하면?

대시보드에서 폐기하고 새 키를 발급하세요. Opseer는 해시만 저장하므로 복구 불가능합니다. 발급 시점에 시크릿 매니저에 즉시 저장해두세요.

무효화된 FCM 토큰에 보내면?

단일 타겟은 422 INVALID_TOKEN, multicast는 failures 배열에 포함되어 반환됩니다. 해당 토큰을 본인 DB에서 삭제하세요. 앱 재설치 전까지 다시 유효해지지 않습니다.

설계 결정 사항

이 API의 주요 설계 선택과 그 근거를 요약합니다. 연동하는 데 반드시 읽을 필요는 없지만, 동작을 깊게 이해하고 싶을 때 참고하세요.

Bearer 토큰 (Query 파라미터·X-API-Key 대신)

Query 파라미터는 서버 로그·프록시 로그·브라우저 히스토리에 그대로 남아 시크릿 취급에 부적합합니다. X-API-Key 같은 커스텀 헤더도 동작하지만 비표준입니다. Bearer 토큰은 OAuth 2.0 표준이라 대부분의 HTTP 클라이언트와 프록시가 기본 지원합니다. HTTPS에서는 보안 수준에 차이가 없고 이식성이 가장 좋습니다.

키 2종(Server·Client) 분리 (단일 키 + ACL 대신)

Custom claims나 세분화 scope 방식은 강력하지만 실수에 취약합니다. scope 설정이 잘못되면 클라이언트용 키에 관리자 권한이 붙을 수 있습니다. Prefix 하드코딩 방식은 인증 시점에 서버가 타입을 강제하므로 이런 실수가 불가능합니다. 유연성은 약간 줄지만 보안 크리티컬 영역에서 올바른 선택입니다.

계정 단위 일일 쿼터만 (키·분당·IP 기반 대신)

키/프로젝트 단위 한도는 여러 개 만들어 우회할 수 있어 비용 예측이 불가능합니다. 분당·burst 기반 limiter는 공유 NAT·CGNAT 환경에서 오탐이 잦고 구현 복잡도가 큽니다. 계정 단위 일일 쿼터는 이런 문제 없이 비용 상한을 명확하게 고정합니다.

키 수량을 플랜 차등 대신 2+2 고정

프로젝트 1개는 키 2+2로 충분합니다(운영 1개 + 로테이션용 1개 × Server/Client). 여러 서비스를 운영하면 프로젝트를 나누는 것이 자연스러운 단위이고, 전 플랜 무제한 프로젝트입니다. 플랜 차별화는 수량이 아닌 일일 호출량에 집중합니다.

multicast 500 토큰 = 1 쿼터

Opseer 비용은 FCM 호출이 지배하고, multicast는 500 토큰이라도 단일 호출입니다. 토큰 단위 계산은 실제 비용과 무관한 선형 증가를 사용자에게 부담시킵니다. "1 요청 = 1 쿼터" 규칙이 단순하고 공정합니다.

푸시 이력 미저장

개별 푸시의 title·body·target·결과를 DB에 저장하지 않습니다. 이유: 쓰기 볼륨 2배 증가와 저장 용량 부담. 대신 사용량 카운터(api_key_usage)로 키별 일일 총량만 추적합니다. 개별 감사가 필요한 사용자는 응답 본문의 message_id와 발송 결과를 본인 시스템에 기록하면 됩니다. Phase 2에서 옵션 기능으로 재검토 예정입니다.