unPLUGDevelopers

Push and in-app messages

Connect your app to unPLUG so its campaigns reach your guests as push notifications and full-screen in-app messages. Pushes go out from your own Firebase project and Apple key, so they show your app's name and icon.

Available at launch Needs a one-time setup with unPLUG support for each app.

What unPLUG sends#

  • Visible pushes. Title, body, an optional image and up to two buttons. No sound, ever.
  • Silent pushes. Nothing shows. They tell your app that a full-screen message is waiting.
  • Full-screen in-app messages. An image with optional title, body, buttons and an offer code. Your app fetches them from the API and draws them.

Your app does five things: register its device, render the pushes, fetch and show in-app messages, report what the guest did, and route taps to the right screen.

How a message reaches the guest#

For a full-screen message, unPLUG tries the quiet route first and falls back to a visible push.

  1. Silent push. unPLUG sends a silent push with silent: "1" and inapp: "1" to every device the member has registered.
  2. Your app fetches the message. On the silent push, or the next time the guest opens the app, you call GET /v1/members/me/messages/next and show it.
  3. Fallback push. If the message isn't shown within the brand's fallback window (5 minutes by default), unPLUG sends one visible push with the same message_id and inapp: "1".
  4. Tap. When the guest taps it, you fetch that exact message with GET /v1/members/me/messages/{id} and show it.

The whole flow counts as one message toward the guest's frequency cap. Quiet hours and frequency caps apply to the visible push only, since the silent push shows nothing. iOS and Android both drop or delay some silent pushes; the fallback is there so the message still arrives.

Set up with support#

Push credentials are set up once per app by unPLUG support. Do it in the sandbox first.

  1. Collect your credentials.
    • Android, and iOS if your iOS app uses the Firebase SDK: a Firebase service account JSON key with the Firebase Cloud Messaging API (V1) turned on. Create it in the Firebase console under Project settings, Service accounts.
    • iOS with raw APNs tokens: an APNs auth key (.p8 file) from your Apple Developer account, its Key ID (for example 7KQ2M9XB4D) and your Team ID (for example 4HJ8W2N6PZ).
  2. Email support for an upload link. Write to support@unplugdining.com with your brand name and environment. Support sends a secure upload link. Upload the files there, never as an email attachment.
  3. Send your app details in the same email. iOS bundle id (com.example.app), Android package name (com.example.app), and which APNs environment each build uses: development for builds run from Xcode, production for TestFlight and the App Store.
  4. Send your links. Your deep link scheme (yourapp://) and your universal link or app link domain (order.example.com).
  5. Wait for the confirmation. Support tells you when your app is connected. Then register a test device and ask for a test send.

unPLUG stores the keys in a secrets vault. Nobody can read them back, including unPLUG staff. To rotate a key, upload the new one the same way and tell support when to switch. Uploading credentials yourself from the developer dashboard is Coming soon.

Warning A Firebase service account key or APNs key lets anyone who holds it push to all your guests. Don't email it, commit it or paste it into a ticket. If one leaks, revoke it in Firebase or Apple first, then send support the new one.

Register the device#

Tell unPLUG which devices belong to the signed-in member, with their push token and permission. This is the only way unPLUG learns where to send.

PUTRequest
curl -X PUT https://api.unplugdining.com/v1/members/me/devices/9c7e2f14-58ab-4d3e-b1a0-6f2d8e47c935 \
  -H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "ios",
    "push_token": "7f3a9c2e1b8d4f6a0c5e9b2d7f1a3c8e4b6d0f2a9c7e5b3d1f8a6c4e2b0d9f7a",
    "token_type": "apns",
    "app_id": "com.example.app",
    "push_enabled": true,
    "app_version": "4.2.0",
    "os_version": "18.1",
    "locale": "en-US",
    "time_zone": "America/Chicago"
  }'
