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

Designing events: what to send and what to call it

Naming rules, choosing properties and building a stable tracking plan for reports and segments.

An event is something a person did. It has a name, a moment, and a few properties that were true at that moment. Everything you build later, every segment, every campaign, every funnel, stands on those three things, and some of the decisions cannot be taken back. This page says exactly what the code does with what you send. If you are after the practical procedure instead, where to put the call in your own code and how to check that it arrived, that is placing events.

EVENT PRODUCERS
Web SDKBrowser behavior
Mobile SDKApp behavior
Server SDKTrusted events
SEGMENTICEvent pipelineValidate, normalize and resolve identity
LIVE CUSTOMER STATE
ProfilesTraits and history
SegmentsLive membership
Journey triggersReal-time entry
How events move from web, mobile and server SDKs into profiles, segments and journey triggers

#What an event is, and is not

An event is not a state. "This user's wallet holds 120,000 toman" is not an event; it is a trait, and it goes through identify. "This user topped up their wallet by 120,000 toman" is an event. The difference is worked through in a property or a trait, because that is where most of the mistakes happen.

An event does not change after it is written. There is no endpoint to edit or delete one. Three things remove a row: your tenant's retention policy, the events table's own 400-day TTL, and the erasure of one named person. So every decision on this page applies to the data arriving from now on, not to the data that arrived yesterday.

Bot traffic is not thrown away. It is stored with is_bot set to one, and every report filters it out by default. Had we discarded it, nobody could later prove what actually happened that day.

An event must carry at least one of user_id or anonymous_id, or it is rejected with missing_identity. Both are capped at 256 bytes.

#The five message types

There are five message types and each has its own path. The name that gets stored does not always come from you:

PathTypeName stored
POST /v1/tracktrackthe event you sent. Required
POST /v1/pagepagethe event, defaulting to page_viewed
POST /v1/screenscreenthe event, defaulting to screen_viewed
POST /v1/identifyidentifyalways identify. Anything in event is discarded
POST /v1/aliasaliasalways alias. As above
POST /v1/batchany of the fiveeach item carries its own type

On the five single-event paths the route decides the type and the body's type field is ignored. Post {"type":"identify"} to /v1/track and a track event is stored. That is deliberate: otherwise one mistyped line in your code would have an identify payload silently accepted as an event.

Inside /v1/batch the rule is inverted, because the path is one and the items differ: each item's type is read, and an unknown one rejects that item with unknown_type rather than the whole batch.

The traits on an identify are not stored on the events table. The identify event lands there with empty properties, and the trait values go only to the person's profile. If you want an event report to show what changed, send a separate track as well.

One complete event
curl -X POST https://in.segmentic.net/v1/track \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer wk_seg_..." \
  -d '{
    "message_id": "9f1c2b70-3a4d-4f2e-9a1b-0c7d8e5f6a21",
    "user_id": "u_88123",
    "event": "order_completed",
    "timestamp": "2026-08-07T09:14:22.310Z",
    "properties": {
      "order_id": "A-100294",
      "revenue": 2450000,
      "currency": "IRR",
      "item_count": 3,
      "payment_method": "gateway"
    },
    "context": {
      "locale": "fa-IR",
      "timezone": "Asia/Tehran",
      "library": { "name": "segmentic-js", "version": "1.0.0" }
    }
  }'
Response
{"status":"ok","accepted":1}

The write key may arrive as Authorization: Bearer wk_seg_..., as the header X-Segmentic-Key, or as the query parameter ?write_key=. The last exists only for sendBeacon and image beacons, which cannot set headers.

Omit message_id and the server generates one, and says so:

Response when you sent no message_id
{"status":"ok","accepted":1,"warnings":[{"code":"generated_message_id","field":"message_id","note":"no message_id sent; retries of this event cannot be de-duplicated"}]}

That warning is serious. The message_id is the only thing that makes a retry safe: an SDK on an Iranian mobile network will resend, and without a stable id a customer's purchase count silently doubles. De-duplication is per tenant, so two tenants may legitimately use the same message_id.

#The naming rule

