Send push notifications to your own iOS and Android app — through your own Apple and Firebase credentials — with one API call, and reach the same people you message on WhatsApp.

TL;DR

  • What it is — a channel for your mobile app. You connect the app once (Settings → Connections → Push notifications), paste the reference code into the app, and send with POST /api/wp-crm/push/{organisationId}/send: to + title + body. Campaigns and automations use the same channel.
  • Who it's for — your developers (one backend call after login, two reference files in the app) and your marketing / operations team (campaigns, one-off sends from a contact).
  • Top outcome — a guest who installed your app gets booking updates and offers as real notifications — image, up to three buttons, a deep link, a live countdown on Android — while every send, delivery and tap is recorded on their contact in Lodgestory — the same contact you message on WhatsApp.

At a glance

Who deliversApple (APNs) and Google (Firebase Cloud Messaging), with your APNs key and your Firebase service account. Apps built with Expo can instead be reached through Expo's push service, with the credentials in your EAS project (see Apps built with Expo).
What you integrateNo SDK. A device contract: the app registers its token with a publishable key, your backend links the device to a guest, and the app renders rich pushes from the payload — reference Swift and Kotlin files are on the settings page.
Who can be reachedPeople who installed your app, allowed notifications, and whose device your backend identified (phone or email). Promotional pushes additionally need the person's in-app opt-in.
AddressingBy phone or email (the same contact Lodgestory uses for WhatsApp), contact id, device id, or your own test devices. Lists and segments are campaigns.
LimitsTitle 120 characters, body 1,000, 3 buttons, 10 data keys, 4 KB per notification (Apple's and Google's ceiling). 600 sends per minute per organisation on the API; campaigns go out through the same push send queue.
AuthenticationYour organisation API token (Bearer) for the backend endpoints; the app's publishable key for the device endpoints; the per-push beacon token for events.

How it works

  1. Connect the app (an Admin, once): Settings → Connections → Push notifications → Add app. Enter the iOS bundle id and the Android application id, upload the APNs key (AuthKey_XXXXXXXXXX.p8 with its Key ID and Team ID) and the Firebase service-account JSON. Lodgestory validates both at once — a wrong key fails here, not at your first campaign. You get a publishable key (pk_…, safe inside the app) and a channel secret (shown once; your backend uses it only if it mints contact tokens itself).
  2. The app registers its device: on every token the OS hands out, PUT /api/push/v1/devices with the publishable key. Anonymous until step 3.
  3. Your backend links the device to the guest after your own login (OTP): either POST …/devices/{deviceId}/identify with the phone or email, or mint a short-lived contact token and let the app send it with its registration. The publishable key alone can never claim a phone number.
  4. Send: POST …/send, campaigns on the push channel, the contact panel's Send push, or the Send push automation action.
  5. The app reports back: it posts delivered (when it rendered the push) and opened / action (a tap) with the beacon token from the payload. These move the push (and that device) to Delivered / Opened in Lodgestory — the message status API, the campaign funnel and the person's Recent pushes in the contact panel. A push is not a chat message: it never appears in the inbox and raises no message.* webhook.

Everything the app does is in the reference code on the settings page; the shapes are below so you can implement them in any language.

Send a push

POST /api/wp-crm/push/{organisationId}/send · Authorization: Bearer <API token>

{
  "to": { "phone": "+919876543210" },
  "title": "Your villa is ready",
  "body": "Check-in from 2 pm. Tap for directions.",
  "url": "yourapp://booking/88",
  "image": "https://cdn.example.com/villa-88.jpg",
  "buttons": [{ "id": "directions", "label": "Directions", "url": "https://maps.app.goo.gl/…" }],
  "kind": "transactional"
}

to is exactly one of:

toReaches
{ "phone": "+919876543210" } / { "phone": "9876543210", "countryCode": "91" }every device identified as the contact with that phone
{ "email": "[email protected]" }the contact with that email (a person with a phone number is matched by phone, never by email)
{ "contactId": "…" }a Lodgestory contact
{ "deviceId": "…" }that one install only (the id the app got when it registered) — the person's other devices do not get it
{ "testDevices": true }the organisation's own test devices (flag them on the settings page): every device of a flagged person, or the flagged install alone when it is not linked to anyone; gates are bypassed

