Skip to content
Docs
FA
Sign in
Start here
  • Overview
  • Quickstart
  • Concepts
Collect data
  • Designing events
  • Event dictionary
  • Placing events
  • Identity
  • Web SDK
  • Android SDK
  • Devices and push
  • Server to server
  • Product catalogue
  • Webhooks
Engage customers
  • Segments
  • Journeys
  • Transactional
  • Consent and caps
  • In-app and inbox
Analyze and export
  • Reports and exports
Developer reference
  • API reference
    • Ingest endpoints
    • Management API
  • Errors
  • Limits
  • OpenAPI
Developer tools
  • MCP server
  • Working with an agent
Privacy and changes
  • Personal data
  • Versioning

Registering a device for mobile push

Register device tokens for mobile push, browser push and messengers.

#Two paths, and which one you need

REGISTRATION
Web subscriptionPush endpoint
FCM tokenAndroid
APNs tokeniOS
SEGMENTICDevice registryOne profile can own many reachable devices
DELIVERY ROUTES
Web pushBrowser
Android pushFCM
iOS pushAPNs
How web subscriptions, FCM tokens and APNs tokens become reachable delivery routes

Device registration is not an event. Events go through the queue because the platform has to absorb bursts of a hundred thousand a second. Registrations are the opposite shape: a few per install per day, and they must be readable immediately. Somebody who opens the app and enters a welcome journey two seconds later has to be reachable at that moment. So POST /v1/devices writes straight to the database and never touches the queue.

There are two paths to that row, and neither replaces the other.

PathWho it is forWhat you fill in yourself
The Android SDKAn Android appThe token, and has_gms if you want to
POST /v1/devicesiOS, web, desktop, a server-side integration, and any app that will not add a dependencyEvery field

The manual path is not going away. There is no published iOS SDK, a server-side integration has no device to take a token from, and some customers add no library to their app at all. All three land on this one endpoint.

Both paths talk to the ingest host: https://in.segmentic.net with a write key wk_seg_.... The write key is public and is meant to ship inside your app bundle. The management host (https://api.segmentic.net, with an API key sk_seg_...) carries no device route at all: not create, not edit, not delete, and not read. The only place a person's devices can be seen is the panel, described in reading back a profile's devices.

On a local install the same endpoints come up on http://localhost:8080.

Device registration is not metered and not quota-checked. overQuota and the usage counter are called from /v1/track and its family and /v1/batch; POST /v1/devices calls neither. How often you re-register your devices has no effect on the bill.

#The Android SDK path

Your app supplies the token. The SDK does not fetch it. That is deliberate: an app that sends push already has Firebase, or Bazaar, or Myket, wired up with its own project and its own version of that library. Fetching the token ourselves would mean this SDK picking a Firebase version on your behalf and colliding with yours. The consequence is that the whole Android SDK has no dependencies: no JSON library, no HTTP library, no AndroidX, no Firebase. The only permission it adds to your app is INTERNET.

Initialise once, then register from wherever the token arrives:

Kotlin
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        Segmentic.init(
            this,
            SegmenticOptions(
                writeKey = "wk_seg_...",
                apiHost = "https://in.segmentic.net",
            ),
        )
    }
}

class MyMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        Segmentic.registerDevice(mapOf(PushTransport.FCM to token))
    }
}

Several routes on one device, with the server deciding which of them delivers:

Kotlin
Segmentic.registerDevice(
    mapOf(
        PushTransport.FCM to fcmToken,
        PushTransport.BAZAAR to bazaarToken,
    ),
)

PushTransport on Android carries four constants: FCM, BAZAAR, MYKET, MQTT. There is no APNS constant on Android, because Android can never deliver on it.

If your app already depends on play-services-base, give the authoritative answer yourself. Left to itself the SDK probes for the com.google.android.gms package: found is true, absent is false, any other exception is null, meaning "I could not tell".

Kotlin
val gms = GoogleApiAvailability.getInstance()
    .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
Segmentic.registerDevice(tokens, hasGms = gms)

The SDK fills these in so you do not have to: device_id, platform, has_gms, push_enabled, app_version, manufacturer, model, os_name, os_version, locale, timezone, sdk_name, sdk_version. Identity (user_id and anonymous_id) is filled in by the core rather than by the caller, so the host app cannot register a device against a user id that has since signed out.

device_id is a random UUID kept in the app's own private storage. Deliberately not the advertising id and not Settings.Secure.ANDROID_ID: both identify a person across unrelated apps, which is a question the customer has to answer rather than us. It lasts until the app is uninstalled or its data is cleared.

