Bildirim sends notifications to Android (FCM) and iOS (APNs) apps too. Mobile subscribers live in the same list as web subscribers; segments, campaigns, automations, measurement and quotas all work the same way. This page covers both routes: with the SDK (recommended — device registration, notification rendering and measurement come ready) and over REST (a few HTTP calls, for an app that already has a Firebase/APNs integration).
| Android | iOS | |
|---|---|---|
| SDK | Android SDK — io.bildirim:bildirim-android |
iOS SDK — Swift Package sdk-ios |
| REST | below | below |
Both SDKs are published: Android
io.bildirim:bildirim-android:1.0.1(Maven Central), iOS1.0.0(a Swift Package Manager tag + theBildirimCocoaPod). The REST route has not been closed and will not be — it is a valid option for apps that already have a Firebase/APNs integration; both routes use the same endpoints and moving between them loses nothing.
#How it works
- Your app receives a device token through Firebase (Android) or APNs (iOS).
- Your app registers that token with Bildirim using
POST /v1/subscribe. - When you send a campaign from the panel or call
POST /v1/push, Bildirim passes the token on to FCM/APNs.
Bildirim uses Google's FCM HTTP v1 interface and Apple's token-based APNs authentication. The credentials we ask you for are for those.
#Step 1 — Enter the provider credentials in the panel
The Settings → Mobile Push screen has two sections. You only need to fill in the platform you use.
#Android (FCM)
In the Firebase console, go to Project settings → Service accounts → Generate new private key, download the JSON file and paste its contents as-is into the "FCM service account JSON" field.
The file must contain project_id, client_email and private_key; the panel checks these when saving and says Invalid FCM service account JSON if anything is missing.
The old server key does not work — Google closed that interface and we use HTTP v1.
#iOS (APNs)
In your Apple Developer account, create a key with Apple Push Notification service enabled under Certificates, Identifiers & Profiles → Keys. The .p8 file can only be downloaded once; if you lose it you have to create a new one.
All four fields are required:
| Field | Where from |
|---|---|
| APNs Key (.p8) | The full contents of the downloaded file (including the -----BEGIN PRIVATE KEY----- line) |
| Key ID | The 10-character id on the key's page |
| Team ID | The 10-character team id at the top right of your account |
| Bundle ID | Your app's bundle identifier, e.g. com.company.app |
If one is missing, saving fails with All APNs fields are required.
The APNs environment: Apple runs two separate servers and tokens do not carry between them — a debug build installed from Xcode produces a sandbox token, TestFlight and the App Store produce production tokens. The SDK reports the device's environment with the registration and the server sends to each subscriber from their own environment. If you register over REST, send the apnsEnvironment field yourself; for records that do not send it, the APNs environment setting in the panel is the default.
Credentials are stored encrypted in the database and can never be read back into the panel; the screen only shows "entered / verified". To change them, paste new ones and save.
After saving, press Verify: the panel makes a trial request to the provider and tells you whether the key, the permissions and the Bundle ID are right. An unverified channel does not accept mobile registrations and does not open the send gate.
#Step 2 — Connect your app
#With the SDK
One line in Application.onCreate on Android, or in AppDelegate on iOS; then ask the user for permission:
Bildirim.initialize(this, "pk_...") // Android
Bildirim.requestPermission { granted -> }
Bildirim.initialize(appKey: "pk_...") // iOS
Bildirim.requestPermission { granted in }
The rest — getting the token, registering, updating when the token is refreshed, rendering the notification, click and impression tracking, the offline queue — is the SDK's job. Details: Android SDK, iOS SDK. The install snippet with your pk_ key already filled in is on the Settings → Mobile Push screen.
#Over REST (with your own code)
The same endpoint as the web. The difference is the channel and token fields:
POST https://api.bildirim.io/v1/subscribe
Content-Type: application/json
{
"projectKey": "pk_...",
"channel": "android",
"token": "<FCM device token>",
"installationId": "<a persistent UUID generated by the app>",
"apnsEnvironment": "production",
"appVersion": "3.2.1",
"os": "android",
"externalId": "user-42",
"tags": { "city": "istanbul" },
"timezone": "Europe/Istanbul"
}
channel is either android or ios. A 201 {"id": "...", "status": "subscribed"} response means the registration was accepted.
installationId— a random UUID your app generates on first launch and stores persistently (NOT an advertising id). When the token is refreshed, a new token arriving with the same installation id replaces the old record; without it the same device counts as two subscribers for a while.apnsEnvironment— iOS only:sandbox(Xcode debug) orproduction. A notification sent to the wrong environment fails withBadDeviceToken.appVersion,sdkVersion— optional; they appear in the subscriber list in the panel and help with support.
#The Origin header is NOT required
On the web, this endpoint verifies that the request comes from the project's site using the Origin header — the browser sets it and page JavaScript cannot change it. A native app sends no Origin and does not need to: that check is not applied on mobile channels.
A different rule applies instead: a mobile registration is only accepted if the project has that channel's provider credentials configured. You cannot register an iOS token without setting up APNs; you cannot register an Android token without setting up FCM. This is not an obstacle but the right order: without credentials you could not have sent anything anyway.
If the configuration is missing, the endpoint tells you what to do:
{
"error": "forbidden",
"message": "APNs is not configured for this project. Enter the .p8 key under Settings → Mobile Push."
}
// Android — OkHttp. No extra headers.
val request = Request.Builder()
.url("https://api.bildirim.io/v1/subscribe")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
// iOS — URLSession. No extra headers.
var request = URLRequest(url: URL(string: "https://api.bildirim.io/v1/subscribe")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
projectKey is the public key (pk_) and having it inside the app is fine — it is already visible to everyone in your website's HTML. Do not put the secret key (sk_) in the app; an app package is easy to open.
#When to send the token
- Right after the user grants notification permission.
- When Firebase's
onNewToken/ APNs'didRegisterForRemoteNotificationsgives a different token. - When the user signs in or out, or a tag changes.
- If 24 hours have passed since the last successful registration (version, language and time zone are refreshed).
Do not send on every app launch. Mobile networks in Türkiye put thousands of devices behind a single IP (CGNAT); an app that registers on every launch hits that IP's rate limit collectively. The limit is 20 registrations per minute per device and is never seen in normal use.
Sending the same token again is safe: a registration is unique per (project, token) pair, and a second request creates no new subscriber but updates the existing record. Sending tags is also a merge; a null value deletes that tag.
#Identifying the user
Put your own user id in the externalId field. If the same user has a phone and a browser, a send using externalIds reaches both.
#With identity verification on, identityHash is required
If the Settings → Keys → Identity verification switch is on in the panel, externalId is only accepted with its signature — the rule is the same on web and mobile and is enforced at the same endpoint. The signature is generated by your server:
identityHash = HMAC-SHA256(the project identity secret, externalId) // hex
The identity secret is under Settings → Keys in the panel. Do not embed the secret in the app: an app package can be opened, and if the secret leaks the verification is meaningless. The right flow is for your server to generate the signature when the user signs in and hand it to the mobile client (adding a field to your sign-in response is the easiest way).
POST /v1/subscribe
{ "projectKey": "pk_...", "channel": "ios", "token": "...",
"externalId": "user-42",
"identityHash": "9f2b…" // the signature your server generated
}
If the signature is missing or invalid, the endpoint returns:
{ "error": "identity_verification_required",
"message": "This project requires verified identity: externalId must be sent with an identityHash generated on your server (Settings → Keys → Identity verification)." }
While the setting is off (the default) you do not need to send identityHash. When the secret is rotated, old signatures are accepted for another 24 hours; that is long enough for your app to pick up the new one.
Why it exists:
externalIdcomes from the client and cannot be verified without a signature. If you send personal notifications (order status, account balance), an unsigned identity is a way for someone to pull another person's notification onto their own device.
#Step 3 — Send
There is no separate mobile send endpoint. Send a campaign from the panel, or:
curl -X POST https://api.bildirim.io/v1/push \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{"title":"Hello","body":"Our first mobile notification","url":"https://yoursite.com/offer"}'
By default a send goes to web, Android and iOS subscribers together. To send to specific platforms only, pass channels: "channels": ["android", "ios"] (the Channels checkboxes in the panel). channel is not a segment field; the platform choice is made with this field, not in a segment.
Two more optional fields: actions (up to 3 action buttons: [{"id":"read","label":"Read","url":"https://..."}]) and badge (the iOS app icon badge). Detail in the API documentation.
#The payload that reaches your app
Android (FCM) — data-only. There is no notification block; your app renders the notification (so images, buttons and measurement work even when the app is in the background). data.bildirim is a JSON string:
{
"data": {
"bildirim": "{\"c\":\"<campaign>\",\"t\":\"<tracking token>\",\"u\":\"https://...\",\"i\":\"https://.../image.jpg\",\"ic\":\"https://.../icon.png\",\"a\":[{\"id\":\"read\",\"l\":\"Read\"}],\"ti\":\"Title\",\"b\":\"Body\"}"
}
}
iOS (APNs) — the system renders it, and with mutable-content: 1 the Notification Service Extension can step in (attaching the image, measuring the impression):
{
"aps": { "alert": { "title": "...", "body": "..." }, "sound": "default", "mutable-content": 1, "badge": 3 },
"bildirim": { "c": "<campaign>", "t": "<tracking token>", "u": "https://...", "i": "https://.../image.jpg", "a": [{ "id": "read", "l": "Read" }] }
}
The fields: c the campaign id, t the tracking token, u the click address, i the large image, ic the icon, a the action buttons (id, l label, u address — the notification's address if absent), ti/b the title and body (FCM only). Empty fields are not present in the payload; if there is no bildirim key, the message is not from Bildirim. The full contract: GET https://api.bildirim.io/v1/mobile/contract.
Opening the u address when the notification is tapped is your app's job; the system does not do it for you.
#Report impressions and clicks
On mobile it is your app that opens the notification, not a browser, so a click cannot be measured with a redirect — your app reports it:
POST https://api.bildirim.io/v1/mobile/events
{ "projectKey": "pk_...", "channel": "android", "token": "<device token>",
"t": "<the bildirim.t from the payload>", "event": "clicked", "actionId": "read" }
event: displayed | clicked | dismissed. actionId only if a button was pressed. The response is { "status": "ok" | "duplicate" | "ignored" } — ignored means an expired token, not an error. The same event sent twice is counted once. The click counts on the notification screen show web and mobile events together.
#Differences between web and mobile
A few differences are deliberate. If you measure without knowing them, the numbers will mislead you:
| Feature | Web | Mobile |
|---|---|---|
| Sending | yes | yes |
| Segment / tag targeting | yes | yes |
Targeting by externalId |
yes | yes |
| Click tracking | yes (redirect) | yes (reported by the SDK/app — above) |
| Impression ("delivered") tracking | no | yes (Android SDK; on iOS with the NSE + App Group) |
| Action buttons | Chrome/Edge (Firefox/Safari ignore them) | yes |
App badge (badge) |
no | iOS only |
Tag personalisation ({{name}}) |
yes | yes |
| Free plan signature | added | added |
| Unsubscribe endpoint | /v1/unsubscribe |
yes (by token) |
Unsubscribing: when the user turns notifications off in your app, deactivate the record:
POST /v1/unsubscribe
{ "projectKey": "pk_...", "channel": "android", "token": "<device token>" }
We also clean up permanently invalid tokens ourselves (below).
#Dead tokens are cleaned up automatically
The provider's response is read on every send. If a token is permanently invalid (UNREGISTERED/NOT_FOUND/INVALID_ARGUMENT on FCM, 400/404/410 on APNs), that subscriber is deactivated and never retried. This is what happens when a user deletes the app.
Temporary errors (a network failure, a provider outage) do not deactivate a subscriber. Nor is any subscriber deactivated when credentials are missing or broken — the whole send is treated as failed and the list is untouched. That is deliberate: losing your subscriber list because of a mistyped .p8 would be unacceptable.
The validity of a .p8 file or an FCM service account is not checked when saving; the Settings → Mobile Push → Verify button makes a trial request to the provider and checks the key, the permissions and the bundle id. After entering credentials, press Verify first, then make a test send.
#Bulk token import
If you are coming from another provider you can import tokens as CSV: Subscribers → Import. Rows must contain channel (android/ios) and token columns. Up to 50,000 rows are processed at a time, and your plan quota is checked.
Imported tokens that belong to a different Firebase project or a different bundle id will not work — tokens are bound to your provider account. Test with a small sample before importing everything.
#The send gate applies on mobile too
Before a new project can send live it has to be verified and have made at least one test send. Projects that only use mobile do not need a website: verifying the FCM or APNs credentials with Settings → Mobile Push → Verify is enough; the test send is made with Send to my test device on the notification screen (your registered device appears in the list).
#Common problems
403 — "FCM/APNs is not configured for this project" — a mobile registration is not accepted until that channel's credentials are set on the project. Enter them under Settings → Mobile Push.
400 — "Web needs endpoint+keys; mobile needs a token" — you forgot to set channel to android/ios, so the system took the request for a web subscription.
400 — the token was rejected — a token must be at least 10 and at most 500 characters. You may be sending an empty or truncated token.
Registration succeeds but no notification arrives — check in order: are the credentials verified in the panel; on iOS, was the token's environment (sandbox/production) reported correctly with the registration; does the bundle id match what is in the panel; could the user have turned notifications off in system settings? The send report on the notification screen shows the failure count. Platform-specific lists: Android, iOS.
402 quota_exceeded — you have hit the plan's subscriber limit. Mobile and web subscribers share the same pool.
More: Troubleshooting.