This is the whole rule, and it is shorter than you expect. The event name is trimmed, then run through Persian normalisation (see Persian text), and then exactly two things are checked:

RuleLimitReject code
length after normalisation128 bytesevent_name_too_long
Unicode control charactersnone allowedevent_name_invalid_chars
a name on a trackrequiredmissing_event_name

That is all. No lower-casing, no snake-casing, no character class filter. Spaces, slashes, question marks, Persian letters, emoji, mixed case and a whole URL are all legal event names.

Two consequences that catch people out:

  • Order Completed, order_completed and order completed are three separate events, for ever. Nothing merges them later.
  • The limit is 128 bytes, not 128 characters. A Persian letter is two bytes in UTF-8, so a Persian event name may be about 64 letters long.

The convention we recommend, and the one the rest of these docs are written in: lower case ASCII, snake_case, past tense, object before verb. order_completed rather than completeOrder, product_viewed rather than View Product. The reason is not tidiness: the event name in the panel, in the segment builder, in a journey trigger and in a CSV export is byte for byte the string you sent, and a list written in three different styles is a list nobody can pick from.

The server places no limit on the number of distinct names you create. Take that seriously, because the next section is about exactly that.

If the names in your integration are already something else, chk_out_v2 or SUB_RENEW, you do not have to change what your code sends to make the panel readable. The panel keeps a display name per event, set only from Data Management under "Events". It is a display name only: what the SDK sends, what a segment definition refers to and what an export contains never change, so a rename cannot break a saved segment or a funnel. Setting one needs settings.write.

#When the event name is a URL

Never build an event name from a variable. That sounds abstract until you see how it happens.

The web SDK's page(name) names the event after the exact string you hand it. One customer handed it pathname + search. The event name became the whole address, and every referral code and every profile id minted a new event type. The real numbers from that account:

  • 169 distinct URL-shaped names across 5,618 rows.
  • The catalogue reached 168 names in a day: 153 of them URLs and 15 of them real events.
  • "Opened the games page" was spread across 46 of those names: /games/, /games/?ref=GPE9UHTV, /games/?utm_source=ecrm&utm_medium=inapp_banner, /u/69235/, and the rest.
  • All 169 collapse to 23 routes once reduced to their route.

Why this is not merely untidy but loses data: the catalogue (GET /v1/schema/events) returns at most 500 names, ordered by volume descending, over the last 90 days only. A tenant in this state loses its real event names off the end of the list. The marketer cannot build the "bought something" segment, because the event name is not in the picker.

The fix: name the page view after the route, and let the address live where the address belongs. With autoContext on, the web SDK already attaches context.page.url, context.page.path and context.page.search to every message, and the collector stores the first two in the page_url and page_path columns. The exact address is not lost.

Name the route, not the address
import { init, page } from "@segmentic/web";

init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
  // Off, because the automatic page view fires on init and would land
  // beside the route-named one below.
  autoPageView: false,
});

function routeNameOf(pathname) {
  return pathname
    .replace(/\/$/, "")
    .replace(/\/u\/\d+/g, "/u/:id")
    .replace(/\/(\d+)(?=\/|$)/g, "/:id") || "/";
}

// On the first load, and again on every client-side navigation.
page(routeNameOf(location.pathname));

One note about autoPageView, where the SDK's own type says something different. The type comment reads "send a page view on init and on history navigation". The code sends one on init only. The only listeners the web SDK installs are visibilitychange, pagehide and online, and there is no popstate handler and no pushState patch anywhere in the source. So a single-page app must call page() itself on every navigation, or a whole user session records one page view.

If you are already in this state, the script deploy/scripts/rename-url-event-names.sh on the branch chore/rename-the-url-shaped-event-names does it for the rows already on disk. It drops the query string, drops a trailing slash, replaces four named dynamic routes, and then turns any remaining digit segment into :id. It reports by default and acts only with --apply.

That branch is not merged into main, so the script is not present on a standard install. More importantly, what it does is an ALTER TABLE ... UPDATE, and that cannot be undone, because no previous value is kept anywhere. The script itself counts rows with no page_url before --apply and refuses if there is even one, because for those rows the address exists only inside the name.