These are the exact bytes an Android 15 device put on the wire. The fixture is not hand-written; it was captured by a recording collector and is only regenerated from a fresh run on a real device:

The real bytes from an Android install
{"device_id":"79a1c2c3-a61a-4816-a355-f3d5a0c7ffc2","platform":"android","anonymous_id":"711faad0-317b-40aa-81d7-253a39280348","tokens":{"fcm":"scripted-token-not-a-real-one"},"has_gms":true,"push_enabled":false,"app_version":"0.1.0","manufacturer":"Google","model":"sdk_gphone64_x86_64","os_name":"android","os_version":"15","locale":"en-US","timezone":"Asia/Tehran","sdk_name":"segmentic-android","sdk_version":"0.1.0"}

registerDevice has three outcomes and the difference matters:

  • REGISTERED: stored.
  • REFUSED: a 4xx came back. The body is discarded, because the same body would be refused identically next time, and keeping it means posting the same rejected request on every launch for ever. The same value comes back when the user has opted out, and that case sends no request at all.
  • PENDING: anything else. Written to disk and retried on the next flush and the next app launch.

You do not receive these three values. Segmentic.registerDevice runs on the SDK's own network thread and returns nothing; the outcome appears only in logcat under the tag segmentic. The core is what returns the value, and the core is what retries.

Calling registerDevice again for the same device is not a duplicate; the server upserts on device_id. Call it every time the provider rotates the token.

Installation and the rest of the SDK surface are in the Android SDK.

#Registering a device with POST /v1/devices

One request, one device. There is no batch endpoint.

Registering an Android install with two routes
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-1",
    "user_id": "u_123",
    "platform": "android",
    "tokens": { "fcm": "fcm-tok", "bazaar": "bazaar-tok" },
    "model": "Xiaomi Redmi Note 12",
    "timezone": "Asia/Tehran"
  }'

The 200 response, exactly this and nothing more, because warnings is omitted from the body when it is empty:

JSON
{"status":"ok"}

iPhone is the same endpoint with a different platform and a different route:

Registering an iOS install from your own code
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "F7A1C2C3-A61A-4816-A355-F3D5A0C7FFC2",
    "user_id": "u_9137",
    "platform": "ios",
    "tokens": { "apns": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" },
    "push_enabled": true,
    "app_version": "3.4.0",
    "model": "iPhone13,2",
    "os_name": "ios",
    "os_version": "17.4",
    "locale": "fa-IR",
    "timezone": "Asia/Tehran"
  }'
JSON
{"status":"ok"}

The write key can be supplied three ways, all of them valid:

  • Authorization: Bearer wk_seg_...
  • X-Segmentic-Key: wk_seg_...
  • the query parameter ?write_key=wk_seg_... (needed for calls such as sendBeacon that cannot set a header)

Transport notes: the body is JSON and is parsed regardless of Content-Type; the body limit is 5 MiB (5 << 20 bytes); every response is application/json; charset=utf-8. CORS is open (Access-Control-Allow-Origin: *, methods POST, OPTIONS, headers Content-Type, Authorization, X-Segmentic-Key, preflight lifetime 86400) but credentials are never allowed.

tenant_id and app_id are ignored if you send them. Both come from the write key. A test posts "tenant_id": 999 and asserts the device is still stored under the key's real tenant.

#Every field of the body

FieldTypeRequiredBehaviour and default
device_idstringyesTrimmed. Empty, or longer than 256 bytes, rejects the whole request
platformstringyesParsed case-insensitively and through an alias table. See platforms
user_idstringone of the two is requiredTrimmed, then truncated at 256 bytes
anonymous_idstringone of the two is requiredTrimmed, then truncated at 256 bytes
tokensobject, transport name to tokenno, but see the "nothing to store" ruleKeys lower-cased and trimmed, values trimmed. Each token at most 4096 bytes
push_providerstringnoThe legacy single-route form, paired with push_token
push_tokenstringnoThe legacy single-route form
has_gmsboolean or nullnoTri-state. Absent means unknown, which is not false
push_enabledboolean or nullnoTri-state. Absent means unknown, which is read as allowed
app_versionstringnoTrimmed, truncated at 256 bytes
manufacturerstringnoTrimmed, truncated at 256 bytes
modelstringnoTrimmed, truncated at 256 bytes
os_namestringnoTrimmed, truncated at 256 bytes
os_versionstringnoTrimmed, truncated at 256 bytes
localestringnoTrimmed, truncated at 256 bytes
timezonestringnoTrimmed, truncated at 256 bytes. This is what makes "send at 9am" and quiet hours mean the recipient's own clock
sdk_namestringnoTrimmed, truncated at 256 bytes
sdk_versionstringnoTrimmed, truncated at 256 bytes

