VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Guides
Launch
Go-live checklist
Make your first App Store purchase
Make your first Google Play purchase
Troubleshoot
Troubleshooting
Recover from incidents
SDK error codes
Purchase error codes
API error codes
FAQ
Recipes
Win back subscribers
Handle plan changes
Unlock the app after a web purchase
Link accounts across platforms
Handle refunds
Register devices for push
Migrate
Migrate to Voidhash
Migrate from RevenueCat
Migrate from Adapty
Migrate from Superwall
Run in observer mode
Privacy and hosting
Delete personal data
Self-hosting
Operate a self-hosted server

Register devices for push

Send each device's push token to Voidhash from your app, and keep it current through sign-in and sign-out.

Voidhash sends a push notification only to devices your app registered for the person. The SDKs don't register push tokens: your app gets the token from the platform and sends it to the client API, with the person's distinct ID from the SDK.

What you need

  • Push notifications turned on for your team (they are in early access) and APNs or FCM set up under Settings → Notifications. See Connect a push service.
  • The Voidhash SDK configured in your app, and the publishable key it uses.
  • On iOS, the Push Notifications capability in Xcode. On Android, Firebase Cloud Messaging added to the app.

How registration works

Registering links one push token to the person the x-distinct-id header names, and returns a pushDeviceTokenId that the app keeps. A device belongs to the person who registered it last, so:

  • Register on every launch. Registering the same token again returns the same ID. When the token has changed, pass the stored ID as previousPushDeviceTokenId and Voidhash drops the old one.
  • Register again after identify, so the device follows the signed-in person.
  • Unregister before the SDK's signOut(). Unregistering only matches the person who registered the device; after signOut() the SDK sends a new anonymous distinct ID, and the call fails with 404.

Add the registration code

The samples send the same headers the SDK sends; the header reference explains each one. They target Voidhash Cloud; a self-hosted install uses its own API origin.

PushRegistration.swift
import UIKit
import UserNotifications
import Voidhash

/// Keeps this device's APNs token registered with Voidhash for the current person.
@MainActor
final class PushRegistration {
    static let shared = PushRegistration()

    private let endpoint = URL(string: "https://api.voidhash.com/api/v1/sdk/push-devices")!
    private let idKey = "voidhashPushDeviceTokenId"
    private var deviceToken: String?

    /// Asks for permission once; later launches get the token without a prompt.
    func enable() async {
        let center = UNUserNotificationCenter.current()
        let granted = (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
        if granted { UIApplication.shared.registerForRemoteNotifications() }
    }

    /// Called from the app delegate with the token APNs issued.
    func didReceive(deviceToken data: Data) async {
        deviceToken = data.map { String(format: "%02x", $0) }.joined()
        await register()
    }

    /// Links the device to the current distinct ID. Call again after `identify`.
    func register() async {
        guard let deviceToken else { return }
        #if DEBUG
        let environment = "sandbox"
        #else
        let environment = "production"
        #endif
        var body: [String: String] = [
            "platform": "ios",
            "provider": "apns",
            "platformToken": deviceToken,
            "bundleId": Bundle.main.bundleIdentifier ?? "",
            "environment": environment,
        ]
        body["previousPushDeviceTokenId"] = UserDefaults.standard.string(forKey: idKey)
        guard let data = try? await post("register", body),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let id = json["pushDeviceTokenId"] as? String
        else { return } // Retried on the next launch.
        UserDefaults.standard.set(id, forKey: idKey)
    }

    /// Stops pushes to this device for the current person. Call before `signOut()`.
    func unregister() async {
        guard let id = UserDefaults.standard.string(forKey: idKey) else { return }
        _ = try? await post("unregister", ["pushDeviceTokenId": id])
        UserDefaults.standard.removeObject(forKey: idKey)
    }

    private func post(_ action: String, _ body: [String: String]) async throws -> Data {
        guard let voidhash = Voidhash.shared else { throw URLError(.cancelled) }
        #if DEBUG
        let isDebugBuild = "true"
        #else
        let isDebugBuild = "false"
        #endif
        var request = URLRequest(url: endpoint.appendingPathComponent(action))
        request.httpMethod = "POST"
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
        let headers = [
            "content-type": "application/json",
            "x-publishable-key": isDebugBuild == "true" ? "vh_pk_test_..." : "vh_pk_...",
            "x-distinct-id": await voidhash.getDistinctId(),
            "x-client-bundle-id": Bundle.main.bundleIdentifier ?? "",
            "x-platform": "ios",
            "x-platform-flavor": "native",
            "x-sdk": "ios",
            "x-sdk-version": Voidhash.sdkVersion,
            "x-is-debug-build": isDebugBuild,
            "x-is-backgrounded": UIApplication.shared.applicationState == .background ? "true" : "false",
            "x-observer-mode": "false", // "true" if you configure the SDK with readOnly
        ]
        for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
        let (data, response) = try await URLSession.shared.data(for: request)
        guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status)
        else { throw URLError(.badServerResponse) }
        return data
    }
}