#Choosing properties

Put on the event what was true at that moment. The amount actually paid, the code that gave the discount, the category the product sat in. If the product's price changes tomorrow, yesterday's event still shows yesterday's price, and that is the only way "revenue last month" means anything.

Three things do not belong on an event:

  • Anything the client already sends. context carries device, OS, app version, page, campaign, language and timezone. A property called os_name is a second and worse copy of a column that already exists.
  • Tokens, passwords and card numbers. Properties are shown verbatim to your support team on the user timeline, and they appear in the CSV export.
  • Anything that has one value per person and changes, such as loyalty tier. That is a trait.

Property keys are normalised too, and you should know how, because the key is what you will type into a filter later:

InputStored key
" spaced key "spaced_key
"dotted.key"dotted_key
"dashed-key"dashed_key
"multi space"multi_space
"_leading_"leading
"" or " "dropped

So: whitespace runs, full stops and hyphens each collapse to a single _, leading and trailing _ are trimmed, control characters are dropped, and the key is truncated to 128 bytes. But keys are not lower-cased and not Persian-normalised. Price and price are two different properties.

#What a property may hold

A ClickHouse Map is homogeneous, so every property is written to one or both of two maps: props_str for text and props_num for numbers. The full table:

JSON valueIn props_strIn props_num
nullnot writtennot written
stringPersian-normalised, truncated to 8192 bytesnot written
true or false"true" or "false"1 or 0
numberthe number as textthe number
array or objectthe JSON as a string, truncated to 8192 bytesnot written
anything that fails to marshalnot writtennot written, plus the warning unserialisable_property

A null means "not set". Storing it as "" would make an "is not set" filter answer wrongly.

An integral number keeps no trailing zero: 1234 is stored as the string "1234", not "1234.0". Without that, an order id travelling as a JSON number would no longer join against the customer's own system.

What you send
{
  "type": "track",
  "user_id": "u_88123",
  "event": "order_completed",
  "properties": {
    "str": "hello",
    "num": 42.5,
    "int_like": 1234,
    "bool_t": true,
    "bool_f": false,
    "nil": null,
    "arr": [1, 2],
    "obj": { "a": 1 }
  }
}
What is stored
props_str = { str: "hello", num: "42.5", int_like: "1234",
              bool_t: "true", bool_f: "false",
              arr: "[1,2]", obj: "{\"a\":1}" }
props_num = { num: 42.5, int_like: 1234, bool_t: 1, bool_f: 0 }

nil is in neither. arr and obj are text, and still filterable with ClickHouse's own JSON functions. Nothing is flattened and no depth is lost.

The most important consequence of that table: a numeric-looking string never becomes a number. Send "price": "2450000" and props_num never gets price, so a "greater than" filter on it finds nothing, ever. This is deliberate: parsing "01234" would throw away the leading zero of a postcode. If you mean a number, send a JSON number. The same holds for Persian digits: "۲۴۵۰۰۰۰" is a string and stays a string.

There is no type registry. Every event is normalised on its own, and nothing in the ingest path remembers what type this key was last time. So sending price as a number and then as text produces neither an error nor a warning. Some rows simply have it in props_num and some do not.

#Limits, and what happens when you cross one

ThingLimitOn breach
properties per event256the first 256 are kept, the rest dropped, warning too_many_properties
property key length128 bytestruncated
property value length8192 bytestruncated, on a UTF-8 rune boundary
traits per message256the loop stops, warning too_many_traits
items per batch500the whole request is rejected with batch_too_large
body size5 MiBHTTP 413
event name length128 bytesrejected with event_name_too_long
user_id, anonymous_id, message_id256 bytesrejected with id_too_long
session_id256 bytestruncated, not rejected
previous_id on an aliasno limitnothing checks its length; only the 5 MiB body cap bounds it
page URL, path and referrer2048 bytestruncated
nesting depthno limitarrays and objects are JSON-stringified whole

Two details you only meet once it is too late:

