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
previousPushDeviceTokenIdand 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; aftersignOut()the SDK sends a new anonymous distinct ID, and the call fails with404.
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.
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).
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.
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:
package com.example.app
import com.google.firebase.messaging.FirebaseMessagingService
class PushService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
PushRegistration(this).register(token)
}
}<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:
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-applicationimport { 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.