MobiariDocs

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
platformios or android.
tokeniOS: the APNs device token as hex (the Data from the delegate, hex-encoded). Android: the FCM registration token.
app_version, device_nameOptional, shown in the device list.
localeOptional BCP 47 tag. pt-* pushes are in Portuguese, en-* in English. Without it, the user's profile language.
environmentiOS 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:

  1. After every sign-in (the new session needs its own device row).
  2. Whenever the OS gives you a new token.
  3. 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}/test

Sends "Notifications are working on this device" (in the device's language) right away and reports what the provider said:

StatusMeaning
200Accepted: {device_id, provider: "apns" | "fcm", provider_message_id}. The user may still have notifications off.
410 push_token_invalidThe provider rejected the token. The device was deleted; register again.
502 push_provider_errorThe provider refused or did not answer; error has its reason.
503 push_not_configuredPush 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)WhenWho gets itNeeds
orders.createdA new order (online or a manual sale)Every member who has it onorders.read
orders.paidPayment confirmed (once per order, however many times the provider confirms)Sameorders.read
orders.cancelledOrder cancelledSameorders.read
inventory.low_stockA variant is down to 5 or fewer units at a location (at most once a day per variant and location)Sameinventory.read
conversations.startedA customer started a conversation and the chat bot is offThe assignee, or every member when unassignedconversations.read
conversations.message_receivedA customer wrote and the chat bot is offThe assignee, or every member when unassignedconversations.read
conversations.handed_overThe chat bot handed the conversation to the teamThe assignee, or every member when unassignedconversations.read
conversations.assignedSomeone else assigned a conversation to youThe new assigneeconversations.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:

KeyPresent
kindAlways. One of the kinds above, or test.
store_idAlways, except test.
order_idorders.*
conversation_idconversations.*
product_id, variant_idinventory.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:

kindOpen
orders.created, orders.paid, orders.cancelledThe order: GET /stores/{store_id}/orders/{order_id}
conversations.*The conversation thread: GET /stores/{store_id}/conversations/{conversation_id}
inventory.low_stockThe variant's stock: GET /stores/{store_id}/variants/{variant_id}/stock (or the product, product_id)
testNothing in particular (the home screen)
anything elseThe 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_P8Contents 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_PATHAlternative to APNS_KEY_P8: path to the .p8 file inside the container.
APNS_KEY_IDThe key's 10-character id.
APNS_TEAM_IDThe Apple Developer team id.
APNS_TOPICThe app's bundle id. Default com.mobiari.merchant.
FCM_SERVICE_ACCOUNT_JSONA Firebase service-account key JSON (Project settings, Service accounts, "Generate new private key"), minified to one line.
FCM_SERVICE_ACCOUNT_PATHAlternative: path to the JSON file inside the container.
FCM_PROJECT_IDThe 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.

On this page