"The first 256" is in no defined order. Go map iteration is randomised, so if you send 300 properties, which 44 are dropped differs from one event to the next. The result is a half-filled column that reads in a report like poor data rather than like a bug. The warning note gives the exact figures: 300 properties sent, keeping 256.

A property whose value is null consumes one of the 256 slots and stores nothing. Traits do not behave that way: there a nil value is skipped before it is counted.

Truncation never splits a character. If it did, the string would be invalid UTF-8, ClickHouse would reject the whole batch, and one bad string would stall an entire partition.

#Revenue, and how it accrues without being asked

There is one behaviour here to understand before you design your events: revenue is extracted from every event, not only from orders.

Extraction runs in this order, and the first non-zero value wins:

  1. props_num["revenue"]
  2. props_num["total"]
  3. props_num["value"]
  4. and if none of those: props_num["price"] × props_num["quantity"], with quantity defaulting to 1 when absent or not positive.

The currency comes from props_str["currency"], upper-cased and truncated to 8 bytes, defaulting to IRR. IRT is a recognised value, but nothing converts between rial and toman. Whatever number you send stays in the unit you declared, and every total assumes you stayed consistent.

Now the expensive part: the profile accrues revenue from any event carrying it.

Event nameEffect on the profile
order_completedadds to total_revenue, increments order_count, moves last_order_at forward
order_refunded and order_cancelledsubtracts the absolute amount from total_revenue (floored at zero) and decrements order_count (floored at zero)
any other nameadds the amount to total_revenue

So a product_added_to_cart carrying price and quantity adds to that person's lifetime value right now, with no order recorded anywhere. A "VIP customer" segment built on total_revenue fills up with people who only ever filled a basket.

Use the keys revenue, total, value, and the pair price with quantity, only on an event where money actually moved. For everything else pick another name: unit_price, cart_total, estimated_value. These keys are reserved and the code gives them meaning.

order_id and product_id are also declared as reserved keys, but no code reads them. They are ordinary properties.

#Names the platform gives meaning to

The platform declares eleven standard commerce names. Only three of them have behaviour (the section above); the rest are ordinary names whose only privilege is a Persian label in the panel:

product_viewed, product_added_to_cart, product_removed_from_cart, cart_viewed, checkout_started, order_completed, order_refunded, order_cancelled, searched, signed_up, signed_in.

Do not read more into "the default picker" than is there. Until the tenant's real schema loads, the segment builder offers exactly ten fixed names and no more: order_completed, product_viewed, product_added_to_cart, checkout_started, order_refunded, searched, app_opened, signed_up, message_opened and message_clicked. The other standard names, cart_viewed, product_removed_from_cart, signed_in and order_cancelled among them, are not in that placeholder list. Once the schema arrives the list is built from your own events, and these ten only supply a label.

Four names are produced by the server and you cannot override them: page_viewed and screen_viewed when a page or screen message carries no name, and identify and alias, which are always fixed.

A few more are written into your event stream by Segmentic's own workers, so that campaign interaction is queryable with exactly the same tooling as app events. Do not send these yourself:

NameWritten by
message_sentthe delivery dispatcher, on a successful send
message_failedthe delivery dispatcher, on failure
message_withheldthe delivery dispatcher, control group only
message_openedthe open-tracking endpoint

message_withheld deserves its own explanation: somebody was chosen for the campaign and deliberately not messaged. The row exists so that uplift has a baseline, because without a row saying "we picked this person and stayed quiet" there is nothing to compare the treated group against. Every other kind of suppression stays out of the stream on purpose, because recording those as sends would inflate every campaign's reach by exactly the people it did not reach.

message_delivered, message_bounced and unsubscribed are declared as constants and nothing else in the backend uses them: no worker writes them and nothing reads them, so a report built on message_delivered comes back empty. The panel asks for a delivery event anyway. Its messaging dashboard template and the starter metrics for delivery rate, open rate and click rate each need one mapped to a name from your own event schema, and activation does not proceed until you name one, so unless you send a delivery event yourself, that dashboard and those three metrics are never built.