Hand the token to it from your app delegate. With SwiftUI, attach the delegate with @UIApplicationDelegateAdaptor(AppDelegate.self).

AppDelegate.swift
import UIKit

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        Task { await PushRegistration.shared.didReceive(deviceToken: deviceToken) }
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("APNs registration failed: \(error)")
    }
}

Call await PushRegistration.shared.enable() where you ask for notifications and on each launch after that. environment must match the APNs environment of the build: sandbox for builds run from Xcode, production for TestFlight and the App Store.

PushRegistration.kt
package com.example.app

import android.content.Context
import android.content.pm.ApplicationInfo
import com.voidhash.sdk.VOIDHASH_SDK_VERSION
import com.voidhash.sdk.Voidhash
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject

/** Keeps this device's FCM token registered with Voidhash for the current person. */
class PushRegistration(context: Context) {
    private val context = context.applicationContext
    private val prefs = this.context.getSharedPreferences("voidhash_push", Context.MODE_PRIVATE)
    private val isDebugBuild =
        (this.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0

    /** Links [token] to the current distinct ID in the background. Call again after identify. */
    fun register(token: String) {
        scope.launch {
            val body = JSONObject()
                .put("platform", "android")
                .put("provider", "fcm")
                .put("platformToken", token)
            prefs.getString(ID_KEY, null)?.let { body.put("previousPushDeviceTokenId", it) }
            val response = post("register", body) ?: return@launch // Retried on the next launch.
            val id = JSONObject(response).getString("pushDeviceTokenId")
            prefs.edit().putString(ID_KEY, id).apply()
        }
    }

    /** Stops pushes to this device for the current person. Call before signOut(). */
    suspend fun unregister() {
        val id = prefs.getString(ID_KEY, null) ?: return
        post("unregister", JSONObject().put("pushDeviceTokenId", id))
        prefs.edit().remove(ID_KEY).apply()
    }

    private suspend fun post(action: String, body: JSONObject): String? = withContext(Dispatchers.IO) {
        val voidhash = Voidhash.shared ?: return@withContext null
        val connection = URL("https://api.voidhash.com/api/v1/sdk/push-devices/$action")
            .openConnection() as HttpURLConnection
        try {
            connection.requestMethod = "POST"
            connection.doOutput = true
            mapOf(
                "content-type" to "application/json",
                "x-publishable-key" to if (isDebugBuild) "vh_pk_test_..." else "vh_pk_...",
                "x-distinct-id" to voidhash.getDistinctId(),
                "x-client-bundle-id" to context.packageName,
                "x-platform" to "android",
                "x-platform-flavor" to "native",
                "x-sdk" to "android",
                "x-sdk-version" to VOIDHASH_SDK_VERSION,
                "x-is-debug-build" to isDebugBuild.toString(),
                "x-is-backgrounded" to "false",
                "x-observer-mode" to "false", // "true" if you configure the SDK with readOnly
            ).forEach { (name, value) -> connection.setRequestProperty(name, value) }
            connection.outputStream.use { it.write(body.toString().toByteArray()) }
            if (connection.responseCode in 200..299) {
                connection.inputStream.bufferedReader().use { it.readText() }
            } else {
                null
            }
        } catch (error: IOException) {
            null
        } finally {
            connection.disconnect()
        }
    }

    private companion object {
        const val ID_KEY = "push_device_token_id"
        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    }
}

FCM hands a new token to your messaging service:

PushService.kt
package com.example.app

import com.google.firebase.messaging.FirebaseMessagingService

class PushService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        PushRegistration(this).register(token)
    }
}
AndroidManifest.xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application>
    <service android:name=".PushService" android:exported="false">
        <intent-filter>
            <action android:name="com.google.firebase.MESSAGING_EVENT" />
        </intent-filter>
    </service>
</application>

Android 13 and later show notifications only after the person grants POST_NOTIFICATIONS. Ask from an activity, then read the current token:

MainActivity.kt
package com.example.app

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging

class MainActivity : ComponentActivity() {
    private val askForPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            if (granted) registerPushToken()
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enablePush()
    }

    private fun enablePush() {
        val needsPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
            ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
            PackageManager.PERMISSION_GRANTED
        if (needsPermission) {
            askForPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
        } else {
            registerPushToken()
        }
    }

    /** Also call after identify, so the device follows the signed-in person. */
    fun registerPushToken() {
        FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
            PushRegistration(this).register(token)
        }
    }
}

This sample uses expo-notifications, whose getDevicePushTokenAsync() returns the native APNs or FCM token, and expo-application for the bundle ID. Send the native token, not an Expo push token.

