Registering a device for mobile push
Register device tokens for mobile push, browser push and messengers.
Two paths, and which one you need
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.
| Path | Who it is for | What you fill in yourself |
|---|---|---|
| The Android SDK | An Android app | The token, and has_gms if you want to |
POST /v1/devices | iOS, web, desktop, a server-side integration, and any app that will not add a dependency | Every 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:
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:
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".
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:
{"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: a4xxcame 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.
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:
{"status":"ok"}
iPhone is the same endpoint with a different platform and a different route:
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"
}'
{"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 assendBeaconthat 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
| Field | Type | Required | Behaviour and default |
|---|---|---|---|
device_id | string | yes | Trimmed. Empty, or longer than 256 bytes, rejects the whole request |
platform | string | yes | Parsed case-insensitively and through an alias table. See platforms |
user_id | string | one of the two is required | Trimmed, then truncated at 256 bytes |
anonymous_id | string | one of the two is required | Trimmed, then truncated at 256 bytes |
tokens | object, transport name to token | no, but see the "nothing to store" rule | Keys lower-cased and trimmed, values trimmed. Each token at most 4096 bytes |
push_provider | string | no | The legacy single-route form, paired with push_token |
push_token | string | no | The legacy single-route form |
has_gms | boolean or null | no | Tri-state. Absent means unknown, which is not false |
push_enabled | boolean or null | no | Tri-state. Absent means unknown, which is read as allowed |
app_version | string | no | Trimmed, truncated at 256 bytes |
manufacturer | string | no | Trimmed, truncated at 256 bytes |
model | string | no | Trimmed, truncated at 256 bytes |
os_name | string | no | Trimmed, truncated at 256 bytes |
os_version | string | no | Trimmed, truncated at 256 bytes |
locale | string | no | Trimmed, truncated at 256 bytes |
timezone | string | no | Trimmed, truncated at 256 bytes. This is what makes "send at 9am" and quiet hours mean the recipient's own clock |
sdk_name | string | no | Trimmed, truncated at 256 bytes |
sdk_version | string | no | Trimmed, 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:
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"
}'
{"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_enabledabsent ortrue: a400withdevice: registration carries no usable token. The call achieved nothing, so storing it would only grow the table. - No token and
push_enabled: false: a200, 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:
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
}'
{"status":"ok"}
Platforms and the transports that reach them
platform is required and has no default. Leaving it out is a 400.
| Platform | Accepted spellings | Transports accepted at registration | Can a campaign actually deliver? |
|---|---|---|---|
android | android | fcm, bazaar, myket, huawei, mqtt | Yes, over fcm, bazaar, myket or huawei |
ios | ios, iphone, ipad | apns, mqtt | Yes, over apns |
web | web, browser, webapp | webpush | Yes, over webpush |
windows | windows, win, win32, win64 | webpush | No. It has no default route order |
macos | macos, mac, mac os, osx, darwin | webpush | No. It has no default route order |
linux | linux | webpush | No. It has no default route order |
server | server, backend, api | none | Registration 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 atransport_not_supportedwarning, 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
| Transport | What to send | Server-side cleaning |
|---|---|---|
fcm | The string onNewToken gave you | Trimmed only |
apns | Lower-case hex | Trimmed, leading and trailing < and > stripped, all spaces removed, lower-cased |
bazaar | The token Cafe Bazaar's push service gave you | Trimmed only |
myket | The token Myket's push service gave you | Trimmed only |
huawei | The token HMS Push Kit gave you | Trimmed only |
webpush | The browser subscription's endpoint URL | Trimmed only |
mqtt | Accepted, never delivered | Trimmed 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:
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 JSON | Meaning | Effect on targeting |
|---|---|---|
Field absent, or null | Unknown; the SDK is too old to report it | Read as allowed |
true | The user granted the permission | Allowed |
false | The user switched notifications off in settings | Excluded 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 JSON | Meaning | Effect |
|---|---|---|
Absent or null | The SDK did not look | The router assumes it is probably there and still tries FCM |
true | Play Services looked healthy | FCM is first choice |
false | No Play Services | FCM 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.
code | field | When | What happens to the token |
|---|---|---|---|
empty_token | the transport name | The token was empty or whitespace only | Dropped |
token_too_long | the transport name | The token was longer than 4096 bytes | Dropped |
transport_not_supported | the transport name | The transport cannot physically reach that platform, or the transport name is unknown entirely | Dropped |
fcm_without_gms | fcm | An FCM token on a device that reported has_gms: false | Kept |
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:
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:
{"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.
| Status | message | When |
|---|---|---|
400 | device: device_id is required | device_id missing, blank after trimming, or longer than 256 bytes |
400 | device: platform must be one of android, ios, web, windows, macos, linux | platform missing, unknown, or resolving to server |
400 | device: user_id or anonymous_id is required | Both are blank |
400 | device: registration carries no usable token | No usable token is left and notifications were not explicitly declared off |
400 | malformed JSON | The body is not JSON |
401 | missing write key | No key in a header or the query string |
401 | invalid write key | Unknown, revoked or suspended key |
413 | request body too large | The body exceeded 5 MiB |
503 | cannot verify the write key right now; retry | The key lookup itself failed. Carries Retry-After: 5 |
503 | temporarily unavailable, please retry | Writing 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:
{"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_idlonger than256bytes reportsdevice_id is required, even though you sent one. The message is misleading. POST /v1/devices/unregisteranswers a malformed body with that samedevice_id is required. Your broken JSON is reported as a missing id.- A
405on/v1/devicesin staging is a configuration fact, not a problem with your payload. With no device store wired the route is never registered, and theOPTIONS /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:
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:
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.
| Field | Type | Required | Meaning |
|---|---|---|---|
device_id | string | yes | The install to act on |
user_id | string | no | When present, detaches only if that user is currently attached |
revoked | boolean | no, default false | false is sign-out, true marks the install as 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", "user_id": "u_123"}'
{"status":"ok"}
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}'
{"status":"ok"}
This is exactly what happens in the database. Sign-out:
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:
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.
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"
}
}'
{"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.
| Field | Type | Required | Meaning |
|---|---|---|---|
user_id | string | yes | The subscription is stored against a person |
endpoint | string | yes | The 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 |
p256dh | string | yes | The browser's public key, base64url, an uncompressed P-256 point |
auth | string | yes | A 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:
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"}'
{"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:
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" }
}'
{"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.
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"
}'
{"status":"ok"}
| Field | Type | Required | Default |
|---|---|---|---|
user_id | string | yes | |
platform | string | yes, one of bale, eitaa, rubika | |
chat_id | string | yes | |
username | string | no | Stored, and never erased by a later link |
source | string | no | bot_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:
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"}'
{"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:
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:
| Platform | Tried in this order |
|---|---|
android | fcm, then bazaar, then myket, then huawei, then mqtt |
ios | apns, then mqtt |
web | webpush |
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 result | What happens |
|---|---|
sent | Returns, and success is recorded on the breaker |
invalid_token | The next route is tried, and that token is retired |
unavailable or rate_limited | The next route is tried, and the failure counts against the breaker |
rejected | Returns 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:
| Transport | Where the identifiers ride |
|---|---|
fcm | In message.data: sg_mid, sg_cid, sg_jid, sg_link, sg_t |
bazaar, myket and huawei | In data: sg_mid, sg_link, sg_t. No campaign or journey id. Huawei takes this map as a JSON string rather than an object |
apns | At the top level of the payload, next to aps: sg_mid, sg_link, sg_t |
webpush | Inside 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:
GET /api/proxy/v1/profiles/u_9137/devices
{
"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.
| Field | Type | Notes |
|---|---|---|
platform | string | |
transports | string | Comma 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_version | string | Omitted when empty |
model | string | Omitted when empty |
timezone | string | Omitted when empty |
push_enabled | boolean | |
revoked | boolean | |
last_seen | string | RFC3339 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}/devicesis 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
GETorDELETEform of these endpoints. They are allPOSTwith a JSON body. - A default route order for
windows,macosandlinux. 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
4096bytes. - The
apns-topicheader on APNs requests, and with itapns-push-type,apns-expiration,apns-priorityandapns-collapse-id. Apple requiresapns-topicfor token-based authentication, so this is a real gap rather than a documentation nuance. In practice it meansttl,collapse_keyandpriorityare honoured on FCM and ignored on APNs. - Per-tenant APNs credentials. The channel catalogue carries only
fcm,bazaar,myketandhuaweiunderpush, 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.