What is missing is the event, not the data. A campaign report counts its own delivered messages from the receipts a transport sends back, the deliverability service reads bounces off the SMTP return path and writes them to the suppression list, and an unsubscribe lands on the person's record in the preference centre and comes back as the suppression reason on the next send. None of that arrives as a row in your event stream.

The attribution worker recognises exactly four names, message_sent, message_withheld, message_opened and message_clicked, and skips every other event it is handed. message_clicked is not written by a worker either: the web SDK raises it when a visitor lands on a link carrying sg_mid, once, until a different message id replaces it. The comparison is against the one id in storage, not against a history of them, so a visitor who lands on one message, then a second, then the first one again reports the first click twice.

Six names, app_installed, app_opened, app_updated, app_removed, session_started and session_ended, are declared in the code as "lifecycle events the SDKs emit automatically". No SDK emits them. Not the web SDK, not Android, not iOS; a search across all three finds zero emitters. app_opened is also in the panel's default event list and carries a Persian label, which makes it look automatic. If you want these events, you have to send them yourself.

#A property or a trait

This is where most of the mistakes happen, and the mistake does not show itself for months.

Event propertyProfile trait
How it is sentproperties on track, page or screentraits on identify
Where it landson that one event rowon the person's profile, one value each
Keeps historyyes, every event holds its own copyno, the newest value replaces the old one
Works for an anonymous visitoryesno, no profile exists until there is a user_id
What it is good for"ordered more than 500,000 in the last 30 days""is currently on the gold tier"

A precise example: city as a property on order_completed means "this order shipped to Tehran". The same city as a trait means "this person lives in Tehran now". The first never changes; the second is replaced on every identify and the old value is gone.

Three things follow from that difference, and you hit all three in the segment builder:

Known traits are lifted out of the trait map. email, phone, first_name, last_name, gender, birthday, national_id, city, region, country, language, timezone, push_opt_in, email_opt_in and sms_opt_in become real profile columns and are not also written into the free-form traits map. So they do not appear in the response of GET /v1/schema/traits. Your email trait has not vanished; it is a column.

A numeric filter on a trait does not select people who lack it. The segment compiler wraps the condition in mapContains. The failure direction was chosen deliberately: an audience that is silently empty is a campaign that does not go out and somebody notices; an audience that is silently everybody is a campaign that went to everybody and cannot be taken back.

A numeric filter on an event property has no such guard. The filter compiles against props_num with no mapContains, and a ClickHouse map yields the zero value for a missing key. So "price under 10,000" also matches events that never carried price at all. If you do not send the property on every event of that name, add an "is set" condition beside it.

The "is set" check for a property tests props_str, which is safe: every numeric value is always written to both maps, so props_str is the larger set.

#Persian text

Every user-supplied string goes through Normalize before it is stored. These are the places it runs:

  • the event name
  • every string property value
  • every custom trait value
  • context.location.country, .region and .city
  • context.page.title

And these are the places it does not run: property keys, trait keys, user_id, anonymous_id, message_id, session_id, page URL, path and referrer, and UTM values. email is only lower-cased, phone is parsed separately, and national_id only has its digits converted to ASCII.

What it does:

FromTo
Arabic yeh ي, alef maksura ى, ےPersian yeh ی
Arabic kaf ك, ڪPersian keheh ک
ة, ۀه
ؤو
أ, إ, ٱا

Beyond that, diacritics, tatweel and the zero-width joiner are stripped entirely, and every run of whitespace (including the non-breaking space, the Unicode spaces, and the byte-order mark) collapses to one plain space and is then trimmed from both ends. A paste out of Word or out of a right-to-left editor drags most of those in.

What is deliberately left alone:

  • The zero-width non-joiner. می‌رود is a different word from میرود to a reader.
  • Latin letter case. Digikala stays Digikala.
  • Persian digits. ۱۴۰۵ stays ۱۴۰۵ and is not converted to ASCII.
  • The zero-width space, alef with madda, yeh with hamza, and the standalone hamza.

The practical result: تهراني typed on an Arabic keyboard and تهرانی typed on a Persian one become one value and land in one segment. But Tehran and tehran do not, and ۱۲۳ never becomes 123.

