In-app messages and the inbox
Banners, modals and surveys inside a site or an app, and the inbox messages persist in.
What an on-site message is
The display rules are not evaluated on our server. They are evaluated in the visitor's browser.
The reason is arithmetic. If every page view makes a request to us, a mid-sized Iranian shop sends us a million requests a day, on the critical rendering path of their own site, with our latency in front of their content and our availability in front of their business. Instead the SDK fetches the list of live campaigns once a minute and matches locally.
That design has one cost and you need to know it: the targeting rules are public. Anybody can open the Network tab and read who you are targeting and on what condition. Nothing secret may go inside a targeting rule.
Three endpoints on the collector, with the write key wk_seg_...:
| Method and path | What it does |
|---|---|
GET /v1/onsite | The list of live campaigns |
POST /v1/onsite/event | Records an impression, click, dismissal or conversion |
POST /v1/onsite/response | Records a survey answer |
All three are registered only when your installation has an on-site store. If it does not, the answer is 405 Method Not Allowed rather than 404, because the OPTIONS /v1/ pattern registered for CORS claims the whole /v1/ prefix.
The write key is public and sits in your site's own code. The management key, prefixed sk_seg_, is for https://api.segmentic.net and must never reach a browser. Displaying a campaign uses the write key. Creating and publishing one uses neither: those routes are on the control plane and are reached in the panel, signed in as a person, as Building and publishing a campaign sets out.
Fetching the campaign list
GET https://in.segmentic.net/v1/onsite. It takes no parameter other than the credential.
The key is read from three places, in this order: the Authorization: Bearer header, the X-Segmentic-Key header, or the write_key query parameter. The web SDK uses the query parameter for this GET, because that makes it a simple request with no preflight, and a CDN that will not vary on Authorization can cache the response.
curl -s "https://in.segmentic.net/v1/onsite?write_key=wk_seg_..."
{
"campaigns": [
{
"id": 7,
"name": "تخفیف عید",
"kind": "modal",
"status": "live",
"content": {
"headline": "۱۵ درصد تخفیف تا پایان هفته",
"body": "کد را در سبد خرید وارد کنید.",
"button_text": "دیدن محصولات",
"button_url": "/products",
"accent": "#2563eb"
},
"targeting": {
"url_contains": ["/products"],
"devices": ["desktop"],
"delay_seconds": 5
},
"max_impressions": 3,
"cooldown_hours": 24,
"dismissible": true,
"starts_at": "2026-08-01T00:00:00Z",
"ends_at": "2026-08-14T00:00:00Z",
"impressions": 1842,
"clicks": 96,
"dismissals": 311,
"created_by": "ملیکا",
"created_at": "2026-07-28T09:12:44Z",
"updated_at": "2026-07-30T11:02:10Z"
}
],
"cache_seconds": 60
}
The response carries Cache-Control: public, max-age=60, and cache_seconds repeats the same number so the browser applies the same frequency-cap arithmetic from local storage without asking. The list is filtered server side on status = live and on the date window.
If our store fails the answer is still 200, with {"campaigns": []} and no cache_seconds and no cache header. This code runs inside the customer's page load, so a failure of ours must degrade to "no banner today" and never to a console error on their site.
Authentication failures:
| Case | Status | Body |
|---|---|---|
| No key sent | 401 | {"status":"error","message":"missing write key"} |
| Unknown, revoked or suspended key | 401 | {"status":"error","message":"invalid write key"} |
| The key lookup itself failed | 503 with Retry-After: 5 | {"status":"error","message":"cannot verify the write key right now; retry"} |
The third row is deliberate. An SDK reads 401 as "this key will never work", stops, and throws the events away; it reads 503 as "try again later" and keeps them.
CORS on all three endpoints: Access-Control-Allow-Origin: *, methods POST, OPTIONS, headers Content-Type, Authorization, X-Segmentic-Key, and Access-Control-Max-Age: 86400.
The campaign on the wire
| Field | Type | Notes |
|---|---|---|
id | int64 | |
name | string | The internal name, shown in the panel |
kind | string | banner, modal, slidein or survey |
status | string | draft, live, paused or ended |
content | object | Below |
targeting | object | See Evaluating the rules |
max_impressions | int | Zero means uncapped |
cooldown_hours | int | Zero means no cooldown |
dismissible | bool | |
starts_at | RFC3339, optional | |
ends_at | RFC3339, optional | |
impressions | int64 | Always present |
clicks | int64 | Always present |
dismissals | int64 | Always present |
created_by | string, optional | The display name of whoever saved it, or apikey:<id> |
created_at | time, optional | |
updated_at | time, optional |
The content fields, all optional: headline, body, image_url, button_text, button_url, position, background, text_color, accent, question, nps (bool), follow_up, choices (array of string), thank_you.
position means top or bottom for a banner, and which corner for a slide-in.
What this response discloses
The response is the whole campaign structure, not a trimmed version of it. That means impressions, clicks, dismissals, created_by, created_at and name are in it, and any visitor to your site can read them in the Network tab. The only thing removed is the account identifier.
If having a colleague's real name sit in created_by matters to you, the account that saves the campaign needs a different display name. There is no way to suppress these fields from the response.
Evaluating the rules
Three gates, in this order: the time window, the rule match, the frequency cap. A campaign is eligible when all three pass.
The targeting fields, all optional:
| Field | Meaning |
|---|---|
url_contains | Array of strings. Passes if any of them is a substring of the page URL |
url_not_contains | Array of strings. Fails if any of them is in the URL |
devices | desktop, mobile, tablet. Empty means all |
delay_seconds | int, clamped to 0 to 120 on save |
scroll_percent | int, clamped to 0 to 100 on save |
on_exit_intent | bool |
new_visitors_only | bool. Fails if the visitor is returning |
returning_only | bool. Fails if the visitor is not returning |
logged_in | Optional bool. Absent means either state |
traits | Map of string to string. Every key must match exactly |
URL matching is substring matching and never a regular expression. A pattern written by a marketer can be catastrophically slow, and this runs on every page of your site. url_contains and url_not_contains together are capped at twenty rules on save.
traits is compared only against the traits the SDK holds locally at that moment, which is what you gave it with identify(). It is not compared against warehouse segments. If you want to attach an on-site campaign to a segment, that cannot be done from here.
The device class comes from the viewport, not the user agent: under 768 pixels mobile, under 1024 pixels tablet, otherwise desktop.
On Android there are two differences that will leave your campaign never shown if you do not know them. First, there are only two classes: a smallest width of 600 or more is tablet and anything narrower is mobile. So a campaign with devices: ["desktop"] is never eligible on Android. Second, url_contains and url_not_contains are matched on a phone against the screen name, meaning the string the app passes to screen(), and not against a web address.
The frequency cap
The cap is applied both in the browser and on the server, and neither alone is sufficient: local storage alone means anybody who clears it gets an uncapped modal, and the server alone means one request per page view.
The order of the rules is identical on both sides and it matters:
- If the campaign is not live, no.
- If this browser has already converted, never again. This rule outranks every other one, including a campaign that is still running.
- If it was dismissed and the campaign is
dismissible, no. - If
max_impressions > 0and the impression count has reached it, no. - If
cooldown_hours > 0and the last impression was inside that window, no.
A click counts as a conversion for capping. Somebody who followed the link has done the thing, and showing it again asks them to do it twice.
The browser's record of what it has seen is kept in local storage under the key sg_onsite. A corrupt or absent value reads as empty, and a quota error on write is swallowed.
When it appears
scroll_percentabove zero attaches a passivescrolllistener and fires at that percentage.on_exit_intentattaches amouseoutlistener and fires whenclientYreaches zero or less. In practice this is desktop only, because a touch device has no pointer to leave for the tab bar.delay_secondsis a trigger of its own only when neither of the other two is set. Otherwise it would race them and show the message on a timer the marketer meant as a minimum.
Which kinds are actually drawn
Four kinds can be built in the panel. The three SDKs do not draw them alike, and the difference is documented here so nobody spends half a day hunting a bug that does not exist.
| Kind | Web SDK | Android SDK | iOS SDK |
|---|---|---|---|
banner | Drawn | Drawn | Does not exist |
modal | Drawn, with a backdrop | Drawn | Does not exist |
slidein | Drawn, in a corner | Drawn as a banner, with no animation | Does not exist |
survey | Drawn, NPS scale or choice list | Not drawn | Does not exist |
On Android the survey returns false rather than being approximated. The consequence is that the campaign is left uncapped and unreported, so the moment the kind is supported the same user still sees it and the opportunity has not been burnt.
The iOS SDK ships no on-site code at all. Its only endpoints are POST /v1/devices and POST /v1/batch. If you want in-app messages on iOS, you call GET /v1/onsite yourself and draw the widget yourself.
Some web rendering constraints worth knowing:
- The widget never throws. An analytics tool must not be the thing that breaks a customer's checkout.
- Nothing is inherited. Every property is set directly on the element, so only a host page
!importantcan win. - Content is written with
textContent, neverinnerHTML. A headline containing<img src=x onerror=...>renders as text. - The container id is
segmentic-onsitewithz-index: 2147483000, deliberately just below the maximum so the customer's own layer can still sit above. The container ispointer-events: noneso it does not swallow the site's clicks, and it isdirection: rtl. - At most one campaign is shown at a time.
- A button's
hrefis set only when the URL starts withhttp://orhttps://or with a leading/. Anything else, includingjavascript:, leaves the anchor with nohref. - A broken
image_urlremoves the image rather than leaving a broken-image icon. - The close button carries
aria-label="بستن"and sits on the left, because Persian reads right to left. - Colour defaults when the content omits them: background
#1f2937, text#ffffff, accent#2563eb. - The NPS row is forced to
direction: ltrso zero sits on the left and ten on the right, even inside a right-to-left card.
Reporting an interaction
POST https://in.segmentic.net/v1/onsite/event
| Field | Type | Required |
|---|---|---|
campaign_id | int64 | Yes, zero is rejected |
user_id | string | One of the two |
anonymous_id | string | One of the two |
action | string | No, empty means impression |
page_url | string | No |
score | int | Survey only |
answers | Map of string to string | Survey only |
action is lower-cased and must be one of impression, the empty string, click, dismiss or convert. Anything else gets 400 with the message unknown action.
curl -s -X POST https://in.segmentic.net/v1/onsite/event \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"campaign_id":7,"action":"impression","anonymous_id":"a_9f21c4","user_id":"u_9137"}'
{"status":"ok"}
The subject key is "u:" + user_id when there is a user id and "a:" + anonymous_id otherwise. One column and one key, because the cap asks "has this browser seen it" and somebody who signs in mid-session is one browser.
If campaign_id is missing, or neither identifier is present, the answer is 400 with campaign_id and a visitor id are required. A malformed JSON body gets 400 with malformed JSON.
Once validation passes the answer is always 200, even when the store write fails. The error is logged at warn and swallowed. Losing an impression count costs a number on a dashboard; returning an error to a script running inside the customer's page costs them a console error on every page view.
What each action does to the store:
action | Effect |
|---|---|
impression | seen_count increments, last_seen_at is updated, and the campaign's impressions counter increments too |
dismiss | dismissed_at is set (the first one is kept), dismissals increments |
click | converted_at is set and clicks increments |
convert | converted_at is set and no counter increments |
Two things you will meet when you go looking for the numbers:
- These events are not metered and not quota-gated. Unlike
/v1/trackand/v1/batch, neither on-site endpoint counts usage or checks the account's quota. - These events are not published to the event bus. They are written straight into Postgres. They appear in no event stream, no relay and no ClickHouse table, so you cannot build a segment on them with the ordinary event reporting tools. The running totals are on the public campaign list above, which is why any visitor can read them in the Network tab. Everything past a total, the survey results and the individual responses, is only on the control-plane routes in Building and publishing a campaign, which are reached in the panel.
Answering a survey
POST https://in.segmentic.net/v1/onsite/response, with the same body structure as above.
The order of work:
- If
campaign_idis zero or there is no visitor identifier,400withcampaign_id and a visitor id are required. - The campaign is loaded from the store, not trusted from the body: whether this is an NPS survey decides whether
scoremeans anything at all, and the browser is not the authority on that. If the load fails,400withunknown campaign. - Validation, five checks, in the table below. A failure gets
400and the message is that English sentence verbatim. - If the save fails,
503withtemporarily unavailable, please retry. This is the only on-site endpoint that can return a server error. - On success a
convertinteraction is also recorded, best effort. Somebody who told you what they think should not be asked the same question next week. 200with{"status":"ok"}.
Two of the five are not failures at all, which is worth seeing in the same place as the three that are:
| Check | What happens |
|---|---|
the campaign's kind is not survey | 400, onsite: this campaign is not a survey |
neither user_id nor anonymous_id is present | 400, onsite: a response must name a browser or a person |
content.nps is true and score is outside zero to ten, or absent | 400, onsite: an NPS score must be between 0 and 10 |
content.nps is false | score is overwritten with -1, no error |
a value in answers is longer than two thousand runes | truncated, never rejected |
curl -s -X POST https://in.segmentic.net/v1/onsite/response \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"campaign_id":12,"user_id":"u_9137","score":9,"answers":{"reason":"ارسال سریع بود"}}'
{"status":"ok"}
The save is idempotent on the key (account, campaign, user_id, anonymous_id) and replaces score and answers with the new values. Somebody who answers, refreshes and answers again has one opinion. responded_at comes from the server clock, never from the body.
One defect, and two that were fixed
page_url is never populated by any shipped SDK. The field exists and is stored, but neither the web SDK nor the Android SDK sends it. If you need it, you have to send it yourself.
The other two entries in this section were real and are fixed, and they are kept here rather than deleted because both of them corrupted rows that are still in your account.
Multiple-choice and free-text answers from the web SDK were silently discarded. The renderer sent the answer as { score?, choice?, text? } and the client spread that straight into the POST body. The server structure has no choice field and no text field, so encoding/json dropped both and answered 200. For a multiple-choice survey the stored row recorded that somebody had answered and not what they answered. The renderer now sends an answers map, the same shape the Android SDK always sent.
A follow-up free-text submission overwrote the real NPS score with zero. On an NPS survey with follow_up set the web SDK posted twice, once with {"score": 9} and once with {"text": "..."}. The second post carried no score, so Go's zero value was used, validation accepted it because zero is a valid detractor score, and the save on the same key replaced the real score with it. Every follow-up cost the account one detractor. Two things changed: each post now carries the whole answer rather than the increment, and a body that omits score on an NPS survey is refused with 400 instead of being read as zero.
[!warning] Answers recorded before this fix cannot be recovered. A discarded choice was never written down, and an overwritten score replaced the real one in place. If an NPS number from a survey with a follow-up question has been quoted anywhere, it was too low, by one detractor for every person who wrote a sentence. Scores collected since the fix are sound.
Half the fix is in the SDK bundle and half is on the server. A page that pinned an older copy of segmentic.js still sends the old shape, so its multiple-choice and free-text answers are still lost. Its scores are not: the server now refuses a follow-up post that carries no score with 400, so the real score stays where it is instead of being replaced by zero. Serve the current bundle to get the answers back.
The web SDK
On-site is on by default. Installing the SDK with no other configuration draws your published campaigns on your site.
import segmentic from "@segmentic/web";
const client = segmentic.init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net",
});
segmentic.identify("u_9137", { city: "تهران", plan: "gold" });
What happens behind that: the list is fetched once during init() and one campaign is drawn, then the list is refetched every sixty seconds to stay in step with the server's cache header. If the user has opted out with optOut(), nothing is fetched and nothing is drawn. A network error is swallowed.
Reports do not go through the batching queue: both posts go directly, with keepalive: true. An impression must not sit behind a batch waiting for nineteen more messages, on a page the visitor is about to leave.
After a client-side navigation, in a single-page application for instance, you re-run the evaluation yourself:
client.refreshOnsite();
Drawing your own widget
If you have your own design system, turn our rendering off and keep only the eligibility and capping logic:
import segmentic, { eligible, deviceOf, readSeen } from "@segmentic/web";
const client = segmentic.init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net",
onsite: false,
});
// Once the list has been fetched at least once.
const visitor = {
url: location.href,
device: deviceOf(window.innerWidth),
loggedIn: segmentic.getUserId() !== null,
returning: true,
traits: { plan: "gold" },
now: Date.now(),
};
const [campaign] = eligible(client.onsiteCampaigns(), visitor, readSeen(localStorage));
if (campaign) {
drawYourOwnWidget(campaign);
}
onsiteCampaigns() and refreshOnsite() are instance methods on the client and are not on the module's default object. segmentic.onsiteCampaigns() does not exist; you have to keep the instance that init() returns. The functions eligible, matches, maySee, isLive, deviceOf, readSeen, writeSeen, recordSeen and recordAction are exported from the module itself.
With onsite: false nothing is reported automatically either. You call POST /v1/onsite/event yourself for impressions, clicks and dismissals, or the server-side cap never fills and the campaign's numbers stay at zero.
Building and publishing a campaign
These six routes are on the control plane, which is the API the panel talks to. There is no on-site management route on the management host.
The control plane is not routed from the internet. In the reference deployment, api.segmentic.net serves the management API on its own listener and the control plane's listener is deliberately not published. The same paths on https://api.segmentic.net fall to the catch-all and answer 404 unknown_endpoint. The only public path to the routes in this table is the panel's own server-side proxy at https://app.segmentic.net/api/proxy/v1/..., which authenticates with the signed-in user's session cookie and answers 401 without one. An sk_seg_ key does not reach it. Building and publishing an on-site campaign is therefore something a person does in the panel.
| Method and path | Permission |
|---|---|
GET /v1/onsite/campaigns | campaign.read |
GET /v1/onsite/campaigns/{id} | campaign.read |
PUT /v1/onsite/campaigns | campaign.write |
POST /v1/onsite/campaigns/{id}/status | campaign.send |
GET /v1/onsite/campaigns/{id}/results | campaign.read |
GET /v1/onsite/campaigns/{id}/responses | profile.read |
Two of those permissions carry a reason. Taking a campaign live puts a banner on your website in front of every visitor immediately, with no send to schedule and no audience to review, so it is the same act as sending. And a free-text survey answer is somebody's own words and regularly contains their phone number, so reading the raw answers takes the same permission as every other route to a named individual.
Saving never publishes. If you put status as live in the PUT body it is rewritten to draft. An empty status becomes draft too.
PUT /api/proxy/v1/onsite/campaigns
Content-Type: application/json
{
"campaign": {
"name": "ارسال رایگان مرداد",
"kind": "banner",
"content": {
"headline": "ارسال رایگان تا پایان مرداد",
"button_text": "خرید",
"button_url": "/products",
"position": "top"
},
"targeting": { "url_not_contains": ["/checkout"], "delay_seconds": 3 },
"max_impressions": 3,
"cooldown_hours": 24,
"dismissible": true
}
}
POST /api/proxy/v1/onsite/campaigns/7/status
Content-Type: application/json
{"status":"live"}
{"id":7,"status":"live"}
status accepts only draft, live, paused or ended; anything else gets 400.
Validation failures on PUT, all 400 with a Persian message:
| Condition | When |
|---|---|
| A name is required | name is empty after trimming |
| Unknown kind | kind is not one of the four |
| Content is required | A non-survey campaign with both headline and body empty |
| A question is required | A survey with no question |
| Invalid link | button_url or image_url does not start with http://, https:// or / |
| Both audiences at once | new_visitors_only and returning_only both true |
| Too many rules | More than twenty URL rules |
| Too many choices | A survey with more than eight choices |
Values corrected automatically on save:
max_impressionsat or below zero becomes3.- A negative
cooldown_hoursbecomes24. Note that an explicit zero is left alone and means no cooldown. kindofmodalforcesdismissibletrue. A modal that cannot be dismissed is not a message, it is a hostage situation.- An NPS survey loses its
choiceslist. - A button with text but no URL, or a URL but no text, has the surviving half deleted.
javascript:anddata:are refused. That link is rendered into the customer's own page and would run in their origin with their cookies.
GET /v1/onsite/campaigns returns at most a hundred campaigns as {"campaigns": [...], "kinds": [...]}. Each campaign carries kind_label, status_label and ctr on top of the ordinary fields. ctr is clicks / impressions * 100, and zero impressions gives zero rather than a division error.
The NPS result
GET /api/proxy/v1/onsite/campaigns/12/results
{
"nps": {
"responses": 128,
"promoters": 61,
"passives": 40,
"detractors": 27,
"score": 26.5625,
"reliable": true
},
"min_reliable": 50
}
The arithmetic is percent promoters minus percent detractors. Passives are in the denominator and nowhere else, which is the part every reimplementation gets wrong: dropping them inflates the score.
The buckets are fixed and not configurable: nine and ten promoter, seven and eight passive, zero to six detractor. NPS is only worth quoting because it means the same thing everywhere.
reliable is false below fifty responses. NPS from eleven responses swings by twenty points on one more answer, and a figure quoted in a board meeting should not do that.
The raw answers:
GET /api/proxy/v1/onsite/campaigns/12/responses?limit=200
limit is honoured between 1 and 500, and anything outside that, including an unreadable value or none at all, becomes 100. Answers are ordered newest first. There is no pagination: no cursor and no offset.
The inbox
The inbox is the other place campaign messages live, waiting until the app next opens. It has two endpoints on the collector and both are POSTs:
POST /v1/inbox
POST /v1/inbox/ack
POST and not GET, for two reasons. The user id and its proof belong in a body rather than in a query string that every proxy, browser history and access log keeps a copy of. And fetching the inbox has a side effect, the rows come back marked delivered, which is not something a GET is allowed to do.
Two-step authentication
This is the one place where the write key alone is not enough. The inbox is the platform's first read endpoint, its rows contain the message body and a personalised discount code, and "give me the inbox of user 91372" behind a key anyone can read out of the page source is not an endpoint that can exist.
So two separate checks run:
- The write key resolves the account. It is public and says nothing about who is asking.
user_hashproves that your own server authenticated this person.
The formula, which your server computes at sign-in:
user_hash = hex(hmac_sha256(identity_secret, user_id))
The comparison is constant time, and the supplied value is trimmed and lower-cased. If the account has no identity_secret, or the user id is empty, or the proof is empty, the answer is false.
There is no way to create the identity_secret from the panel or from the API. The only writer in the whole tree is the adminctl command line tool, which generates thirty-two random bytes, base64url encodes them, and prints the formula above. That means a customer cannot self-serve the inbox and has to ask us. Rotating the secret invalidates every hash already issued, signing the whole app out of its inbox until the customer redeploys.
Error responses:
| Case | Status | Body |
|---|---|---|
user_id missing | 400 | {"status":"error","message":"user_id is required"} |
Wrong hash, missing hash, or an account with no identity_secret at all | 403 | {"status":"error","message":"user identity is not verified"} |
403 rather than 404, and the same answer in both cases, so the endpoint cannot be used as an oracle for which user ids exist.
curl -s -X POST https://in.segmentic.net/v1/inbox \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"u_9137","user_hash":"3f2a1c...","limit":25}'
{
"status": "ok",
"messages": [
{
"message_id": "c104.u_9137",
"title": "سفارش شما ارسال شد",
"body": "کد رهگیری در اپلیکیشن قابل مشاهده است.",
"image": "https://cdn.example.ir/box.png",
"deeplink": "myapp://orders/104",
"surface": "inbox",
"token": "1.42.k1.oi.mfx2b1.QkNERUZHSElK",
"created_at": "2026-08-06T08:11:00Z",
"expires_at": "2026-09-05T08:11:00Z",
"seen": false
}
]
}
messages is always an array and never null. If the store fails, the answer is 503 with {"status":"error","messages":null,"message":"temporarily unavailable, please retry"}.
A limit at or below zero, or above 25, becomes 25. Only rows that have not been dismissed and have not expired come back, newest first. The same statement sets delivered_at, because that is the moment that can be proved: the bytes left the server. Whether the person then looked is seen_at, a different and weaker claim.
token is what proves that an open reported back belongs to a message we really sent. Without it every in-app open is an unsigned claim, which the ledger stores but does not count.
Acknowledging what was seen or dismissed:
curl -s -X POST https://in.segmentic.net/v1/inbox/ack \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"u_9137","user_hash":"3f2a1c...","seen":["c104.u_9137"],"dismissed":["c99.u_9137"]}'
{"status":"ok"}
seen keeps the first sighting and is not overwritten, so a message re-rendered on every app open does not report its open time as "just now" for ever. dismissed removes the row from later fetches. An empty array is not an error and does nothing. A failure of either gets 503.
seen is not an engagement signal. For an in-app open to be counted, the app sends a message_opened event through the ordinary event path, carrying context.campaign.message_id and context.campaign.token from the inbox entry. A second, trusted path for the same signal would be trusted on the word of a caller holding nothing but a public write key.
What your app has to build
No shipped SDK has an inbox client. Not web, not Android, not iOS. What you write:
- Computing
user_hashon your own server at sign-in. Theidentity_secretmust never reach a browser or an app binary. - Calling
POST /v1/inboxand holding the result. - The entire interface: the list, read and unread state, the dismissal gesture, and opening the
deeplink. - Sending
message_openedwith themessage_idand thetokenso that opens are countable. - Pagination. There is none. Twenty-five rows per call, no cursor, no offset. If a user has a hundred messages you see only the twenty-five newest until the rest are dismissed or expire.
A message's default lifetime is thirty days unless the campaign set another one.
What is not there today
An honest list of things a customer expects and that do not exist today:
- On-site on iOS. There is no code at all.
- Surveys on Android. The renderer returns
false. - A slide-in animation on Android. It is drawn as a banner.
- Attaching on-site targeting to a segment.
traitsis compared only against the SDK's local traits. - Inbox pagination and an inbox client in any SDK.
- Creating an
identity_secretwithout us. Command line only. - Seeing on-site interactions in event-based reports. They are not published to the bus.
- Suppressing the counters and
created_byfrom the publicGET /v1/onsiteresponse. - Populating
page_urlfrom any SDK. The column is there and nothing writes it. See One defect, and two that were fixed.
If one of these blocks you, say so. It is written down here so it is not discovered after an afternoon.