Optional fields: channelId (only if you connected more than one app), kind (transactional default, or promotional), image (https, ≤ 1 MB for Android, 2:1 looks best), url (deep link or https URL opened on tap), data (≤ 10 string values the app receives), buttons (≤ 3 { id, label, url? }), countdownEndAt (ISO 8601 — a live countdown on Android, "Ends 8:00 pm" appended on iOS), collapseKey (a newer push with the same key replaces the older one on the device), ttlSeconds (60 seconds to 4 weeks; default the app's setting — 3 days unless an admin changed it), sound ("default" or null), badge, android.channelId, android.style (rich / plain), ios.subtitle, ios.threadId, ios.interruptionLevel, sendAt (within 30 days), dryRun, name.

Response 202

{
  "id": "7b1e2c30-5f1a-4c7e-9a2d-1b2c3d4e5f60",
  "ids": ["7b1e2c30-5f1a-4c7e-9a2d-1b2c3d4e5f60"],
  "type": "message",
  "status": "queued",
  "recipients": { "matched": 1, "devices": 2, "excluded": { "notOptedIn": 0, "quietHours": 0, "dailyCap": 0, "disabled": 0 } },
  "sendAt": "2026-10-21T10:00:00.000Z",
  "warnings": []
}

Text is sent as given: {{tokens}} are filled in campaigns and automations, not in a one-off send.

status is queued (sends within a few seconds), scheduled (sendAt in the future), dry_run, or no_recipients — the person has no eligible device. That is a state, not an error: recipients.excluded says why (no opt-in, quiet hours, the daily cap, notifications switched off on the device) and warnings spells it out. ids holds one message id per person reached — several only for testDevices; id is the first.

Transactional vs promotional. kind: "transactional" (booking confirmed, check-in ready, payment received) goes to every active device. kind: "promotional" goes only to devices whose person opted in to marketing in the app, outside the channel's quiet hours (default 21:00–09:00 in the device's time zone) and under the promotional daily cap (default 2 per device per 24 hours). Apple's App Store rules require that opt-in and an in-app way to turn it off; the reference code sends it as marketingOptIn. Sent now, a promotional push skips the devices that are in quiet hours or at the cap — recipients.excluded counts them, and nobody left means no_recipients. With sendAt more than a minute ahead, and in campaigns, both are checked when it sends instead (the response says so in warnings): a push that meets quiet hours then is held until they end, in each device's own time zone, and a person already at the cap then is skipped.

Errors come as 400 { "message", "error": { "type": "invalid_request_error", "code", "message", "param" } } (the top-level message repeats error.message): missing_field, invalid_field (param names it — e.g. sendAt more than 30 days ahead, more than 10 data keys), field_too_long, invalid_recipient, invalid_image_url (plain http), invalid_url, too_many_buttons, push_channel_not_configured, channel_required (more than one app — pass channelId), push_channel_disconnected (the app was disconnected, e.g. by a suspension — an admin reconnects it on the settings page); 404 for an unknown contact, device or app; 409 / 422 for an Idempotency-Key still in use / reused with another body (below); 429 past 600 sends a minute (wait for Retry-After); 503 while the server is not set up for push, or when the push queue is briefly unavailable.

Retrying safely — Idempotency-Key. Send a header Idempotency-Key: <a unique string of 1–255 printable ASCII characters, no spaces> (a UUID per push you mean to send; anything else is a 400) and retry with the same key after a timeout or a 5xx: the push goes out once. The same key with the same body answers what the first request got — 200, header Idempotent-Replayed: true, "replayed": true in the body, the same id; the same key with a different body is a 422; a repeat while the first request is still running is a 409 (retry in a few seconds). A request that sent nothing — a dry run, no_recipients, a 400 or 404, a 503 that says nothing was sent — leaves the key unused, so the retry is a fresh attempt. A key is remembered as long as its push is (31 days after it is sent); cancelling a scheduled push forgets it. Without a key, a request repeated after a timeout can send the push twice. A 429, or a 503 that says nothing was sent, is always safe to retry, with or without a key. The one other 503 — the push queue hung, then failed: "the push is saved and goes out once it recovers" — means the push will still go out: retry it only with the same key (that answers with the saved push), never without one.

Follow a push

GET /api/wp-crm/push/{organisationId}/messages/{id}