Truncation cuts on a character boundary, so a Persian field is never left as invalid UTF-8. The test measures it with 256 copies of «ش».

The legacy single-route form still works and is not going away, because customers pin SDK versions for years and an upgrade must never be the condition of continuing to receive:

The legacy form, still accepted
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-old",
    "user_id": "u_123",
    "platform": "android",
    "push_provider": "bazaar",
    "push_token": "legacy"
  }'
JSON
{"status":"ok"}

If you send both forms, the legacy pair is applied first and tokens then writes over it, so the richer map wins.

#The "nothing to store" rule

An empty tokens is not an error on its own. It depends on push_enabled.

  • No token and push_enabled absent or true: a 400 with device: registration carries no usable token. The call achieved nothing, so storing it would only grow the table.
  • No token and push_enabled: false: a 200, and the row is stored. That is a real state change: the user switched notifications off and it has to be recorded.

The second case is exactly what you send after the user turns notifications off in the OS settings:

The user switched notifications off
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-1",
    "user_id": "u_123",
    "platform": "android",
    "push_enabled": false
  }'
JSON
{"status":"ok"}

#Platforms and the transports that reach them

platform is required and has no default. Leaving it out is a 400.

PlatformAccepted spellingsTransports accepted at registrationCan a campaign actually deliver?
androidandroidfcm, bazaar, myket, huawei, mqttYes, over fcm, bazaar, myket or huawei
iosios, iphone, ipadapns, mqttYes, over apns
webweb, browser, webappwebpushYes, over webpush
windowswindows, win, win32, win64webpushNo. It has no default route order
macosmacos, mac, mac os, osx, darwinwebpushNo. It has no default route order
linuxlinuxwebpushNo. It has no default route order
serverserver, backend, apinoneRegistration is refused with 400

Names are parsed case-insensitively after trimming, so " Android " resolves to android. The same applies to tokens keys: " FCM " becomes fcm.

The three desktop platforms are deliberately not folded into web, so that "how many macOS installs do we have" stays answerable. They exist at all because a real customer had them and the import failed.

windows, macos and linux register but do not receive push today. The route-order table has entries only for android, ios and web, and the router produces no route for a platform with no entry. At send time the result is push: no usable transport for this device.

server is refused rather than stored. A backend has no device at all, and a row here that no push could reach would be counted in every "reachable" figure a customer sees. In practice that means platform: "api" or platform: "backend" gets a 400.

An unknown platform and an unknown transport behave completely differently:

  • An unknown platform (blackberry, symbian) rejects the whole request. Nothing is stored.
  • An unknown transport (pigeon) drops only that token with a transport_not_supported warning, and the rest of the registration proceeds.

The mqtt transport is accepted and stored and never delivers. The constant is defined and it is in the default order, but there is no MQTT provider implementation anywhere in the repository, and the router drops a transport with no configured provider. Do not build on mqtt.

#Token shapes, per transport

TransportWhat to sendServer-side cleaning
fcmThe string onNewToken gave youTrimmed only
apnsLower-case hexTrimmed, leading and trailing < and > stripped, all spaces removed, lower-cased
bazaarThe token Cafe Bazaar's push service gave youTrimmed only
myketThe token Myket's push service gave youTrimmed only
huaweiThe token HMS Push Kit gave youTrimmed only
webpushThe browser subscription's endpoint URLTrimmed only
mqttAccepted, never deliveredTrimmed only

The APNs cleaning is not cosmetic. Older iOS APIs stringify the token as <a1b2 c3d4>, and sending that verbatim is refused by Apple for every message for ever, while the campaign reports a hundred percent sent. The test measures it: " <A1B2 C3D4 E5F6> " becomes a1b2c3d4e5f6. Note that this cleaning is applied to transport apns only.

There is no other validation of token shape. The only two checks are that it is non-empty after trimming and no longer than 4096 bytes. No prefix, no character set, no length range. A malformed FCM token is accepted here and refused by Google at send time.

If Apple's SDK hands you Data, convert it to hex yourself. Stringifying it with description produces exactly the <a1b2 c3d4> shape:

Converting an APNs token to a string correctly
func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
    register(apnsToken: hex)   // your own POST /v1/devices call
}

#Notification permission and Play Services: both tri-state

