Push notifications
How the Mobiari mobile apps register for push, what each push carries, how to open the right screen on tap, and how operators turn APNs and FCM on.
The merchant apps (iOS and Android, application id com.mobiari.merchant)
receive the same team alerts the platform sends by e-mail and WhatsApp: new
and paid orders, cancellations, low stock, and conversations that are
assigned to you or to nobody. The server sends iOS pushes through APNs and
Android pushes through Firebase Cloud Messaging (FCM).
Push is for team members signed in to the app. Store API tokens cannot register devices.
Register the device
Ask the OS for permission and a token first (iOS:
registerForRemoteNotifications then
didRegisterForRemoteNotificationsWithDeviceToken; Android:
FirebaseMessaging.getInstance().token and onNewToken). Then:
POST /v1/me/devices
Authorization: Bearer <access token>
Content-Type: application/json
{
"platform": "ios",
"token": "5f0c1e...9a",
"app_version": "1.4.0 (52)",
"device_name": "iPhone de Ana",
"locale": "pt-BR",
"environment": "production"
}| Field | |
|---|---|
platform | ios or android. |
token | iOS: the APNs device token as hex (the Data from the delegate, hex-encoded). Android: the FCM registration token. |
app_version, device_name | Optional, shown in the device list. |
locale | Optional BCP 47 tag. pt-* pushes are in Portuguese, en-* in English. Without it, the user's profile language. |
environment | iOS only. sandbox for debug builds run from Xcode, production (default) for TestFlight and the App Store. A token sent to the wrong gateway is rejected and the device is deleted. |
The answer is the device: 201 when it is new, 200 when the token was
already registered (it is updated). Keep its id for the test call and for
unregistering.
Rules the server applies:
- Registration is an upsert on
(platform, token). A token that was registered by another account moves to the caller. - The device is bound to the session of the access token (
sid). An earlier token of the same session and platform is dropped, so after the OS rotates the token you only register the new one. - Ending the session stops its pushes:
POST /auth/logout,DELETE /me/sessions/{session_id}, a password change elsewhere and refresh-token reuse all delete the session's devices. An expired session keeps the row but gets no pushes. - Tokens APNs or FCM report as invalid (app uninstalled, wrong app, wrong environment) are deleted.
When to call it:
- After every sign-in (the new session needs its own device row).
- Whenever the OS gives you a new token.
- On app start, if the token or the device language changed since the last registration. Calling it with nothing changed is harmless.
Before signing out, DELETE /v1/me/devices/{device_id} (the logout call
also removes it, but the explicit call does not depend on the refresh token
still being valid). GET /v1/me/devices lists the user's devices; current
marks the one registered by the calling session and active is false once
its session ended.
Test it
POST /v1/me/devices/{device_id}/testSends "Notifications are working on this device" (in the device's language) right away and reports what the provider said:
| Status | Meaning |
|---|---|
200 | Accepted: {device_id, provider: "apns" | "fcm", provider_message_id}. The user may still have notifications off. |
410 push_token_invalid | The provider rejected the token. The device was deleted; register again. |
502 push_provider_error | The provider refused or did not answer; error has its reason. |
503 push_not_configured | Push for this platform is not set up on the server. |
Preferences
Each member chooses, per store, which alerts push to their devices:
GET /v1/stores/{store_id}/me/push-preferences
PUT /v1/stores/{store_id}/me/push-preferences
{ "is_enabled": true, "enabled_events": ["orders.paid", "conversations.message_received"] }available_events lists the alerts the member can receive in that store
(their permissions decide). Until they save, is_default is true and the
defaults apply: every order and conversation alert on, low stock off.
is_enabled: false silences the store entirely. Preferences apply to every
device of the user.
Alert (kind) | When | Who gets it | Needs |
|---|---|---|---|
orders.created | A new order (online or a manual sale) | Every member who has it on | orders.read |
orders.paid | Payment confirmed (once per order, however many times the provider confirms) | Same | orders.read |
orders.cancelled | Order cancelled | Same | orders.read |
inventory.low_stock | A variant is down to 5 or fewer units at a location (at most once a day per variant and location) | Same | inventory.read |
conversations.started | A customer started a conversation and the chat bot is off | The assignee, or every member when unassigned | conversations.read |
conversations.message_received | A customer wrote and the chat bot is off | The assignee, or every member when unassigned | conversations.read |
conversations.handed_over | The chat bot handed the conversation to the team | The assignee, or every member when unassigned | conversations.read |
conversations.assigned | Someone else assigned a conversation to you | The new assignee | conversations.read |
New kinds will be added. Ignore a kind you do not know (open the app's
home screen).
What a push carries
Every push has a localized title and body, and deep-link data:
| Key | Present |
|---|---|
kind | Always. One of the kinds above, or test. |
store_id | Always, except test. |
order_id | orders.* |
conversation_id | conversations.* |
product_id, variant_id | inventory.low_stock |
All values are strings (UUIDs).
iOS (APNs). The data keys sit at the top level of the payload, next to
aps:
{
"aps": {
"alert": { "title": "Pedido #1042 pago", "subtitle": "Loja Azul", "body": "R$ 1.234,56 · Ana Souza" },
"sound": "default",
"badge": 3,
"thread-id": "store-0199a0c4-..."
},
"kind": "orders.paid",
"store_id": "0199a0c4-...",
"order_id": "0199b7e2-..."
}subtitle is the store name. thread-id groups a conversation's pushes
(conv-<conversation id>) and a store's other alerts (store-<store id>).
Pushes use apns-push-type: alert, priority 10, and expire after an hour.
Android (FCM). A notification message with the data under data:
{
"notification": { "title": "Order #1042 paid", "body": "R$1,234.56 · Ana Souza" },
"data": { "kind": "orders.paid", "store_id": "0199a0c4-...", "order_id": "0199b7e2-..." },
"android": {
"priority": "high",
"ttl": "3600s",
"collapse_key": "order-paid-0199b7e2-...",
"notification": { "channel_id": "orders", "tag": "order-paid-0199b7e2-...", "notification_count": 3, "sound": "default" }
}
}Create three notification channels in the app so users can mute them
separately: orders, conversations and inventory. Until a channel
exists, Android shows the push in the app's default channel.
Collapsing. Pushes about the same conversation share a collapse key
(apns-collapse-id / collapse_key and tag = conv-<conversation id>):
a burst of messages leaves one notification with the latest text. Low stock
collapses per variant; order alerts per order and kind.
Badge. The badge is the number of open or pending conversations with
unread messages that are assigned to the user or to nobody, in the store the
push is about (the unread side of GET /stores/{store_id}/conversations/counts
narrowed to yours and unassigned). It is omitted for members without
conversations.read. Recompute it from the counts endpoint when the app
opens.
Handling a tap
Read kind and store_id from the push data. If store_id is not the
store currently selected in the app, switch to it first (the user may belong
to several stores), then:
kind | Open |
|---|---|
orders.created, orders.paid, orders.cancelled | The order: GET /stores/{store_id}/orders/{order_id} |
conversations.* | The conversation thread: GET /stores/{store_id}/conversations/{conversation_id} |
inventory.low_stock | The variant's stock: GET /stores/{store_id}/variants/{variant_id}/stock (or the product, product_id) |
test | Nothing in particular (the home screen) |
| anything else | The home screen |
If the user lost access to the store or the record is gone, the call
answers 403 or 404; show the store's home instead.
For operators
Push is off until credentials are configured. With none, the backend logs
push notifications disabled once at startup and everything else works as
before; POST /me/devices/{id}/test answers 503 push_not_configured.
Each provider is enabled on its own: configure only APNs and Android devices
simply get nothing (and vice versa). A key that does not parse disables that
provider with an error in the startup log; the backend still starts.
| Variable | |
|---|---|
APNS_KEY_P8 | Contents of the APNs auth key (AuthKey_XXXXXXXXXX.p8, PKCS#8 PEM). In an env file, put it on one line with \n for the newlines. |
APNS_KEY_PATH | Alternative to APNS_KEY_P8: path to the .p8 file inside the container. |
APNS_KEY_ID | The key's 10-character id. |
APNS_TEAM_ID | The Apple Developer team id. |
APNS_TOPIC | The app's bundle id. Default com.mobiari.merchant. |
FCM_SERVICE_ACCOUNT_JSON | A Firebase service-account key JSON (Project settings, Service accounts, "Generate new private key"), minified to one line. |
FCM_SERVICE_ACCOUNT_PATH | Alternative: path to the JSON file inside the container. |
FCM_PROJECT_ID | The Firebase project id. Defaults to the service account's project_id. |
APNs uses token-based auth: the backend signs an ES256 JWT with the key and
talks HTTP/2 to api.push.apple.com (or api.sandbox.push.apple.com for
devices registered with environment: sandbox). One key works for both
gateways and every app of the team. FCM uses the HTTP v1 API; the backend
exchanges a JWT signed with the service account for an OAuth2 access token
and reuses it until it expires.
Deliveries go through the job queue (jobs table, kind push.deliver):
one job per device, retried with exponential backoff on provider 5xx, 429
and network errors, and dead-lettered after 5 attempts or on errors a retry
cannot fix (bad credentials, rejected payload). A dead-lettered push job
with InvalidProviderToken or THIRD_PARTY_AUTH_ERROR means the credentials
are wrong.