Response200
{
  "device_id": "9c7e2f14-58ab-4d3e-b1a0-6f2d8e47c935",
  "member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
  "platform": "ios",
  "push_token": "7f3a9c2e1b8d4f6a0c5e9b2d7f1a3c8e4b6d0f2a9c7e5b3d1f8a6c4e2b0d9f7a",
  "token_type": "apns",
  "app_id": "com.example.app",
  "push_enabled": true,
  "app_version": "4.2.0",
  "os_version": "18.1",
  "locale": "en-US",
  "time_zone": "America/Chicago",
  "created_at": "2026-10-12T15:04:51Z",
  "updated_at": "2026-10-30T14:58:20Z"
}
  • device_id is your own id for this install. Generate a UUID the first time the app runs and keep it in app storage. Don't use the push token: it changes.
  • token_type is fcm if the token comes from the Firebase SDK (always on Android, and on iOS if you use Firebase), or apns for a raw APNs device token on iOS.
  • push_enabled is the OS notification permission right now.
  • app_id is the bundle id or package name you gave support.

Call it on every app start once the member is signed in, and again whenever the push token changes, the guest changes the notification permission in Settings, or a different member signs in. It's a PUT: sending the same device again is safe. A member can have many devices.

On sign out, delete the device before you throw the member token away, so the next person on the phone doesn't get Maya's messages:

DELETERequest
curl -X DELETE https://api.unplugdining.com/v1/members/me/devices/9c7e2f14-58ab-4d3e-b1a0-6f2d8e47c935 \
  -H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"

It returns 204. If the member token has expired, get a new one from your server first. Keep the same device_id after sign out; the next member who signs in registers it again with their own token.

Set up the app#

Register the button categories#

unPLUG pushes use two notification categories, UNPLUG_ONE_BUTTON and UNPLUG_TWO_BUTTONS, with action ids primary and secondary. Register them at launch. The titles here are placeholders; the Notification Service Extension replaces them with each push's labels.

AppDelegate.swift
import UserNotifications

func registerUnplugCategories() {
    let primary = UNNotificationAction(identifier: "primary", title: "Open", options: [.foreground])
    let secondary = UNNotificationAction(identifier: "secondary", title: "Not now", options: [])
    let one = UNNotificationCategory(identifier: "UNPLUG_ONE_BUTTON",
                                     actions: [primary], intentIdentifiers: [])
    let two = UNNotificationCategory(identifier: "UNPLUG_TWO_BUTTONS",
                                     actions: [primary, secondary], intentIdentifiers: [])
    UNUserNotificationCenter.current().setNotificationCategories([one, two])
}

Add a Notification Service Extension#

Pushes arrive with mutable-content: 1. Add a Notification Service Extension target to download image_url and set the button titles from buttons. Without it, pushes show text only and the buttons show your placeholder titles.

NotificationService.swift
import UserNotifications

struct PushButton: Decodable {
    let id: String       // "primary" or "secondary"
    let label: String
    let action: String   // "open_screen", "open_url" or "close"
    let url: String?
}

final class NotificationService: UNNotificationServiceExtension {
    private var contentHandler: ((UNNotificationContent) -> Void)?
    private var content: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
            return contentHandler(request.content)
        }
        self.contentHandler = contentHandler
        self.content = content
        let info = content.userInfo
        let group = DispatchGroup()

        // Button titles: re-register the category with this push's labels.
        let buttons = Array(Self.buttons(from: info["buttons"]).prefix(2))
        if !buttons.isEmpty {
            let actions = buttons.map {
                UNNotificationAction(identifier: $0.id, title: $0.label,
                                     options: $0.action == "close" ? [] : [.foreground])
            }
            let id = actions.count == 2 ? "UNPLUG_TWO_BUTTONS" : "UNPLUG_ONE_BUTTON"
            let category = UNNotificationCategory(identifier: id, actions: actions, intentIdentifiers: [])
            content.categoryIdentifier = id
            group.enter()
            let center = UNUserNotificationCenter.current()
            center.getNotificationCategories { existing in
                center.setNotificationCategories(existing.filter { $0.identifier != id }.union([category]))
                group.leave()
            }
        }

        // Image: download it and attach it.
        if let link = info["image_url"] as? String, let url = URL(string: link) {
            group.enter()
            URLSession.shared.downloadTask(with: url) { tmp, _, _ in
                defer { group.leave() }
                guard let tmp = tmp else { return }
                let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
                let file = FileManager.default.temporaryDirectory
                    .appendingPathComponent(UUID().uuidString).appendingPathExtension(ext)
                try? FileManager.default.moveItem(at: tmp, to: file)
                if let image = try? UNNotificationAttachment(identifier: "image", url: file) {
                    content.attachments = [image]
                }
            }.resume()
        }

        group.notify(queue: .main) { contentHandler(content) }
    }

    override func serviceExtensionTimeWillExpire() {
        if let handler = contentHandler, let content = content { handler(content) }
    }

    // buttons arrives as a JSON array from APNs, or as a JSON string through FCM.
    private static func buttons(from raw: Any?) -> [PushButton] {
        var data: Data?
        if let text = raw as? String { data = text.data(using: .utf8) }
        else if let list = raw { data = try? JSONSerialization.data(withJSONObject: list) }
        guard let data = data else { return [] }
        return (try? JSONDecoder().decode([PushButton].self, from: data)) ?? []
    }
}