A field being absent is not the same as it being false, and these two fields are where that difference costs you money and audience.

push_enabled is the OS-level notification permission:

Value in JSONMeaningEffect on targeting
Field absent, or nullUnknown; the SDK is too old to report itRead as allowed
trueThe user granted the permissionAllowed
falseThe user switched notifications off in settingsExcluded from every campaign

Unknown is read as allowed on purpose: muting a user because their app is out of date would silently shrink every audience. The delivery query carries AND d.push_enabled and the two lookup indexes are partial on the same condition, so the exclusion is enforced in two places.

The storage nuance you will meet: the column is push_enabled BOOLEAN NOT NULL DEFAULT TRUE, so the tri-state is collapsed on write. On insert it is COALESCE($8, TRUE), on update COALESCE($8, devices.push_enabled). Unknown becomes true for a new row and leaves an existing row untouched, because the SDK staying silent must not erase what a newer build already told us.

has_gms is the SDK's report on whether Google Play Services looked healthy:

Value in JSONMeaningEffect
Absent or nullThe SDK did not lookThe router assumes it is probably there and still tries FCM
truePlay Services looked healthyFCM is first choice
falseNo Play ServicesFCM is skipped entirely at send time, and registration emits a fcm_without_gms warning

The FCM token is stored even when has_gms: false, because Play Services can be installed later and throwing the token away would make that recovery impossible.

Do not send false when you mean "I did not check". Leave the field out. push_enabled: false removes the device from every campaign, and has_gms: false disables FCM for that device until a later registration says otherwise.

Do not send has_gms on iOS at all. It is meaningless there, and the iOS SDK does not send it either.

#Warnings

A warning means something was accepted and altered. The warnings array rides along on a 200 and on a 400. There are exactly four codes.

codefieldWhenWhat happens to the token
empty_tokenthe transport nameThe token was empty or whitespace onlyDropped
token_too_longthe transport nameThe token was longer than 4096 bytesDropped
transport_not_supportedthe transport nameThe transport cannot physically reach that platform, or the transport name is unknown entirelyDropped
fcm_without_gmsfcmAn FCM token on a device that reported has_gms: falseKept

Their message strings, in the same order: token was empty and has been ignored, token exceeds the maximum length and has been ignored, transport <t> cannot deliver to <platform>, device reports no Play Services; FCM will not be used for it.

This example is real. An Android install that sent both an APNs token and an FCM token:

One right token and one wrong one
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-1",
    "user_id": "u",
    "platform": "android",
    "tokens": { "apns": "wrong", "fcm": "right" }
  }'

The response is 200. The usable token was stored and the other was not:

JSON
{"status":"ok","warnings":[{"code":"transport_not_supported","message":"transport apns cannot deliver to android","field":"apns"}]}

Read the 200 body, not just its status code. A wrong token is dropped quietly and this array is the only place it shows. That is the difference between a customer debugging a zero delivery rate for a week and seeing the problem the first time they call the endpoint.

#Rejections and status codes

The message on a 400 is the validation error text verbatim, and the warnings ride along on failure too. Without them, somebody sending an APNs token from an Android build sees only "no usable token" and has nothing to go on.

StatusmessageWhen
400device: device_id is requireddevice_id missing, blank after trimming, or longer than 256 bytes
400device: platform must be one of android, ios, web, windows, macos, linuxplatform missing, unknown, or resolving to server
400device: user_id or anonymous_id is requiredBoth are blank
400device: registration carries no usable tokenNo usable token is left and notifications were not explicitly declared off
400malformed JSONThe body is not JSON
401missing write keyNo key in a header or the query string
401invalid write keyUnknown, revoked or suspended key
413request body too largeThe body exceeded 5 MiB
503cannot verify the write key right now; retryThe key lookup itself failed. Carries Retry-After: 5
503temporarily unavailable, please retryWriting the device to the database failed
405(no JSON body)This deployment has no device store, so the route was never registered

A full 400, from the test that posts platform ios with an fcm token. The key order is this: status, then warnings, then message:

JSON
{"status":"error","warnings":[{"code":"transport_not_supported","message":"transport fcm cannot deliver to ios","field":"fcm"}],"message":"device: registration carries no usable token"}

Read 401 as permanent and 503 as transient, exactly as the SDKs do. A failing key lookup answers 503, not 401, and the reason was measured rather than reasoned about: when this path answered 401, with the database scaled to zero, eight of eight events came back 401, so an infrastructure outage destroyed events at the customer's end while their own logs told them their API key was invalid.

