> ## Documentation Index
> Fetch the complete documentation index at: https://developer.voyado.com/llms.txt
> Use this file to discover all available pages before exploring further.

# App push

Voyado's App Push API enables your mobile app to send native push notifications (APNs on iOS, FCM on Android) to your customer's mobile device through Customer Engagement. It also reports on which devices your customers are using, and lets your app report back on what happened to a push after it was sent, whether it was delivered, opened or dismissed. That engagement can then be used in automations and shown in analytics.

Here you will learn how to integrate with the App Push API. This is intended for:

* **Mobile engineers** (iOS or Android) integrating push notifications into an app
* **Backend engineers** building a server that proxies push registration for a mobile app

## Base path

Each endpoint in the App Push API uses a *base path* URL which is unique to your Customer Engagement environment. The value you'll use can be found on Config Hub.

<Card title="Show me about Config Hub" href="https://help.engage.voyado.com/hc/en-gb/sections/21844508239004-Config-Hub" icon="https://mintcdn.com/voyado/Ns4bBcK3LNctK_Un/icons/help-center-link.png?fit=max&auto=format&n=Ns4bBcK3LNctK_Un&q=85&s=3e7ca2ce0b8cfb9fbd27a4bdf53b2ce1" horizontal width="128" height="128" data-path="icons/help-center-link.png" />

<Warning>
  Your base path URL will be referred to as `<base-path>` from this point on.
</Warning>

## Authentication

Every call to the App Push API requires a Bearer token:

```http Authorization theme={null}
Authorization: Bearer <PublicApiKey>
```