Give the extension its own bundle id under your app's (com.example.app.NotificationService). It doesn't need to be registered with support.

Handle silent pushes#

Turn on the Remote notifications background mode. In application(_:didReceiveRemoteNotification:fetchCompletionHandler:), when silent and inapp are both "1", call GET /v1/members/me/messages/next. Show the message right away if the app is in the foreground and the guest isn't in checkout; otherwise keep it and show it when they next open the app.

Push payload#

These are the keys your app reads. On iOS, title, subtitle and body are in aps.alert and the rest are top-level keys. On Android they're all in the data map, as strings.

KeyPlatformWhat it is
message_idBothThe message. Send it back on push opens, and use it to fetch the in-app message.
campaign_idBothThe unPLUG campaign it came from. Useful in your own analytics.
titleBothNotification title.
subtitleiOSLine under the title.
bodyBothNotification text.
image_urlBothLarge image. HTTPS.
thumbnail_urlAndroidSmall image for the collapsed notification.
deep_linkBothWhere a tap on the notification goes: your scheme (yourapp://menu/fall) or your https domain.
buttonsBothUp to 2 buttons, each { id, label, action, url }. id is primary or secondary; action is open_screen, open_url or close.
inappBoth"1": on a tap, open the in-app message for message_id instead of deep_link. On a silent push, fetch the next message.
silentBoth"1": show nothing.
channel_idAndroidmarketing or transactional.

Pushes never play a sound. A payload is at most 4 KB. A push expires after at most 28 days, so a phone that's been off for a month doesn't wake up to old offers.

iOS payload
{
  "aps": {
    "alert": {
      "title": "A pastry on us",
      "subtitle": "South Congress",
      "body": "Pick any pastry with your next order. It's in your wallet until November 12."
    },
    "mutable-content": 1,
    "category": "UNPLUG_TWO_BUTTONS"
  },
  "message_id": "5b81d3e0-2c4f-4a96-8e17-d0f9a3b62c58",
  "campaign_id": "e4a09b27-7d13-4c5e-9f82-31b6c0d8a4f1",
  "image_url": "https://cdn.unplugdining.com/demo/campaigns/fall-pastry.jpg",
  "deep_link": "yourapp://wallet/7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28",
  "buttons": [
    { "id": "primary", "label": "See my wallet", "action": "open_screen", "url": "yourapp://wallet/7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28" },
    { "id": "secondary", "label": "Not now", "action": "close", "url": null }
  ]
}

Handle a push tap#

  1. Report the open. On any tap, on the notification or a button, call POST /v1/members/me/push_opens. Include button when a button was tapped.
  2. If inapp is "1", fetch GET /v1/members/me/messages/{message_id} and show that message. Don't follow deep_link.
  3. Otherwise route. A button goes to its url (or closes, for close). A tap on the notification goes to deep_link.
POSTRequest
curl -X POST https://api.unplugdining.com/v1/members/me/push_opens \
  -H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS" \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "5b81d3e0-2c4f-4a96-8e17-d0f9a3b62c58",
    "opened_at": "2026-10-30T15:06:12Z",
    "button": "primary"
  }'

It returns 204. Send opened_at as the time of the tap, not the time of the request: a tap on a cold start may reach you after the app has loaded and fetched a member token. If the guest is signed out when they tap, route the tap and skip the report.

Full-screen in-app messages#

When to fetch#

Call GET /v1/members/me/messages/next each time the app comes to the foreground, and when a silent push with inapp: "1" arrives. It returns 200 with a message, or 204 when there's nothing to show. A member sees at most one full-screen message a day; the API enforces this, so you don't need to count.