npx expo install expo-notifications expo-application
push.ts
import { version as sdkVersion } from "@voidhash/react-native/package.json";
import * as Application from "expo-application";
import * as Notifications from "expo-notifications";
import { AppState, Platform } from "react-native";

import { voidhash } from "./voidhash";

const ENDPOINT = "https://api.voidhash.com/api/v1/sdk/push-devices";
let pushDeviceTokenId: string | undefined;

async function post(action: string, body: object): Promise<Response | undefined> {
  const distinctId = await voidhash.client.getDistinctId();
  if (distinctId.isErr() || distinctId.value === null) return undefined;
  try {
    const response = await fetch(`${ENDPOINT}/${action}`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-publishable-key": __DEV__ ? "vh_pk_test_..." : "vh_pk_...",
        "x-distinct-id": distinctId.value,
        "x-client-bundle-id": Application.applicationId ?? "",
        "x-platform": Platform.OS,
        "x-platform-flavor": "native",
        "x-sdk": "react-native",
        "x-sdk-version": sdkVersion,
        "x-is-debug-build": String(__DEV__),
        "x-is-backgrounded": String(AppState.currentState === "background"),
        "x-observer-mode": "false", // "true" if you create the client with readOnly
      },
      body: JSON.stringify(body),
    });
    return response.ok ? response : undefined;
  } catch {
    return undefined; // Offline: retried on the next launch.
  }
}

async function registerToken(platformToken: string): Promise<void> {
  const device =
    Platform.OS === "ios"
      ? {
          platform: "ios",
          provider: "apns",
          bundleId: Application.applicationId,
          environment: __DEV__ ? "sandbox" : "production",
        }
      : { platform: "android", provider: "fcm" };
  const response = await post("register", {
    ...device,
    platformToken,
    previousPushDeviceTokenId: pushDeviceTokenId,
  });
  if (response) pushDeviceTokenId = (await response.json()).pushDeviceTokenId;
}

/** Asks for permission and registers the device. Call on launch and after identify(). */
export async function enablePush(): Promise<void> {
  if (Platform.OS === "android") {
    // Android 13 and later prompt only once a channel exists.
    await Notifications.setNotificationChannelAsync("default", {
      name: "Default",
      importance: Notifications.AndroidImportance.DEFAULT,
    });
  }
  const { granted } = await Notifications.requestPermissionsAsync();
  if (!granted) return;
  const token = await Notifications.getDevicePushTokenAsync();
  await registerToken(token.data);
}

Notifications.addPushTokenListener((token) => {
  void registerToken(token.data);
});

/** Unregisters this device, then signs out of Voidhash. */
export async function signOutOfVoidhash(): Promise<void> {
  if (pushDeviceTokenId) {
    await post("unregister", { pushDeviceTokenId });
    pushDeviceTokenId = undefined;
  }
  await voidhash.client.signOut();
}

Call enablePush() without await so the UI never waits on it. Bare React Native apps can use @react-native-firebase/messaging instead: send getAPNSToken() on iOS and getToken() on Android to the same endpoint.

A distinct ID that isn't printable ASCII must be percent-encoded, with the header x-distinct-id-encoding: percent. SDK-created anonymous IDs never need it.

Sign in and sign out

After the person signs in, register again so the device moves to them:

_ = try await Voidhash.shared?.identify(externalUserId: user.id)
await PushRegistration.shared.register()

// Signing out
await PushRegistration.shared.unregister()
await Voidhash.shared?.signOut()
voidhash.identify(user.id)
registerPushToken()

// Signing out
PushRegistration(context).unregister()
voidhash.signOut()
await voidhash.client.identify(user.id);
void enablePush();

// Signing out
await signOutOfVoidhash();

If unregistering fails, for example offline, the device stays with the signed-out person until someone registers it again. To push to signed-out people too, register again after signOut(); the device then moves to the new anonymous person.

Handle failures

Registration never needs to block the UI or a purchase. Run it in the background and treat a network error or 500 as "try again next launch". A 400, 401 or 403 needs a fix, not a retry, and a 404 on unregister means the work is done. Responses explains each one.

Check that it worked

Send yourself a push to the distinct ID the app registered, as an admin or owner. From the CLI, run voidhash push send --to user_123 --title "Test" --body "Registered". The API, server libraries and MCP work too; see Send a push. The API's 202 response gives the send's status and deviceCount, and MCP's list_push_deliveries shows each delivery.

The status no_recipients means no device is registered to that person on an enabled push service. Check Activity → Sent notifications, or GET …/push-notifications/{pushNotificationId}/deliveries, for each device's delivery.

Next steps

  • Push notifications: send pushes and follow delivery.
  • Push device API: every header, field and response.
  • Report opened notifications from Swift, Kotlin and React Native.

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousHandle refundsNextMigrate to Voidhash

On this page

What you needHow registration worksAdd the registration codeSign in and sign outHandle failuresCheck that it workedNext steps