<ResponseField name="PublicApiKey" type="string" required>
  Identifies your app to Customer Engagement. Passed as a Bearer token. You will set this in [Config Hub](https://help.engage.voyado.com/hc/en-gb/sections/21844508239004-Config-Hub) as part of configuring push settings.
</ResponseField>

The `PublicApiKey` is best thought of as an identifier for your app, and not a secret to be protected. It ships inside your compiled app binary, so it can be found by decompiling your IPA/APK. But that is fine - the key is write-only and scoped to registering devices and reporting events for one specific app configuration. It cannot read, modify, or delete any other tenant's or app's data. Treat it how you'd treat any other client-visible app identifier.

<Tip>
  If you'd rather avoid shipping this identifier inside your app at all, see [Backend-mediated integration](#backend-mediated-integration) below.
</Tip>

## The endpoints

There are three endpoints and you use your base path to build the URLs like this:

| Endpoint | POST request URL |
| :- | :- |
| Register | `POST <base-path>/installations` |
| Unregister | `POST <base-path>/installations/unregister` |
| Event | `POST <base-path>/events` |

In each case, insert your own version of the base path.

Here are the endpoints in more detail:

<AccordionGroup>
  <Accordion title="Register endpoint">
    Used to register a device to a certain contact or update its record. The JSON payload contains the attributes:

    * `platform`
    * `push_token`
    * `contact_id`
    * `app_version?`
    * `os_version?`
    * `device_model?`
    * `locale?`
    * `timezone?`

    ```bash Example call theme={null}
    # Register
    curl -X POST <base-path>/installations \
      -H "Authorization: Bearer $PUBLIC_API_KEY" -H "Content-Type: application/json" \
      -d '{"platform":"ios","push_token":"<token>","contact_id":"<contact-id>"}'
    ```
  </Accordion>

  <Accordion title="Unregister endpoint">
    Used to stop a certain device from receiving pushes, for example on logout or opt-out. The JSON payload contains the attributes:

    * `push_token`

    ```bash Example call theme={null}
    # Unregister (logout)
    curl -X POST <base-path>/installations/unregister \
      -H "Authorization: Bearer $PUBLIC_API_KEY" -H "Content-Type: application/json" \
      -d '{"push_token":"<token>"}'
    ```
  </Accordion>

  <Accordion title="Events endpoint">
    Used to report what happened when a push arrived or was interacted with. Sent as an "events" array of JSON objects:

    * `type`
    * `message_id`
    * `contact_id`
    * `recipient_id`
    * `occurred_at`
    * `app_state?`
    * `deeplink?`
    * `action_id?`

    ```bash Example call theme={null}
    # Report an opened notification
    curl -X POST <base-path>/events \
      -H "Authorization: Bearer $PUBLIC_API_KEY" -H "Content-Type: application/json" \
      -d '{"events":[{"type":"opened","message_id":"<msg-id>","contact_id":"<contact-id>","recipient_id":"<recipient-id>","occurred_at":"2026-07-02T10:20:42Z"}]}'
    ```
  </Accordion>
</AccordionGroup>

### Failure modes

| Situation | Response |
| :- | :- |
| Missing or malformed `Authorization` header | 401 unauthorized |
| Key doesn't match the tenant/app config | 401 unauthorized |
| Invalid value of base-path | 400 invalid\_path |

## Core concepts

Here are some core concepts for working with the App Push API.

### 1. An installation is a device

An "installation" is a single app install on a single device. It is identified solely by its push token (the APNs device token or FCM registration token). If the push token changes, the installation should be updated.

### 2. Binding a contact

The `contact_id` is the existing Customer Engagement contact identifier for the signed-in user. Registering an installation with a `contact_id` *binds* that device to that contact. Customer Engagement can then send targeted pushes to it.

### 3. Registration is idempotent

Calling the register endpoint with the same push token twice doesn't create a duplicate, it just updates the existing record (for example by giving a new `contact_id` or refreshed device metadata).

## Integration guidance

This section describes *how* to use the API. These are the patterns every integration should follow.

### 1. Authenticate first

Only call the register endpoint once you know, with certainty, who the current user is — that is, you have a valid `contact_id` from your own app's authentication/session system.

<Warning>
  Voyado does not know or derive your contact identity; your app must supply it. Registering a push token using the wrong (or a stale) `contact_id` means personalized pushes could go to the wrong person, so this ordering matters: confirm identity first, then register.
</Warning>

### 2. Register early

Register the device as early as possible in your app's lifecycle but only when *both* of these conditions are met:

<Steps>
  <Step title="The user is authenticated">
    You have their `contact_id` from your own authentication system.
  </Step>

  <Step title="Notification permission has been granted">
    This condition is implicit: you cannot obtain a push token from the OS at all until the user has granted notification permission, so simply having a token in hand already satisfies this.
  </Step>
</Steps>

In practice this usually means to *request notification permission* at an appropriate point in your onboarding flow, and as soon as you have both a push token and a signed-in user, *call register*. For most apps that's at app launch, if already logged in or immediately after login.

### 3. Minimize redundant calls

Registration is idempotent and safe to call repeatedly, but you should still avoid calling it more than necessary. So you should:

* **Cache** the last successfully registered `(push_token, contact_id)` pair locally on the device
* **Only call register again** when either value changes, such as when the push token rotates, the user logs in as a different contact, or you haven't registered yet at all.
* Don't call register "just in case" on every single app launch if neither value has changed since your last successful call. Doing this will not break anything, but it creates unnecessary network traffic and battery cost.

### 4. Unregister on logout

When the user logs out, call the unregister endpoint along with the device's current push token:

```http theme={null}
POST <base-path>/installations/unregister
```

This is the recommended way to fully remove the installation from Customer Engagement.

If the same device is used to log back in later (by the same or a different contact), your app should call the *register* endpoint again with the current push token and the newly authenticated `contact_id`. As in, a completely fresh registration.

<Tip>
  Also call the unregister endpoint whenever a user explicitly opts out of push notifications in your app's settings, or when you detect that OS-level notification permission has been revoked.
</Tip>

## Endpoints in detail

Here is a more detailed run-down of the three endpoints mentioned above and how they are used.

<Tip>
  Important points:

  * All requests and responses attribute names use JSON with *snake\_case* field names.
  * All endpoints use the same base path and require the `Authorization: Bearer <PublicApiKey>` header
</Tip>

### Register installation

The endpoint is:

```http Register endpoint theme={null}
POST <base-path>/installations
```

This creates or updates an installation. This call is idempotent, which means it is safe to call every time your app has a current push token and a signed-in contact, without checking whether the device is already registered.

<AccordionGroup>
  <Accordion title="Request body fields">
    The following attributes can be used:

    <ResponseField name="platform" type="string" required>
      This will be "ios" or "android".
    </ResponseField>

    <ResponseField name="push_token" type="string" required>
      The APNs device token or FCM registration token. This is the installation's identity.
    </ResponseField>

    <ResponseField name="contact_id" type="string" required>
      Your Voyado contact identifier for the signed-in user.
    </ResponseField>

    <ResponseField name="app_version" type="string">
      Your app's version string. (See note below.)
    </ResponseField>

    <ResponseField name="os_version" type="string">
      Device OS version. (See note below.)
    </ResponseField>

    <ResponseField name="device_model" type="string">
      Device model identifier. (See note below.)
    </ResponseField>

    <ResponseField name="locale" type="string">
      Device locale, for example `en-US`. (See note below.)
    </ResponseField>

    <ResponseField name="timezone" type="string">
      IANA timezone, for example `Europe/Stockholm`. (See note below.)
    </ResponseField>

    <Tip>
      `app_version`, `os_version`, `device_model`, `locale`, and `timezone` are metadata that Voyado does not currently use for any targeting, filtering, or business logic. It is recommended to send them anyway as they are likely to power future capabilities (such as minimum-OS-version rules or locale-aware send scheduling), and it costs you nothing to include them now rather than adding them retroactively later.
    </Tip>
  </Accordion>

  <Accordion title="Example request">
    This is an example request for tenant "acme" (using a likely value for `<host>`):

    ```Http Request details theme={null}
    POST https://app-push-prod.voyado.com/api/v1/acme/abc123/default/installations
    Authorization: Bearer <PublicApiKey>
    Content-Type: application/json
    ```

    The payload:

    ```json Registering an installation theme={null}
    {
      "platform": "ios",
      "push_token": "a1b2c3d4e5f6...",
      "contact_id": "496977ad-1d09-4993-9957-ef019eaa1eb3",
      "app_version": "3.4.1",
      "os_version": "26.4.4",
      "device_model": "iPhone15,2",
      "locale": "sv-SE",
      "timezone": "Europe/Stockholm"
    }
    ```

    A successful request gives a *HTTP 202 Accepted* response with an empty body.
  </Accordion>

  <Accordion title="Errors">
    | Status | Code | When |
    | :- | :- | :- |
    | `400` | `invalid_json` | Malformed JSON body. |
    | `400` | `invalid_request` | Missing `push_token` or `platform`. |
    | `400` | `invalid_platform` | `platform` is not "ios" or "android". |
    | `401` | `unauthorized` | Missing/invalid Bearer token. |
  </Accordion>
</AccordionGroup>

### Unregister installation

The endpoint is:

```http Register endpoint theme={null}
POST <base-path>/installations/unregister
```

This entirely removes the installation identified by the push token sent. Use this on logout and when the user explicit opts out.

<Warning>
  The push token is sent *in the request body*, not in the URL. A push token is a sensitive value and should never appear in a URL path, since URLs are logged by proxies, browsers, and monitoring tools far more readily than request bodies.
</Warning>

<AccordionGroup>
  <Accordion title="Request body fields">
    <ResponseField name="push_token" type="string" required>
      The push token identifying the installation that is to be removed.
    </ResponseField>
  </Accordion>

  <Accordion title="Example request">
    ```http Request details theme={null}
    POST https://app-push-prod.voyado.com/api/v1/acme/abc123/default/installations/unregister
    Authorization: Bearer <PublicApiKey>
    Content-Type: application/json
    ```

    The payload:

    ```json Registering an installation theme={null}
    { "push_token": "a1b2c3d4e5f6..." }
    ```

    A successful request gives a *HTTP 202 Accepted* response with an empty body.
  </Accordion>

  <Accordion title="Errors">
    | Status | Code | When |
    | :- | :- | :- |
    | `400` | `invalid_json` | Malformed JSON body. |
    | `400` | `invalid_request` | Missing `push_token`. |
    | `401` | `unauthorized` | Missing/invalid Bearer token. |
  </Accordion>
</AccordionGroup>

### Report events

An engagement events describes what happened to a push after it was sent. The endpoint to send one or a batch of events:

```http Report events endpoint theme={null}
POST <base-path>/events
```

<Warning>
  Events are validated as a *whole batch* before any of them are accepted. If any event in the batch is invalid, the whole batch is rejected.
</Warning>

<Accordion title="Request body">
  ```json Events request body theme={null}
  {
    "events": [
      { /* event */ },
      { /* event */ }
    ]
  }
  ```
</Accordion>

<Accordion title="Event fields">
  <ResponseField name="type" type="string" required>
    One of `delivered` or `opened` or `dismissed` - See "Event types" below for more details.
  </ResponseField>

  <ResponseField name="message_id" type="string" required>
    Echoed back from the push payload your app received.
  </ResponseField>

  <ResponseField name="recipient_id" type="string" required>
    Echoed back from the push payload your app received.
  </ResponseField>

  <ResponseField name="occurred_at" type="datetime" required>
    When the event happened, from the device's clock. ISO-8601 format.
  </ResponseField>

  <ResponseField name="contact_id" type="string" required>
    The Voyado contact this event relates to.
  </ResponseField>

  <ResponseField name="app_state" type="string">
    This will be "foreground", "background", or "killed", which is the app's state when the event occurred.
  </ResponseField>

  <ResponseField name="deeplink" type="string">
    Echoed from the push payload, if the notification carried one and the user tapped it.
  </ResponseField>

  <ResponseField name="action_id" type="string">
    The identifier of a custom notification action button the user tapped, if the notification included custom actions and tapping one opened the app. Reported alongside `opened`.
  </ResponseField>
</Accordion>

<Accordion title="Event types">
  | Type | Fire when: |
  | :- | :- |
  | `delivered` | The push arrived on the device (before the user does anything with it). |
  | `opened` | The user tapped the notification (or a custom action button that opens the app). |
  | `dismissed` | The user dismissed the notification without opening it (Android only — iOS has no reliable equivalent). |

  Every push Voyado sends includes both a `message_id` and `recipient_id` in its payload specifically so your app can echo it back on every event. This is how Voyado correlates "delivered" and "opened" events with the original send. Consult your platform's push payload documentation for exactly where this value appears in the payload your app receives.
</Accordion>

<Accordion title="Example request">
  ```http Reporting a batch of events theme={null}
  POST https://app-push-prod.voyado.com/api/v1/acme/abc123/default/events
  Authorization: Bearer <PublicApiKey>
  Content-Type: application/json
  ```

  The payload:

  ```json The events theme={null}
  {
    "events": [
      {
        "type": "delivered",
        "message_id": "msg-abc123",
        "contact_id": "496977ad-1d09-4993-9957-ef019eaa1eb3",
        "recipient_id": "572c44be-945d-879-41a2-77826b6bb21b",
        "occurred_at": "2026-07-02T10:20:15.123Z",
        "app_state": "background"
      },
      {
        "type": "opened",
        "message_id": "msg-abc123",
        "contact_id": "496977ad-1d09-4993-9957-ef019eaa1eb3",
        "recipient_id": "572c44be-945d-879-41a2-77826b6bb21b",
        "occurred_at": "2026-07-02T10:20:42.456Z",
        "deeplink": "myapp://product/sku-123"
      }
    ]
  }
  ```

  A successful request gives a *HTTP 202 Accepted* response:

  ```json theme={null}
  { "accepted": 2 }
  ```
</Accordion>

<Accordion title="Errors">
  | Status | Code | When |
  | :- | :- | :- |
  | `400` | `invalid_json` | Malformed JSON body. |
  | `400` | `invalid_request` | Empty or missing `events` array. |
  | `400` | `invalid_event` | An event is `null`, or missing `message_id` / `occurred_at` / `contact_id`. |
  | `400` | `invalid_event_type` | `type` is not one of the event types above. |
  | `401` | `unauthorized` | Missing/invalid Bearer token. |
</Accordion>

<Accordion title="Error response shape">
  Every error from every endpoint uses the same envelope:

  ```json Error response theme={null}
  {
    "error": {
      "code": "invalid_request",
      "message": "platform and push_token are required",
      "request_id": "0f1e2d3c-..."
    }
  }
  ```

  The `request_id` value uniquely identifies the request server-side; include it when contacting support about a specific failed call.
</Accordion>

<Warning>
  What the delivered event is designed to mean is that your app push has “reached" the user, and that it is now possible for them to see it (think of an email reaching an inbox). However the notification permissions on a user’s device can vary wildly. For example, a user may allow pushes but choose to show them not as a banner but as a red notification badge on the app icon. Therefore it is hard to give exact guidance on when you should send the “delivered” event and it is up to you to implement it in a way that works for you.
</Warning>

## Sending a push

Here are example payloads for sending a push on Android and on iOS.

<AccordionGroup>
  <Accordion title="Push payload example - Android">
    ```json theme={null}
    {
      "voyado_push_service_message_id": "3f2c9b1e-8a4d-4e77-9c2a-1d7b6f0a5e3c",
      "voyado_push_service_contact_id": "322c4bbe-9ccd-3e79-91aa-17826b6bb21a",
      "voyado_push_service_recipient_id": "572c44be-945d-879-41a2-77826b6bb21b",
      "body": "20% off everything this weekend only!",
      "title": "Weekend sale",
      "voyado_push_service_deeplink": "https://shop.example.com/campaigns/weekend-sale",
      "voyado_push_service_image_url": "https://cdn.example.com/campaigns/weekend-sale.png"
    }
    ```
  </Accordion>

  <Accordion title="Push payload example - iOS">
    ```json theme={null}
    {
      "aps": {
        "alert": {
          "title": "Weekend sale",
          "body": "20% off everything this weekend only!"
        },
        "sound": "default",
        "mutable-content": 1
      },
      "voyado_push_service": {
        "message_id": "3f2c9b1e-8a4d-4e77-9c2a-1d7b6f0a5e3c",
        "contact_id": "322c4bbe-9ccd-3e79-91aa-17826b6bb21a",
        "recipient_id": "572c44be-945d-879-41a2-77826b6bb21b",
        "deeplink": "https://shop.example.com/campaigns/weekend-sale",
        "image_url": "https://cdn.example.com/campaigns/weekend-sale.png"

      }
    }
    ```
  </Accordion>
</AccordionGroup>

## Migrating from another provider

If you're currently sending push notifications through a different push provider and want to move to Voyado, it's recommended to follow a *gradual, dual-provider transition* rather than a sudden total cut-over.

<Warning>
  This works best when your app's implementation can actually support running two providers side by side. So check that first. Some existing push SDKs register their own OS-level callbacks (such as `UNUserNotificationCenterDelegate` on iOS or `FirebaseMessagingService` on Android) and don't forward those callbacks anywhere your own code can intercept them, which can make it difficult or impossible to also wire up Voyado registration and event reporting alongside them.

  Only your app's developers can assess whether your current setup allows for this. If it doesn't, talk to your Voyado contact about alternative migration approaches.
</Warning>

<Steps>
  <Step title="Integrate both providers side by side">
    Add Voyado registration (this API) to your app alongside your existing provider's SDK. Your app now registers with *both* providers whenever a user authenticates.
  </Step>

  <Step title="Let registrations accumulate naturally">
    As your existing users open the app over the following days and weeks, each one registers with Voyado automatically — no forced re-login, no push notification required to trigger it, no big migration event. Coverage grows organically with normal app usage.
  </Step>

  <Step title="Decide when to cut over">
    How many registered contacts is "enough" to switch is entirely a business decision on your side — Voyado doesn't enforce or suggest a threshold. Once you're satisfied with coverage, you cut over.
  </Step>

  <Step title="Changing where you trigger sends from">
    Registering a device with Voyado never causes a push to be sent, this must still be explicitly done. This means that running both providers' registration side by side during the transition does *not* risk duplicate pushes on its own.
  </Step>

  <Step title="Remove the legacy SDK (optional)">
    Do this when you're fully cut over and confident that you no longer need the legacy SDK.
  </Step>
</Steps>

<Warning>
  You should not send pushes from both the old provider and from Voyado at the same time during the transition. Doing this creates a risk of sending the push twice.
</Warning>

## Backend-mediated integration

Not every customer wants their mobile app talking to Voyado directly. If you'd rather route push registration and event reporting through your own backend, that's fully supported.

In this case your backend just calls the same API as discussed above on your app's behalf.

```mermaid theme={null}
flowchart LR
    A["📱 Mobile app"] -->|"registers device"| B["⚙️ Your backend"]
    B -->|"App Push API"| C["🔔 Voyado App Push API"]
 
    subgraph yours ["You build this"]
        A
        B
    end
 
    subgraph voyado ["Voyado provides this"]
        C
    end
 
    style A fill:#f0f4ff,stroke:#4a6cf7,stroke-width:2px
    style B fill:#f0f4ff,stroke:#4a6cf7,stroke-width:2px
    style C fill:#fff4ed,stroke:#f77f4a,stroke-width:2px
```

Important points to consider are:

* Your backend calls **the exact same Voyado endpoints** (register, unregister, events) as those described above, with the exact same fields.

* **Your backend can supply `contact_id` itself** when calling Voyado, since it already knows which of your own users is making the request (via your existing session/auth system),  Your mobile app only needs to hand its push token to your backend, and not to Voyado directly.

* The key **never ships inside your distributed app binary** where it could be extractable by unpacking the IPA/APK. This is a meaningful security advantage. If keeping the key off-device matters to you, this pattern is worth the extra implementation effort.

<Tip>
  Everything else in this guide — when to register, when to unregister, the migration approach — applies in the backend-mediated case. It's only *who* makes the HTTP call to Voyado that changes.
</Tip>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.