PushNotice — Client Integration Guide
Everything a mobile developer needs to connect an app to the PushNotice platform.
- Base URL:
https://app.rspush.com - API root:
https://app.rspush.com/api/v1 - All requests and responses are JSON. HTTPS only.
1. What your app has to do
PushNotice delivers notifications through Firebase Cloud Messaging (FCM) — for both Android and iOS (iOS is relayed to APNs by Firebase). Your app:
- Integrates the Firebase Cloud Messaging SDK and obtains an FCM registration token.
- Sends that token to
POST /subscribers(register). - Re-sends it whenever Firebase rotates the token (refresh).
- Handles notification taps by reading the data payload (see §7).
- Optionally calls
DELETE /subscriberswhen a user opts out.
PushNotice does not replace the Firebase SDK — you still initialise Firebase normally. PushNotice is the server that stores tokens, groups them, and sends to them.
2. Prerequisites
| Requirement | Notes |
|---|---|
| Firebase project | Must be the same project whose service-account JSON is configured in PushNotice → App → Settings → Firebase credentials. Confirm the project_id matches. |
google-services.json (Android) |
From that project, added to the Android app module. |
GoogleService-Info.plist (iOS) |
From that project, added to the Xcode target. Exact filename, no suffix. |
| APNs key (iOS) | A .p8 APNs Auth Key uploaded in Firebase Console → Project settings → Cloud Messaging → Apple app configuration. Without it, iOS pushes silently fail. |
| Firebase Messaging SDK | firebase-messaging (Android) / FirebaseMessaging (iOS). |
| API key | Generated in PushNotice → App → Settings → API keys. Format: pnp_ + 48 chars. Scoped to one app; can only register/unsubscribe devices — cannot send pushes or read data. Safe to embed in the app binary. |
3. Authentication
Every request must present the app’s API key. Three accepted ways (pick one):
Authorization: Bearer pnp_xxxxxxxx… ← preferred
X-Api-Key: pnp_xxxxxxxx…
?api_key=pnp_xxxxxxxx… ← query string, avoid
The {slug} in the URL path must match the app the key belongs to, or the request is rejected with 403.
| Response | Meaning |
|---|---|
401 {"message":"Missing API key."} |
No key presented |
401 {"message":"Invalid API key."} |
Key not recognised or revoked |
403 {"message":"API key does not match this app."} |
Valid key, wrong app for {slug} |
Revoking a key in the dashboard takes effect immediately.
4. Endpoints
POST /api/v1/apps/{slug}/subscribers — register / refresh
Idempotent on (app, token). Call on every app launch and on every token refresh — safe to call repeatedly.
| Field | Type | Req. | Notes |
|---|---|---|---|
token |
string | yes | The FCM registration token. 10–4096 chars. |
previous_token |
string | no | The device’s prior FCM token. On a refresh, pass the old token here so the existing record is migrated instead of a duplicate created. |
platform |
string | no | ios | android | web | unknown. Defaults to unknown — send it. |
external_id |
string | no | Your own user/account identifier, if the app has accounts. Max 255. |
locale |
string | no | e.g. en-US. Max 20. |
app_version |
string | no | e.g. 1.4.0. Max 50. |
device_model |
string | no | e.g. iPhone15,3. Max 120. |
meta |
object | no | Arbitrary JSON, stored verbatim. |
groups |
string[] | no | Names of existing groups to add this device to. Unknown names are ignored. Every device is always added to the built-in Master group. |
| Status | Body | When |
|---|---|---|
201 |
{"id":42,"status":"active","created":true} |
New device |
200 |
{"id":42,"status":"active","created":false} |
Existing device refreshed |
422 |
{"message":"…","errors":{"token":["…"]}} |
Validation failed |
A device previously marked inactive or invalid is set back to active on a successful register.
curl -X POST https://app.rspush.com/api/v1/apps/ktso/subscribers \
-H "Authorization: Bearer pnp_xxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{
"token": "fY3...:APA91b...",
"platform": "ios",
"app_version": "1.4.0",
"locale": "en-US",
"device_model": "iPhone15,3"
}'
DELETE /api/v1/apps/{slug}/subscribers — unsubscribe
Soft unsubscribe: the record is kept for reporting, status set to inactive, and it stops receiving pushes. Call on logout / notifications-disabled.
| Field | Type | Req. |
|---|---|---|
token |
string | yes |
Response — always 200 {"ok":true}, even if the token was unknown.
curl -X DELETE https://app.rspush.com/api/v1/apps/ktso/subscribers \
-H "Authorization: Bearer pnp_xxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{"token": "fY3...:APA91b..."}'
5. Registration lifecycle
| Event | Action |
|---|---|
| App first launch (after notification permission granted) | POST /subscribers with the current FCM token |
| Every subsequent launch | POST /subscribers again (idempotent; refreshes last_seen, app_version, …) |
Firebase onNewToken / didReceiveRegistrationToken |
POST /subscribers with token = new, previous_token = old |
| User disables notifications / logs out | DELETE /subscribers with the current token |
| User re-enables | POST /subscribers again — reactivates the record |
Persist the last token you registered locally so you can supply previous_token on refresh.
6. Subscriber status & automatic cleanup
| Status | Meaning |
|---|---|
active |
Receiving pushes |
inactive |
Unsubscribed via DELETE — kept for history |
invalid |
FCM reported the token as unregistered during a send. Set automatically; the device is skipped on future sends until it re-registers. |
You don’t manage these — keep registering on launch/refresh and the platform keeps itself clean.
7. What the device receives
Each push is an FCM message with both a notification block (drives the OS notification) and a data block (key/value strings). Your tap handler should read from data.
notification
| Key | Source |
|---|---|
title |
send title |
body |
send body |
image |
send image URL, if set |
data (all values are strings)
| Key | Always? | Source |
|---|---|---|
title |
yes | mirror of the notification title |
body |
yes | mirror of the notification body |
send_id |
yes | PushNotice’s ID for this send — use as the stable key for an on-device “clicked” archive |
launch_url |
if set | deep link / URL to open |
url |
if set | same value as launch_url (alias) |
image_url |
if set | image URL |
| (custom) | — | any key/value pairs added to the send’s Custom data field |
Also set: Android priority high; WebPush fcm_options.link = launch_url when present.
Platform notes
data payload in the intent extras — the notification title/body are not included. Read data.title / data.body / data.send_id from getIntent().getExtras(). This is why the data mirror exists; relying on the notification block leaves an on-device click archive blank on Android (older versions especially).- Android 13+ (API 33): request the
POST_NOTIFICATIONSruntime permission. - Android notification channel: create a channel; without one, notifications are dropped silently on API 26+.
- iOS — rich images: an image only renders if the app includes a Notification Service Extension that downloads the URL and attaches it. Firebase sets
mutable-contentautomatically; the extension is your code. Text-only works without it. - iOS — tap handling: the full payload (including
data) is delivered touserNotificationCenter(_:didReceive:), so bothnotificationanddataare readable.
8. Errors & retries
- No rate limiting is enforced, but register once per launch, not in a loop.
- All endpoints are idempotent — safe to retry.
- Retry on network failure and
5xxwith exponential backoff (e.g. 2s, 8s, 30s). - Do not retry
401/403(fix the key) or422(fix the payload).
9. Groups
- Groups (e.g.
Test,Music Team) are created by operators in the PushNotice dashboard. - Every device is automatically in Master — the default target for a broadcast.
- Your app may pass
groups: ["Test"]at registration to self-assign to an existing group. Unknown names are ignored, not an error. - Operators can also move devices between groups from the dashboard.
10. Reference — minimal client snippets
iOS (Swift) — register on launch and on refresh
import FirebaseMessaging
func registerPushToken(_ token: String, previous: String? = nil) {
var body: [String: Any] = ["token": token, "platform": "ios"]
if let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
body["app_version"] = v
}
if let previous { body["previous_token"] = previous }
var req = URLRequest(url: URL(string: "https://app.rspush.com/api/v1/apps/ktso/subscribers")!)
req.httpMethod = "POST"
req.setValue("Bearer pnp_xxxxxxxx…", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: req).resume()
}
// MessagingDelegate
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
guard let fcmToken else { return }
let previous = UserDefaults.standard.string(forKey: "pnp_token")
registerPushToken(fcmToken, previous: previous == fcmToken ? nil : previous)
UserDefaults.standard.set(fcmToken, forKey: "pnp_token")
}
Android (Kotlin) — register, and read a tap
// FirebaseMessagingService
override fun onNewToken(token: String) {
val previous = prefs.getString("pnp_token", null)
registerPushToken(token, if (previous == token) null else previous)
prefs.edit().putString("pnp_token", token).apply()
}
fun registerPushToken(token: String, previous: String?) {
val json = JSONObject().apply {
put("token", token)
put("platform", "android")
put("app_version", BuildConfig.VERSION_NAME)
previous?.let { put("previous_token", it) }
}
val req = Request.Builder()
.url("https://app.rspush.com/api/v1/apps/ktso/subscribers")
.header("Authorization", "Bearer pnp_xxxxxxxx…")
.post(json.toString().toRequestBody("application/json".toMediaType()))
.build()
okHttp.newCall(req).enqueue(/* retry on IOException / 5xx */)
}
// Launch Activity — record which push was tapped
val extras = intent.extras
val sendId = extras?.getString("send_id")
val title = extras?.getString("title")
val body = extras?.getString("body")
val url = extras?.getString("launch_url")
11. Test checklist
- Fresh install registers → device appears in the dashboard Subscribers tab as
active, in Master. - Second launch → same subscriber row,
last_seenupdates, no duplicate. - Token refresh with
previous_token→ same row keeps its ID, token value changes. - Send a test push from the dashboard → arrives on iOS and Android.
- Tap it on Android (app killed) → your click archive records
send_id/title/body. - Tap it on iOS → same.
launch_urlset → tapping opens the right screen/URL.- Uninstall the app, send again → next send marks that subscriber
invalidautomatically. DELETE /subscribers→ subscriber goesinactive, stops receiving.