Three traps that catch a developer on the first try:

  • A device_id longer than 256 bytes reports device_id is required, even though you sent one. The message is misleading.
  • POST /v1/devices/unregister answers a malformed body with that same device_id is required. Your broken JSON is reported as a missing id.
  • A 405 on /v1/devices in staging is a configuration fact, not a problem with your payload. With no device store wired the route is never registered, and the OPTIONS /v1/ pattern claims every path under /v1/, so the multiplexer knows the path but not the method.

Every response from these endpoints, successful or not, carries an X-Segmentic-Trace header: sixteen hex characters. It is repeated in no body, so it is lost if you do not log it, and it is the only thing that lets us trace one request.

The full error contract is in errors.

#The same device again, the same token elsewhere

A push token identifies a delivery route, not a device. Everything in this section follows from that one sentence.

Registering the same device again. Register is an upsert on (tenant_id, device_id), and two rules are enforced in the store rather than in the caller:

  • Never erase. A field the SDK did not send keeps its stored value. Every string column goes through COALESCE(NULLIF(EXCLUDED.x, ''), devices.x). SDKs report device facts from several places at different times, so treating silence as "clear it" would have the FCM token wipe the Bazaar one on every app open.
  • Take the token. If another install holds the same token, it lost it.

Also, last_seen_at always advances, and revoked_at is set back to NULL because a reinstall brings a revoked device back to life. A token that had been retired has retired_at cleared, because a retired token coming back means the app was reinstalled and the route is live again.

The same token on a different device. This is the most important dedup rule here. After a restore-from-backup or a reinstall, the provider can hand the same token to a new device_id. If both rows survive, every campaign delivers twice to that person, and it looks like a bug in the customer's app rather than in ours. So registration deletes any other holder of the token before inserting:

SQL
DELETE FROM device_tokens
WHERE tenant_id = $1 AND transport = $2 AND token = $3 AND device_id <> $4

That delete runs inside the same transaction as the upsert, and a unique index is what makes it non-negotiable rather than best-effort:

SQL
CREATE UNIQUE INDEX idx_device_tokens_unique
    ON device_tokens (tenant_id, transport, token)
    WHERE retired_at IS NULL

Two users on one phone. The device row is keyed by the install, not by the person. One phone sees several accounts over its life and one person has several phones; keying by user would keep pushing an old account's messages to whoever signs in next. On the next POST /v1/devices carrying a new user_id, the upsert overwrites user_id: the later sign-in wins. Detaching the previous account is what sign-out does, and if you never call it, the previous account stays attached until the next registration.

One user with many devices. Bounded in two places.

  • A cap on how many installs one person receives on: 5 by default, from DELIVERY_DEVICES_PER_USER. Someone who has upgraded their phone five times still holds five rows, and without a cap they get the same notification five times, which reads as spam and is the fastest way to lose a push permission.
  • Stale installs are dropped: 180 days by default, from DELIVERY_STALE_DEVICE.

The cap counts devices, not token rows. A phone with three transports is one recipient. The ordering is last_seen_at DESC, newest install first.

Anonymous installs. A registration carrying only anonymous_id is accepted and stored, but it is targeted by no campaign today. The delivery query selects on d.user_id = ANY($2) only, and there is no code path in the store that reads devices by anonymous_id. So push to a not-yet-identified install does not exist. If you want a welcome campaign for a visitor who has not signed up, there is no answer for it today: identification has to happen first.

#Sign-out and uninstall

Signing out is not uninstalling. They are two different things and one endpoint separates them with one flag.

It matters more than it looks. On a shared phone, leaving the previous account attached means the next person receives somebody else's order updates, the kind of defect that ends a contract.

FieldTypeRequiredMeaning
device_idstringyesThe install to act on
user_idstringnoWhen present, detaches only if that user is currently attached
revokedbooleanno, default falsefalse is sign-out, true marks the install as gone
Sign-out
curl -X POST https://in.segmentic.net/v1/devices/unregister \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"device_id": "dev-1", "user_id": "u_123"}'
JSON
{"status":"ok"}
The install is gone
curl -X POST https://in.segmentic.net/v1/devices/unregister \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"device_id": "dev-1", "revoked": true}'
JSON
{"status":"ok"}

This is exactly what happens in the database. Sign-out:

SQL
UPDATE devices SET user_id = '', last_seen_at = now()
WHERE tenant_id = $1 AND device_id = $2
  AND ($3 = '' OR user_id = $3)

The row survives deliberately. The token is still valid, the person may sign back in, and deleting the row would make the next registration look like a brand new install, which corrupts install and reactivation counts. An empty user_id in the request means "detach whoever is attached".