#Phone, national id and gender

Four traits get bespoke handling, because they are the person's identity and storing them in whatever shape they arrived guarantees two profiles for one human.

phone is parsed by ParsePhone and stored in E.164 form, plus a derived trait called phone_operator. The algorithm: Persian and Arabic digits to ASCII, then keep only digits and a leading +, then strip the country prefix, and what remains must be exactly ten digits beginning with 9.

Shapes that all produce the same result
09123456789     9123456789      +989123456789    00989123456789
989123456789    0912 345 6789   0912-345-6789    (0912) 345 6789
۰۹۱۲۳۴۵۶۷۸۹    ٠٩١٢٣٤٥٦٧٨٩

result:  phone = "+989123456789"   phone_operator = "mci"
Shapes that are refused
""   "abc"   "0812345678"   "091234567"   "091234567890"
"+981234567890"   "12345"   "+1234567890"   "0000000000"

When parsing fails, the raw value is stored exactly as given and the warning invalid_phone comes back; phone_operator is not written. It is not dropped, because a badly shaped number is still the only way to reach that person.

The operator comes from the first four digits of the national form, the 09XX shape:

OperatorPrefixes
mci (Hamrah-e Aval)0910 to 0919, 0990 to 0997, 0999
irancell0900 to 0905, 0930, 0933, 0935 to 0939, 0941
rightel0920 to 0923
shatel0998
samantel0931
unknownanything else, for example 0906, 0932, 0934

national_id is checked with the standard mod-11 check digit. The length must be 8 to 10, and anything shorter than 10 is left-padded with zeros, because a leading zero is routinely lost in a spreadsheet. That padding is only for the check-digit arithmetic. What gets stored is the value you sent, with only its digits converted to ASCII, so 12345679 stays eight characters long. An all-identical id such as 1111111111 is refused even though it passes the checksum. If it is invalid the trait is dropped entirely and the warning invalid_national_id comes back. An invalid phone is kept as you sent it; an invalid national id is not kept at all, because enterprise customers key their CRM on this field and one wrong value creates a phantom profile.

email is trimmed and lower-cased. No format validation happens at ingest.

gender is mapped to exactly one of male, female or other. male comes from m, male, man, مرد, اقا, پسر; female from f, female, woman, زن, خانم, دختر; anything else becomes other. The comparison runs through Fold, so آقا also reaches male.

Every other trait, including city, first_name, birthday, language and the consent keys, gets Persian normalisation and truncation only.

#The cardinality guard

Three things must be kept apart, because only one of them is actually guarded.

What is guarded: the metric label. When an event is rejected, what gets counted as the reason is one of the stable codes, not the full error text. The full text contains the event type or the timestamp the caller chose, and that text used to become the Prometheus label directly: anyone holding a write key could mint a new time series per request by varying a name. That is unbounded memory, and it degrades monitoring first, which is the thing you need working in order to notice. Now 300 distinct invalid types in one batch collapse to a single label. The response body still gives you the full text and the offending value.

What is not guarded: the number of event names you create. There is no counter and no ceiling. The only effect you will see is the catalogue truncating at 500 names.

What is not guarded: the number of distinct values a property takes. There is no cap there either. Sending a session id or an order number as a property value is correct and fine. Sending the same thing as an event name is the subject of URL-shaped names.

#Seeing what actually arrived

Three ways, and each tells you something different.

The response body. Warnings come back immediately. The complete list of warning codes: generated_message_id, timestamp_in_future, timestamp_too_old, too_many_properties, unserialisable_property, too_many_traits, invalid_phone, invalid_national_id. On /v1/batch the collector stops collecting warnings once it is already holding 50, and it makes that test before appending a whole item's warnings, so a response can carry 49 plus everything the next item raised. The counts stay exact.

A batch with one bad item
{"status":"ok","accepted":2,"rejected":1,"errors":[{"index":1,"reason":"missing_event_name"}]}

One bad item does not sink the batch. Rejected items are reported by their index in your array. Note that accepted includes duplicates, so that the SDK stops retrying them.

