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.
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:
| Path | Type | Name stored |
|---|---|---|
POST /v1/track | track | the event you sent. Required |
POST /v1/page | page | the event, defaulting to page_viewed |
POST /v1/screen | screen | the event, defaulting to screen_viewed |
POST /v1/identify | identify | always identify. Anything in event is discarded |
POST /v1/alias | alias | always alias. As above |
POST /v1/batch | any of the five | each 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.
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" }
}
}'
{"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:
{"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:
| Rule | Limit | Reject code |
|---|---|---|
| length after normalisation | 128 bytes | event_name_too_long |
| Unicode control characters | none allowed | event_name_invalid_chars |
| a name on a track | required | missing_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_completedandorder completedare 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.
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.
contextcarries device, OS, app version, page, campaign, language and timezone. A property calledos_nameis 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:
| Input | Stored 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 value | In props_str | In props_num |
|---|---|---|
null | not written | not written |
| string | Persian-normalised, truncated to 8192 bytes | not written |
true or false | "true" or "false" | 1 or 0 |
| number | the number as text | the number |
| array or object | the JSON as a string, truncated to 8192 bytes | not written |
| anything that fails to marshal | not written | not 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.
{
"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 }
}
}
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
| Thing | Limit | On breach |
|---|---|---|
| properties per event | 256 | the first 256 are kept, the rest dropped, warning too_many_properties |
| property key length | 128 bytes | truncated |
| property value length | 8192 bytes | truncated, on a UTF-8 rune boundary |
| traits per message | 256 | the loop stops, warning too_many_traits |
| items per batch | 500 | the whole request is rejected with batch_too_large |
| body size | 5 MiB | HTTP 413 |
| event name length | 128 bytes | rejected with event_name_too_long |
user_id, anonymous_id, message_id | 256 bytes | rejected with id_too_long |
session_id | 256 bytes | truncated, not rejected |
previous_id on an alias | no limit | nothing checks its length; only the 5 MiB body cap bounds it |
| page URL, path and referrer | 2048 bytes | truncated |
| nesting depth | no limit | arrays 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:
props_num["revenue"]props_num["total"]props_num["value"]- and if none of those:
props_num["price"] × props_num["quantity"], withquantitydefaulting 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 name | Effect on the profile |
|---|---|
order_completed | adds to total_revenue, increments order_count, moves last_order_at forward |
order_refunded and order_cancelled | subtracts the absolute amount from total_revenue (floored at zero) and decrements order_count (floored at zero) |
| any other name | adds 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:
| Name | Written by |
|---|---|
message_sent | the delivery dispatcher, on a successful send |
message_failed | the delivery dispatcher, on failure |
message_withheld | the delivery dispatcher, control group only |
message_opened | the 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 property | Profile trait | |
|---|---|---|
| How it is sent | properties on track, page or screen | traits on identify |
| Where it lands | on that one event row | on the person's profile, one value each |
| Keeps history | yes, every event holds its own copy | no, the newest value replaces the old one |
| Works for an anonymous visitor | yes | no, 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,.regionand.citycontext.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:
| From | To |
|---|---|
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.
DigikalastaysDigikala. - 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.
09123456789 9123456789 +989123456789 00989123456789
989123456789 0912 345 6789 0912-345-6789 (0912) 345 6789
۰۹۱۲۳۴۵۶۷۸۹ ٠٩١٢٣٤٥٦٧٨٩
result: phone = "+989123456789" phone_operator = "mci"
"" "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:
| Operator | Prefixes |
|---|---|
mci (Hamrah-e Aval) | 0910 to 0919, 0990 to 0997, 0999 |
irancell | 0900 to 0905, 0930, 0933, 0935 to 0939, 0941 |
rightel | 0920 to 0923 |
shatel | 0998 |
samantel | 0931 |
unknown | anything 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.
{"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:
curl -H "Authorization: Bearer sk_seg_..." \
https://api.segmentic.net/v1/schema/events
{
"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.
| Decision | Why it does not reverse | What to do instead |
|---|---|---|
| The event name | Renaming means an ALTER TABLE ... UPDATE over existing rows, which keeps no previous value. The script is not in main either | Write the list of names down before the first send. There is a ready-made one in the event dictionary |
| A property you did not send | There is no way to add a property to rows already written | Send the contextual properties from day one, even the ones you do not need yet |
| A number sent as a string | A numeric-looking string is never parsed, and later events do not repair earlier rows | Send a JSON number. Persian digits are a string too |
Migrating history through POST /v1/events | That 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 day | Use the import path, which in backfill mode refuses rather than moving |
| A trait that changed type | Sending "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 it | Keep a trait's type stable |
| An event sent by mistake | There is no endpoint that deletes one event | Only 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.