And uninstall:

SQL
UPDATE devices SET
    revoked_at = now(),
    revoked_reason = 'unregistered',
    first_uninstalled_at = COALESCE(first_uninstalled_at, now())
WHERE tenant_id = $1 AND device_id = $2 AND revoked_at IS NULL

The reason is unregistered rather than uninstalled, because this is the SDK calling unregister, which in practice is a sign-out far more often than a deletion. Counting the two together would make every logout look like churn on the uninstall report.

Neither call retires the tokens. A revoked device is kept out of sends by d.revoked_at IS NULL.

Responses: 200 with {"status":"ok"}; 400 with {"status":"error","message":"device_id is required"} when the body is not JSON or device_id is empty; 503 with the transient message when the write fails; and the same 401 and 503 authentication answers as the section above.

#The browser equivalent: a web push subscription

A browser has no token, it has a subscription. The web equivalent of POST /v1/devices is two separate endpoints.

Storing a browser subscription
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_9137",
    "subscription": {
      "endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
      "p256dh": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
      "auth": "tBHItJI5svbpez7KI4CCXg"
    }
  }'
JSON
{"status":"ok"}

The flat shape is accepted too, with endpoint, p256dh and auth at the top level. The nested shape exists because it is the Push API's own shape: a page can post what the browser handed it without picking it apart first and, more to the point, without re-encoding the keys. Base64 that a well-meaning helper decoded and re-encoded is the classic way a subscription silently stops decrypting on the recipient's machine.

FieldTypeRequiredMeaning
user_idstringyesThe subscription is stored against a person
endpointstringyesThe push service URL for this browser install. Possession of it is sufficient to send, which is why it is treated as a secret and never returned to a client
p256dhstringyesThe browser's public key, base64url, an uncompressed P-256 point
authstringyesA 16-byte shared secret the browser generated

All four are required. Missing any of them is a 400 with {"status":"error","message":"user_id and a complete subscription are required"} and nothing is stored: an endpoint with no keys is unusable, the payload cannot be encrypted, and storing it would show up as a permanently failing recipient rather than as a bad integration. The request's User-Agent header is captured and stored alongside.

Unsubscribing needs only the endpoint:

Unsubscribing
curl -X POST https://in.segmentic.net/v1/webpush/unsubscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "https://fcm.googleapis.com/fcm/send/abc123"}'
JSON
{"status":"ok"}

No user_id is required and none is checked, and the reason is explicit: the endpoint is the subscription's own secret, possession of it is already sufficient to send to that browser, so demanding more before allowing somebody to stop receiving would be protecting the wrong direction.

The web push channel today needs a device row as well as a subscription. The dispatcher consults the device registry for the push and webpush channels, and when it finds nothing it suppresses the message with reason not_reachable before the web push sender is ever called. Browser subscriptions live in a separate table (webpush_subscriptions), so a visitor who called only POST /v1/webpush/subscribe counts as unreachable today. The check is unconditional: it runs on a deployment with no device store wired at all, and there every push and every web push is suppressed here without exception.

The working instruction for today is to register the same browser as a device as well, with platform: "web" and the endpoint as the webpush token:

The same browser, as a device row
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "browser-9f31",
    "user_id": "u_9137",
    "platform": "web",
    "tokens": { "webpush": "https://fcm.googleapis.com/fcm/send/abc123" }
  }'
JSON
{"status":"ok"}

Write this with your eyes open: no test covers the web push channel through the dispatcher. The web push sender is tested directly, the full campaign path is not.

The rest of the web surface, including the service worker, why it has to be served from the origin root, and where the VAPID public key comes from, is in the web SDK.

#Messengers: Bale, Eitaa, Rubika

A chat id is not an address, it is a consent signal. None of these three platforms lets a bot message somebody who has not started the conversation themselves. That makes the id closer to a double opt-in than to a phone number taken off an order form.

They matter for a simple reason: a large part of the Iranian audience is reachable on them and nowhere else. Push needs working Play Services or an app install, SMS costs money per message and is capped by the operator, and email is barely used by Iranian consumers.

Linking a chat id
curl -X POST https://in.segmentic.net/v1/messenger/link \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_9137",
    "platform": "bale",
    "chat_id": "44120099",
    "source": "bot_start"
  }'
JSON
{"status":"ok"}
FieldTypeRequiredDefault
user_idstringyes
platformstringyes, one of bale, eitaa, rubika
chat_idstringyes
usernamestringnoStored, and never erased by a later link
sourcestringnobot_start