The catalogue. GET /v1/schema/events on api.segmentic.net, with a management key holding event.read:

The event catalogue
curl -H "Authorization: Bearer sk_seg_..." \
  https://api.segmentic.net/v1/schema/events
Response
{
  "events": [
    { "name": "product_viewed", "volume": 88000000, "prop_keys": ["product_id","price"], "last_seen": "2026-08-06" },
    { "name": "order_completed", "volume": 4200000, "prop_keys": ["revenue","order_id"], "last_seen": "2026-08-07" }
  ]
}

last_seen is the most useful column here: an event with a large volume and a last-seen three weeks ago is an integration that broke, and no other figure says so, because volume alone looks healthy for a month afterwards. The window is 90 days, the cap is 500 names ordered by volume, and prop_keys is the union of both maps' key names with no type information at all. So this endpoint cannot tell you that a property changed type.

Freshness follows the ingestor's flush cycle: every 10,000 events or every 5 seconds, whichever comes first. There is no separate refresh job and no cache in front of it.

The live debugger. In the panel you turn recording on and watch events arrive for 30 minutes, with their warnings. The last 200 are kept. Nothing is recorded until somebody opens the debugger.

The debugger only records the single-event paths. POST /v1/batch hands it nothing, and all three SDKs batch. So on a fresh SDK install the debugger stays empty while events are arriving perfectly well, and you conclude the integration is broken. To confirm an SDK install use the panel's connect screen, which asks for the app's activity over the last 24 hours, or send one event through POST /v1/track by hand.

The table segmentic.ingest_warnings exists in the schema and its comment says it holds each tenant's warning history. Nothing writes to it. So there is no warning history: a warning is visible only in the response body and in the live debugger. If you want to know that your Android SDK stopped sending user_id yesterday, you have to log the responses yourself.

#Decisions that cannot be undone

Every row here is written once into the data, after which you can only fix things from that point forward.

DecisionWhy it does not reverseWhat to do instead
The event nameRenaming means an ALTER TABLE ... UPDATE over existing rows, which keeps no previous value. The script is not in main eitherWrite the list of names down before the first send. There is a ready-made one in the event dictionary
A property you did not sendThere is no way to add a property to rows already writtenSend the contextual properties from day one, even the ones you do not need yet
A number sent as a stringA numeric-looking string is never parsed, and later events do not repair earlier rowsSend a JSON number. Persian digits are a string too
Migrating history through POST /v1/eventsThat path has a fixed 30-day window, anything older is clamped to its edge, the status is 202 and no warning comes back in the body at all. A year of orders lands as one enormous dayUse the import path, which in backfill mode refuses rather than moving
A trait that changed typeSending "abc" after 428 replaces the text half and leaves the numeric half at 428, and there is no way to clear it from the ingest path either: an empty string value is discarded before the profile sees itKeep a trait's type stable
An event sent by mistakeThere is no endpoint that deletes one eventOnly the retention policy, the table's own 400-day TTL and a per-person erasure remove a row

Two details on the migration row. The 30-day window belongs to POST /v1/events alone; the collector on in.segmentic.net uses the tenant's own retention policy as its window, and when it does clamp it returns a timestamp_too_old warning in the body. And the import path (POST /v1/import/events) is not registered on the public API: it is reachable only from the panel, so migrating history is something a person does in a browser, not something a script does with a management key.

And one limit that is not your policy but the platform's ceiling: the events table carries a 400-day TTL. No event survives 400 days, whatever retention you configured. If you need longer, export.

Next: the event dictionary has ready-made lists for six kinds of business, and identity explains how user_id and anonymous_id are joined.

PreviousConceptsNextEvent dictionary

On this page

  • What an event is, and is not
  • The five message types
  • The naming rule
  • When the event name is a URL
  • Choosing properties
  • What a property may hold
  • Limits, and what happens when you cross one
  • Revenue, and how it accrues without being asked
  • Names the platform gives meaning to
  • A property or a trait
  • Persian text
  • Phone, national id and gender
  • The cardinality guard
  • Seeing what actually arrived
  • Decisions that cannot be undone

Segmentic

This page is written from the code