Never show a message during checkout. Each message says so in display.never_during: ["checkout"]. If the app opens straight into checkout (for example from a cart reminder), wait until the guest leaves checkout, then fetch.

GETRequest
curl https://api.unplugdining.com/v1/members/me/messages/next \
  -H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"
Response200
{
  "id": "5b81d3e0-2c4f-4a96-8e17-d0f9a3b62c58",
  "layout": "image_and_text",
  "image_url": "https://cdn.unplugdining.com/demo/campaigns/fall-pastry.jpg",
  "title": "A pastry on us",
  "body": "Croissant or muffin, your pick. Add one to your next order and it's free. Good until November 12.",
  "primary": { "label": "See my wallet", "action": "open_screen", "url": "yourapp://wallet/7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28" },
  "secondary": { "label": "Not now", "action": "close", "url": null },
  "offer": { "code": null, "wallet_item_id": "7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28", "text": "Fall pastry on us, until November 12." },
  "display": { "never_during": ["checkout"] },
  "created_at": "2026-10-30T15:00:00Z",
  "expires_at": "2026-11-13T05:59:59Z"
}

Layouts#

LayoutHow to draw it
full_imageThe image fills the screen and carries the words. Draw the buttons over the bottom of it. Use title as the image's accessibility label.
image_and_textThe image on top, then title, body, the offer if there is one, and the buttons.

Always draw your own close button in the top corner, whatever the message says. secondary and offer can be null. Don't show a message after its expires_at.

Buttons and offers#

  • open_screen: open url, a link in your scheme or on your link domain, inside the app.
  • open_url: open url, always https, in the browser or an in-app browser.
  • close: dismiss the message.

When offer.code is set, show the code with a copy button and prefill it on checkout if you can. When offer.wallet_item_id is set, the offer is already in the member's wallet; link to it. See Offers and promo codes.

Report what the guest did#

Post an impression when the message is on screen, a button_tap with the button when one is tapped, and a close when it's dismissed any other way. Each is recorded once per message, so retries are safe.

POSTRequest
curl -X POST https://api.unplugdining.com/v1/members/me/messages/5b81d3e0-2c4f-4a96-8e17-d0f9a3b62c58/events \
  -H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS" \
  -H "Content-Type: application/json" \
  -d '{ "type": "button_tap", "button": "primary" }'

It returns 204. The impression is what stops the fallback push, so send it as soon as the message is visible, before any image finishes loading.

Two separate things decide whether a guest gets marketing pushes:

  • OS permission. Whether the phone lets your app show notifications. You send it as push_enabled on the device.
  • Marketing preference. Whether the guest agreed to marketing pushes from your brand: communication_preferences.push_marketing on the member.

unPLUG sends marketing pushes only when both are on. Put an "Offers and news" switch in your app's settings and save it like this:

PATCHRequest
curl -X PATCH https://api.unplugdining.com/v1/members/me/communication_preferences \
  -H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS" \
  -H "Content-Type: application/json" \
  -d '{ "push_marketing": false }'

The change is stamped with source: "app" and fires communication_preferences.updated. See Members for the full preferences object.

Web#

Web push isn't part of v1. unPLUG reaches guests who only use your website by email.

Test in the sandbox#

  1. Connect your sandbox app. Go through the setup above with support for the sandbox environment. Debug builds usually get development APNs tokens.
  2. Register your test device. Sign in with a sandbox member and call PUT /v1/members/me/devices/{device_id} on https://sandbox.api.unplugdining.com/v1. Sandbox sends only reach devices registered in the sandbox, so real guests can't get a test message.
  3. Ask support for a test send. Support or your unPLUG contact sends a push and a full-screen message to your sandbox brand.
  4. Try the hard cases. The app closed, in the background and in checkout. A cold start from a push tap. A message with two buttons and one with an offer. Sign out, then check the device no longer gets pushes.

Before you go live#

  • Production credentials are uploaded and support has confirmed your production bundle id and package name.
  • The device is registered on every app start and on token, permission and member changes, and deleted on sign out.
  • iOS: both categories are registered and the Notification Service Extension ships in the release build.
  • Android: both channels exist and data messages render with image and buttons.
  • Taps report push_opens and route correctly from a cold start, including inapp: "1" pushes.
  • In-app messages never show during checkout, always have your close button, and report impression, button_tap and close.
  • Your settings screen has a marketing push switch that saves push_marketing.