source records how the id was obtained. It defaults to bot_start, the only route that carries real consent; anything else is worth being able to find later. A user who typed /start into the bot is a different consent story from one whose id arrived in a CSV.

Unlinking needs only user_id and platform:

Unlinking
curl -X POST https://in.segmentic.net/v1/messenger/unlink \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id": "u_9137", "platform": "eitaa"}'
JSON
{"status":"ok"}

Rejections: 400 with {"status":"error","message":"user_id, chat_id and a known platform are required"} on link, and {"status":"error","message":"user_id and a known platform are required"} on unlink. A platform such as telegram is refused here; the column carries a CHECK, and an unrecognised value would fail in Postgres with an error nobody can act on.

Linking again is the only way a block is cleared: blocked_at and blocked_reason are set to NULL and nothing else clears them. An automatic retry that decided a block had lapsed would be messaging somebody who left.

There is no bot-update webhook. The webhook intake accepts only the integration sources (Digikala, Basalam, Torob, ZarinPal, WooCommerce, Shopify, Segment) and none of them is a messenger. So you run the bot yourself: your bot receives /start, your backend maps the chat id to your own user id, and your backend calls POST /v1/messenger/link.

#What happens when a campaign targets push

The order of the steps is itself a decision.

Governance first, devices second. Consent, frequency caps, quiet hours and holdout assignment all run before the device registry is touched. A person who opted out must not have their profile read, their devices listed, or their content rendered.

The registry is queried only for channels that address an install rather than a person, which is push and webpush. SMS and email find their recipient from the profile, so having no device is meaningless for them.

The delivery query is this:

SQL
SELECT d.user_id, d.device_id, d.platform, d.has_gms,
       d.app_version, d.locale, d.timezone, d.last_seen_at,
       t.transport, t.token
FROM devices d
JOIN device_tokens t
  ON t.tenant_id = d.tenant_id AND t.device_id = d.device_id AND t.retired_at IS NULL
WHERE d.tenant_id = $1
  AND d.user_id = ANY($2)
  AND d.revoked_at IS NULL
  AND d.push_enabled
  AND d.last_seen_at >= $3
ORDER BY d.user_id, d.last_seen_at DESC, d.device_id

So a device is invisible to a campaign if it holds no live token, it was revoked, its push_enabled is false, it has not been seen for 180 days, it falls beyond the fifth-newest install, or it has no user_id at all.

Having no device is not a failure. The outcome is suppressed with reason not_reachable. The person exists and is willing; we simply have no way to reach them, and saying so is what makes a reach report actionable rather than mysterious.

One person receives one notification. The devices are walked newest first, stopping at the first that accepts. Sending to every install would mean a user who has upgraded their phone twice gets the same message three times, which reads as spam whatever the content says.

For each device the router picks a transport. The preference order:

PlatformTried in this order
androidfcm, then bazaar, then myket, then huawei, then mqtt
iosapns, then mqtt
webwebpush

FCM is first because when Play Services works it is the fastest and cheapest route. Bazaar and Myket come next because they cover precisely the devices FCM cannot. mqtt is last and has no provider today, so it is always skipped.

That list is filtered by three things: the device actually holds a token for that transport, a provider is configured for it, and, for FCM, the device did not report has_gms: false. Zero routes gives a rejected result with the text push: no usable transport for this device.

Each route's answer decides what happens next:

Provider resultWhat happens
sentReturns, and success is recorded on the breaker
invalid_tokenThe next route is tried, and that token is retired
unavailable or rate_limitedThe next route is tried, and the failure counts against the breaker
rejectedReturns immediately. A rejected payload will be rejected everywhere, and trying another transport only wastes quota

A dead token on one transport says nothing about the others, and falling through to the next route is the case that recovers the audience FCM cannot reach.

A dead token is retired, and the last one revokes the install. When a provider says the token is invalid, that token gets retired_at. If the device then has no live token left, the device itself is revoked with revoked_reason = 'uninstall_detected'. This is the only realistic uninstall signal there is: an app cannot call unregister while it is being removed, so without it every tenant's install base only ever grew. Retired rather than deleted, so that the same token coming back on the next registration is recognised as a reinstall.

The circuit breaker. 10 consecutive failures open a transport for 30 seconds. If every route was skipped as unavailable, the result is unavailable with the text every transport was unavailable.

Every push carries the attribution identifiers, and where they ride differs per transport:

