Identify a subscriber
PUT /v1/subscribers/:externalId is an idempotent upsert on your own id. It answers 201 the first time and 200 on every call after, so it is safe on every login.
email belongs to the subscriber: it is saved as the email attribute, and attributes.email means exactly the same thing, so pick whichever spelling suits you. When the tenant has an email provider connected, either one also registers an email subscription for that address. Without an email provider the address is kept as profile data and nothing fails, so you can set it from day one. Pass subscribe: { email: false } to keep an address as profile data only. timezone takes an IANA name and sets $timezone from your backend, which is what scheduling in each subscriber’s local time reads. Anything else is a 400 invalid_timezone.
externalId must be URL-encoded in the path. Emails, slashes and spaces all work as ids.$subscriber.created on the event stream and a change records $subscriber.updated, both carrying the attributes snapshot.
Attributes
attributes is a free-form JSON object. Segments filter on it, workflows branch on it, and your own reads see it on every subscriber. Two rules apply:
- The server-side PUT replaces attributes wholesale when the field is present. The client API merges instead, so the app can add keys without wiping what your backend set.
- The serialized object is capped at 64KB. Past that the call is a 400
attributes_too_large.
System attributes
Keys starting with$ belong to BuzzKit and are refused in a PUT body with a 400 system_attribute. They survive a wholesale replace and ride along in attributes on every read.
They refresh on every client identify and every client subscription registration, newest wins.
$timezone is the one you can also set from your backend, through the timezone field above, for subscribers whose devices never call the client API.
Subscriptions
A subscription is registered by channel shape, and it creates the subscriber implicitly if the id is new.lastSeenAt, reactivates an endpoint that was marked invalid, and moves the endpoint if the externalId changed, which is what happens when a device changes hands. You get 201 on create and 200 on a refresh. Every write that is more than a lastSeenAt refresh records $subscription.registered on the stream, and a move also records $subscription.removed for the previous owner. The channel must have a live credential on the tenant, otherwise the call is a 400 channel_not_connected.
Push subscriptions also carry environment, production by default and sandbox for debug builds. It selects which APNs credential slot is used at delivery time.
In practice the iOS SDK makes this call for you on every launch. Register from your backend when you hold the token yourself.
Muting one device versus removing it
PATCH /v1/subscriptions/:id with { "enabled": false } mutes a single subscription. The work iPhone goes quiet while every other device of the same person keeps receiving. A refresh never resets enabled, so a muted subscription stays muted when the app re-registers.
DELETE /v1/subscriptions/:id soft-deletes it instead. The endpoint can register again fresh, and a fresh registration is not muted.
A send goes out only through subscriptions that are enabled, active, and opted in to the message’s topic and channel.
status is active or invalid. Delivery feedback from the provider, an APNs 410 or an FCM UNREGISTERED, flips a push subscription to invalid on its own, so dead tokens need no pruning of yours.Anonymous to identified
Apps track people before they sign up. The iOS SDK mints an anonymous id at first launch and sends it asanonymousId on the first identify after the device was anonymous, and the API merges that subscriber into the identified one.
POST /v1/client/identify
subscriber_merged, because the row it was started for no longer exists. A run started before a rename is untouched.
The anonymous id is kept as an alias, so it stays resolvable, and every merge writes a $subscriber.merged event carrying the id it merged from.
Only an anonymous, never-verified subscriber is merged automatically. Naming an identified subscriber is refused with
merge_source_identified, and repeating a merge that already happened does nothing, so identifying on every launch is safe. To link two ids deliberately, use the alias endpoint below.A merge moves the most recent 1,000 events of the retired subscriber. Anything older stays where it was written and is not lost, it simply does not appear on the merged timeline. A merge is also refused with
merge_history_pending while the retired subscriber still has events waiting to reach the event store, so retry it and nothing is dropped.Aliases
A subscriber can be known by more than one id. Every past id is kept as an alias, and any endpoint that takes anexternalId accepts an alias, so an id never stops working once it has been used. That covers sends (to), including one scheduled before a merge, and the subscriber a source rule names when a provider still reports the old id.
POST /v1/subscribers/user_42/aliases
- The id is unused. It is recorded as an alias, and
GET /v1/subscribers/onesignal:8f2c1anow answers withuser_42. - The id already belongs to another subscriber. That subscriber is merged into this one: its subscriptions, preferences, attributes, live activities, widget registrations, deliveries and events move across, its own aliases follow, its in-flight runs are canceled, and it is retired. An id that is only an alias of another subscriber counts as belonging to it, so aliasing one merges that whole subscriber away. This is how you fold imported installs or a legacy id scheme into your real user ids.
GET /v1/subscribers/:externalId/aliases lists them, each with the source that created it: system for one BuzzKit recorded when a device identified, manual for one you added yourself.
Identity verification
A client key alone lets any caller claim anyexternalId. To prove the claim, your backend computes identityHash = HMAC-SHA256(externalId, identitySecret) as hex and hands it to the app at login. The secret comes from GET /v1/tenants/:slug/identity-secret, which is session-only. Keep it server-side and never ship it in the app binary. POST /v1/tenants/:slug/identity-secret/rotate invalidates every outstanding hash.
A valid hash on any client call stamps the subscriber verified with an identityVerifiedAt, both visible on every subscriber read, so anonymous and verified users coexist and you can tell them apart. An invalid hash is a 401 whether or not enforcement is on. Turn enforcement on per tenant and every client call must then carry a valid hash.
Reading a subscriber
verified and identityVerifiedAt. GET /v1/subscribers/:externalId/subscriptions returns the same list on its own.
The timeline is this person’s slice of the event stream, newest first, keyset-paginated and filterable by name, source and provider. It holds every event you tracked plus the lifecycle BuzzKit writes for them: $subscriber.created, updated and deleted, $subscription.registered, muted, unmuted, removed and invalidated, $preferences.updated and $identify. Every $subscription.* event names the subscription it is about, with externalId, channel, platform and endpoint, so a timeline row can say which device changed.
The deliveries list is every delivery addressed to this subscriber, newest first with a total, each carrying a summary of its message: id, channel, topic, title, body and createdAt. It needs the messages:read scope. See delivery for the statuses.
Listing and searching
search matches external ids starting with the text, or a name attribute containing it. Each item carries lastSeenAt, the newest across the subscriber’s live subscriptions and null when there are none, channels such as ["push", "email"], and platforms such as ["ios", "android"].
DELETE /v1/subscribers/:externalId soft-deletes the subscriber and all their subscriptions.
Next
Segments
Target subscribers by attributes, events and activity instead of by id.
Topics and preferences
Let each subscriber choose which notifications reach them, per channel.