{
  "id": "…", "type": "message", "status": "sent", "kind": "transactional", "title": "Your villa is ready",
  "sendAt": "…", "sentAt": "…", "lastError": null,
  "counts": { "queued": 0, "sent": 1, "failed": 0, "delivered": 0, "opened": 1, "invalidDevice": 0 },
  "devices": [
    { "deviceId": "…", "platform": "android", "status": "OPENED", "error": null, "sentAt": "…", "deliveredAt": "…", "openedAt": "…", "actionId": "directions" }
  ]
}

Per device: QUEUED → SENT (Apple or Google accepted it — not yet shown) → DELIVERED (the app rendered it) → OPENED (a tap; actionId names the button). FAILED is final for this push; INVALID_DEVICE means the token is dead (the app was uninstalled or the token rotated) and the device was retired — it comes back the moment the app registers again. delivered and opened exist only for apps that post the beacons; without them a push stops at sent.

DELETE …/messages/{id} cancels a push that is still queued or scheduled (409 once it started sending).

Debug while integrating

  • dryRun: true on a send validates the notification, counts who would receive it, and returns preview: the exact JSON each platform would get — android (the FCM v1 message: data-only when the app renders it, notification + data otherwise; androidStyle says which), ios (the APNs request's headers and aps + ls payload) and iosViaFirebase (the FCM message for an iPhone registered through Firebase), with the byte sizes against the 4 KB limit. ls_mid / ls.mid and ls_ack / ls.ack are placeholders in a dry run; on a real send they carry the push message id and a per-device beacon token.
  • A device's delivery log — GET /api/wp-crm/push/{organisationId}/devices/{deviceId}/deliveries?limit=20 — lists the device's recent pushes newest first: the title, kind, Apple's / Google's answer (SENT, FAILED with the error code, INVALID_DEVICE), the delivered / opened beacons the app posted (with actionId for a button) and every timestamp. The same log opens from a device's row on the push app's settings page, and the page's Send a test shows the preview of what the phones received.

For a campaign, use the Campaigns API with the push channel: message is { "push": { "title", "body", "image", "url", "buttons", "kind", … } } with the same fields, and {{tokens}} resolve from segment attributes and contact fields. The campaign funnel counts delivered and opened ("read") per recipient.

Identify devices (your backend)

After your login flow verifies who the guest is, link the device to them. Two ways:

Identify by id — POST /api/wp-crm/push/{organisationId}/devices/{deviceId}/identify

{ "phone": "+919876543210", "name": "Asha Guest", "externalUserId": "u_4421", "marketingOptIn": true }

phone (E.164, or national + countryCode), email or externalUserId — at least one. A person with a phone number is matched by phone only; without one, by email; with only your externalUserId, by the contact that user id was last identified as (remembered even after the user's old phone passes to someone else); nobody matching creates a contact (named from name — first word the first name, the rest the last name — and announced as contact.created with origin: "PUSH"). The device's own registration response gave the app its deviceId.

A device that moves to a different person (another guest signs in on the same phone) keeps nothing of the previous one: their externalUserId and their marketing opt-in come off unless the same call sends new ones. An externalUserId sent with a phone or email is remembered against the contact that call resolved to. When the CRM merges contacts, their devices, user ids and push history move to the contact that is kept; when a contact is deleted, its devices stay registered but anonymous and its user ids are forgotten (the next sign-in with one creates a new contact).

Contact tokens — POST /api/wp-crm/push/{organisationId}/contact-tokens with any of phone, email, externalUserId (and an optional name for a contact it may create; ttlSeconds ≤ 3600, default 900) → { "contactToken": "eyJ…" }. Everything you send is carried in the token. Hand it to the app; the app sends it as contactToken with its device registration and the device is linked in the same call, exactly as an identify with the same fields would link it. Tokens are signed with the channel secret (rotatable on the settings page) and die when it rotates.

Also: POST …/devices/{deviceId}/unlink (logout — the install stays registered, anonymous), GET …/devices?phone=|email=|contactId=|channelId=|status= (newest activity first; never the token), GET …/devices/{deviceId}, DELETE …/devices/{deviceId}, GET …/channels (your apps, readiness per platform, device counts).

The device contract (what the app implements)

Register — PUT /api/push/v1/devices · Authorization: Bearer pk_… (the publishable key)

{
  "platform": "android", "provider": "fcm", "targetKind": "fcm_fid", "target": "<Firebase Installation ID>",
  "bundleId": "com.example.app", "appVersion": "1.0.3", "osVersion": "15", "deviceModel": "V2345",
  "locale": "en-IN", "timezone": "Asia/Kolkata",
  "notificationsEnabled": true, "marketingOptIn": true,
  "consent": { "grantedAt": "2026-10-20T08:00:00Z", "source": "settings_toggle", "textVersion": "v1" },
  "contactToken": "<optional, from your backend>"
}

→ 200 { "deviceId": "…", "status": "ACTIVE", "identified": false, "created": true, "warnings": [] }

Android: firebase-messaging 25.1+ (Firebase BoM 34.15+) registers by Firebase Installation ID — "targetKind": "fcm_fid" with the id onRegistered(installationId) hands over; Google has deprecated getToken / onNewToken. Apps on older versions register their FCM token with "targetKind": "fcm_token".

iOS: "platform": "ios", "provider": "apns", "targetKind": "apns_token", "target": "<the APNs device token, hex>", "apnsEnvironment": "production" | "development". The token's length is Apple's to choose (64 hex characters today). The environment is the provisioning profile's aps-environment — App Store and TestFlight builds are always production, and the reference code reads it from the profile. If a token is refused in its environment and accepted in the other, Lodgestory sends there (when both APNs keys are uploaded) and remembers it. An iOS app that uses Firebase Messaging may register "provider": "fcm" instead; then the APNs key must also be in your Firebase project.

Call it every time the OS hands over the token or installation id — on iOS that is every launch; never cache the token — (the same token re-registers the same device), after login with the contactToken, and whenever notification permission or the marketing opt-in changes (PATCH /api/push/v1/devices/{deviceId} takes the same fields). POST /api/push/v1/devices/{deviceId}/unlink on logout. A wrong bundleId for the iOS app is refused (bundle_id_mismatch): Apple only delivers to the bundle id the key was validated for.

What arrives on Android — a data message when the push is rich (buttons or a countdown) or android.style: "rich"; otherwise a notification message (rendered by Android, with the same data as extras on tap). All values are strings:

key
ls_v"1" — it is a Lodgestory push
ls_midmessage id (the id from the send response)
ls_kindtransactional / promotional
ls_ackthe beacon token for this device — send it back with delivered / opened
title, body, image, url, tag (collapse key), channel_id, sound (default / none), badge
actionsJSON array of { "id", "label", "url" } (≤ 3)
countdown_end_atISO 8601 — show a chronometer counting down to it
dataJSON object — your own values

What arrives on iOS — an alert with mutable-content: 1 (so a Notification Service Extension can run), sound, badge, thread-id (the collapse key), interruption-level, and category: "ls_dyn" when there are buttons; plus a top-level ls object: { "v": 1, "mid", "kind", "ack", "image", "url", "actions", "countdown_end_at", "data" }. Before iOS shows the push, the extension posts the delivered beacon, registers the actions as a category of their own (ls_<hash> per distinct set of buttons, so button titles come from the server and a newer push never relabels an older one) and attaches image (JPEG, PNG or GIF up to 10 MB). Without the extension iOS shows the title and body only. interruption-level: time-sensitive is for transactional pushes only — the API refuses it on promotional ones, as Apple requires — and needs the Time Sensitive Notifications capability in your app. There is no live countdown on iOS: the deadline is appended to the body ("Ends 8:00 pm", in the device's time zone).

Beacons — POST /api/push/v1/events (no key needed; the beacon token is the credential)

{ "events": [ { "ack": "<ls_ack>", "event": "delivered" }, { "ack": "<ls_ack>", "event": "action", "actionId": "directions" } ] }

event is delivered (the app rendered it), opened (a tap on the notification) or action (a button; actionId). Send at (ISO 8601, the moment it happened): the reference code queues beacons and retries them, so they can arrive late. Events are idempotent; the first per message moves the Lodgestory record (the message status API, the campaign funnel, the person's Recent pushes). No webhook fires for it: a push raises no message.* events. Up to 50 events per request, and 1,200 requests a minute per publishable key and IP address.

Apps built with Expo

An app built with Expo can register its Expo push token and be reached through Expo's push service. Expo sends with the Apple and Firebase credentials already in your EAS project, so there is nothing to upload to Lodgestory; an Expo access token is needed only when your Expo project has enhanced push security switched on (Settings → Connections → Push notifications → the app → Expo push). One app in Lodgestory can hold Expo devices and direct (APNs / Firebase) devices side by side — for example older app versions on Expo while newer ones register directly.

Register — the same PUT /api/push/v1/devices, with "provider": "expo", "targetKind": "expo_token", "target": "ExponentPushToken[…]" (from getExpoPushTokenAsync). The reference module lodgestoryPush.ts on the settings page does registration, the Android channels, taps and logout with expo-notifications.

What arrives — Expo's message, rendered by your app's expo-notifications setup: title, subtitle, body, image (Android shows it; an iPhone needs a Notification Service Extension), sound, badge, the Android channel, and a data object carrying ls_ack (the beacon token), url (the deep link) and your own data. No buttons — Expo cannot add buttons per push, so a push with buttons arrives without them (a one-off send says so in warnings). A countdown becomes text in the body ("Ends 8:00 pm"), as on iOS.

Statuses — sent means Expo accepted the push. Expo's receipt, read from 15 minutes to 24 hours later, can still turn it into failed, retire a dead token (DeviceNotRegistered), or report a problem with the credentials in your EAS project (InvalidCredentials, MismatchSenderId, shown on the settings page). An Expo app reports opened from taps; delivered only for pushes that arrive while it is open.

Limits — Expo takes 100 messages a request and 600 a second per project; Lodgestory batches and paces the sends for you.

Delivery rules worth knowing

  • A phone that is offline keeps only some pushes. Apple stores only the most recent notification per app. Google keeps only the latest notification-style (plain) push per app; rich (data) pushes are stored one by one, except that a collapse key replaces the earlier push with the same key (at most four keys per device). So a promotional push sent while a phone is off can replace a booking update that was waiting for it.
  • Priority. Transactional pushes go to iPhones at Apple's priority 10, promotional ones at 5 — Apple may hold those back to save power. Android pushes are high priority; a rich push must show a notification, or Google lowers the app's priority (the reference code always shows one).
  • Google's limits. 240 pushes a minute and 5,000 an hour to one Android device. Your Firebase project's quota (600,000 a minute) is yours alone.
  • What retires a device. Firebase: UNREGISTERED (uninstalled, or inactive for 270 days), a token from another Firebase project, an invalid token. Apple: Unregistered, ExpiredToken, DeviceTokenNotForTopic, and BadDeviceToken once the other environment refused it too. A credential problem — a refused key, a bundle id the key does not serve, a Firebase project that may not send — never retires devices: the push fails and the settings page shows the error. Rate limits and server errors are retried 5 minutes apart, up to 3 attempts in all.
  • What delivered cannot see. A phone with notifications off (iOS does not run the extension; Android shows nothing and the reference code reports nothing), a push that expired or was replaced while the phone was offline, an iOS build without the extension, and a plain push Android showed while your app was in the background — that one reports only when tapped.

Webhooks

Three device events — push.device_registered (a new install), push.device_identified (linked to a person), push.device_invalidated (the token is dead) — with a push_device payload (id, channel_id, contact_id, external_user_id, platform, provider, app_version, os_version, locale, timezone, notifications_enabled, marketing_opt_in, status, invalid_reason, last_seen_at, created_at; never the token). A push itself raises no message.* event (it is not a chat message): follow it with GET …/messages/{id}; a push campaign ends with campaign.completed like any other. See Webhooks.

MCP

The same surface for AI assistants: push_channels_list, push_send (dryRun: true first — no confirmation needed, it returns the payload preview; a real send needs confirm: true; pass idempotencyKey so a retried call never sends twice), push_message_get, push_message_cancel, push_devices_list, push_device_deliveries (a device's delivery log), push_device_identify. See AI assistants (MCP).

What Lodgestory does and does not do

  • Lodgestory stores your Apple key and Firebase service account encrypted, uses them only to send, and never returns them. Rotate them in Apple's or Google's console at any time and upload again.
  • Delivery is Apple's and Google's. sent means they accepted the push; a phone that is off, a user who turned notifications off, or an Android maker's battery optimiser can still swallow it. The beacons are the only ground truth, which is why the reference code sends them.
  • A push cannot be recalled once sent. Cancel only works while it is queued.
  • Promotional content must follow the stores' rules: explicit opt-in (consent wording shown in your app), an in-app way to opt out, nothing sensitive in the payload, never Time Sensitive. The channel enforces opt-in, quiet hours, the daily cap and the interruption level; the wording is yours.

Related pages


Did this page help you?