TransportWhere the identifiers ride
fcmIn message.data: sg_mid, sg_cid, sg_jid, sg_link, sg_t
bazaar, myket and huaweiIn data: sg_mid, sg_link, sg_t. No campaign or journey id. Huawei takes this map as a JSON string rather than an object
apnsAt the top level of the payload, next to aps: sg_mid, sg_link, sg_t
webpushInside the encrypted JSON: mid, tkn, url

The identifiers also ride on the link itself, which is why push click tracking needs no redirect service of ours: the person lands on the customer's own site, where the SDK is already present. The customer's own UTM parameters go on the same link.

#Reading back a profile's devices

This is a card on the panel's profile screen, not an endpoint your API key can reach. The route is GET /v1/profiles/{user_id}/devices and it is registered only on the dashboard control plane, a listener that is deliberately not addressable from outside the internal network. The same path on https://api.segmentic.net falls to the catch-all and answers 404 unknown_endpoint.

The panel calls it with your session cookie, through its own proxy:

HTTP
GET /api/proxy/v1/profiles/u_9137/devices
JSON
{
  "devices": [
    {
      "platform": "android",
      "transports": "bazaar,fcm",
      "app_version": "3.4.0",
      "model": "Xiaomi Redmi Note 12",
      "timezone": "Asia/Tehran",
      "push_enabled": true,
      "revoked": false,
      "last_seen": "2026-08-05T09:14:22Z"
    }
  ]
}

The permission is profile.read. Somebody who may look at a person's traits and event history is already reading the more sensitive half.

FieldTypeNotes
platformstring
transportsstringComma separated. One install with two tokens is one phone, not two. Retired tokens are listed here too, so a transport in this string is not necessarily a live route
app_versionstringOmitted when empty
modelstringOmitted when empty
timezonestringOmitted when empty
push_enabledboolean
revokedboolean
last_seenstringRFC3339 in UTC

The ordering is last_seen_at DESC with a limit of 50 rows. Revoked installs are included on purpose: "you uninstalled the app on the 3rd" is the answer to «چرا پوش نمی‌گیرم؟», and hiding the row leaves the question unanswerable.

Errors: 400 with user_id is required, and 503 when the query fails.

A right-to-be-forgotten request deletes device_tokens first and then the devices row, because the tokens hang off device_id and would otherwise be pushed to for ever. The detail is in privacy.

#What does not exist

Written out plainly, because an honest gap is cheaper than a plausible sentence that costs you an afternoon.

  • Any management-host (sk_seg_...) device route at all, reads included. GET /v1/profiles/{user_id}/devices is registered only on the dashboard control plane and is not routed publicly, so an API key cannot list one person's devices.
  • Any MCP tool relating to devices or push.
  • An MQTT provider. The constant exists, registration is accepted, nothing is ever sent.
  • A batch device-registration endpoint. One request, one device.
  • Any GET or DELETE form of these endpoints. They are all POST with a JSON body.
  • A default route order for windows, macos and linux. They register and they do not receive.
  • Any lookup of devices by anonymous_id. An anonymous install is stored and targeted by no campaign.
  • A bot-update webhook that would link a Bale, Eitaa or Rubika chat id automatically.
  • Any validation of FCM, Bazaar or Myket token shape beyond non-empty and no longer than 4096 bytes.
  • The apns-topic header on APNs requests, and with it apns-push-type, apns-expiration, apns-priority and apns-collapse-id. Apple requires apns-topic for token-based authentication, so this is a real gap rather than a documentation nuance. In practice it means ttl, collapse_key and priority are honoured on FCM and ignored on APNs.
  • Per-tenant APNs credentials. The channel catalogue carries only fcm, bazaar, myket and huawei under push, so iOS push runs on the deployment's own APNs key for every tenant on it.
  • A verified Bazaar or Myket send with a real store-issued token. The token mapping is accepted but was never exercised with a real one, because both need a developer account and a published app.
  • A dispatcher-level test of the web push channel.
PreviousAndroid SDKNextServer to server

On this page

  • Two paths, and which one you need
  • The Android SDK path
  • Registering a device with POST /v1/devices
  • Every field of the body
  • Platforms and the transports that reach them
  • Token shapes, per transport
  • Notification permission and Play Services: both tri-state
  • Warnings
  • Rejections and status codes
  • The same device again, the same token elsewhere
  • Sign-out and uninstall
  • The browser equivalent: a web push subscription
  • Messengers: Bale, Eitaa, Rubika
  • What happens when a campaign targets push
  • Reading back a profile's devices
  • What does not exist

Segmentic

This page is written from the code