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

Reports and exports

Build funnels and retention reports, then import and export data safely from the panel.

The management host https://api.segmentic.net serves two reports and no more: funnel and retention. Everything else you see in the panel, paths, churn, RFM, engagement, the dashboard builder, has no public address at all. The full list is in what exists only in the panel and what is not possible.

Both reports take a management key (sk_seg_...), never a write key. The write key belongs to in.segmentic.net and reaches none of these routes.

On a local install this host is not served until you set PUBLIC_API_ADDR. The collector runs separately on http://localhost:8080.

OBSERVATIONS
ExposureMessage delivered
ConversionCustomer outcome
HoldoutUntreated control
SEGMENTICAttribution engineConnect exposure to outcome and compare the control
REPORTS
FunnelStep conversion
RetentionReturn over time
Incremental liftEffect beyond baseline
How exposure, conversion and holdout observations become funnel, retention and lift reports

#Funnel

POST /v1/reports/funnel. Permission analytics.read. Costs 25 units of the request budget. Times out after 45 seconds. Body limit 1 MiB.

The number that comes back is cumulative. users on a step means everybody who reached that step or beyond, not everybody who stopped there. ClickHouse windowFunnel reports the furthest step each user reached, and we accumulate the histogram backwards. Read it directly and you build a funnel whose later steps have more users than its first.

Shell
curl -X POST https://api.segmentic.net/v1/reports/funnel \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "steps": [
          {"name": "product_viewed", "label": "دیدن محصول"},
          {"name": "add_to_cart"},
          {"name": "purchase"}
        ],
        "range": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
        "window": "7d"
      }'
FieldTypeRequiredDefaultRule
stepsarrayyesnone2 to 12 members
steps[].namestringyesnonethe event name, at most 256 bytes
steps[].labelstringnothe namechart label only
steps[].filtersarraynononeat most 10 members
range.fromRFC3339yesnoneinclusive
range.toRFC3339yesnoneexclusive
windowstringyesnone"7d", "2h", "30m"
strictbooleannofalseno other event between two steps
split_bystringnoemptyallow-list below

from is inclusive and to is exclusive so consecutive ranges tile without counting the events on the boundary twice. A range longer than 730 days is refused.

window is parsed by hand because Go has no day unit. "7d", "1d", "0.5d", "2h" and "30m" are accepted. "" and null become zero, and zero is refused. "tomorrow" is a JSON decoding error. A window longer than the range itself is refused too: nobody takes ninety days to convert inside a thirty-day report.

Nothing on this platform validates an event name. A typo in steps[].name returns a perfectly well-formed funnel full of zeroes, which is indistinguishable from a real audience of nobody. Call GET /v1/schema/events first and check the name exists.

Anonymous users (an empty user_id) and bot traffic are excluded from every funnel, retention and path report. Bots are not dropped from the warehouse, they are flagged with is_bot and left out of reports: a traffic dip nobody can explain destroys trust in the whole set of numbers.

#The saved funnel library

The panel keeps named funnels. Save a definition once and it appears in a library where every entry draws its own chart, which is the point: twenty funnels side by side is how somebody notices that one of them broke last Tuesday.

None of it has a public address. The library is on the panel's control plane, which is deliberately not routed from outside, so GET /v1/funnels is not reachable with a management key. This section is here so nobody spends an hour writing an integration against a route that answers 404. What the API offers is this page's POST /v1/reports/funnel, which computes a funnel from a definition you hold yourself.

When you have not built a funnel yet, the library shows only an empty state and a Start with ready-made funnels button. The ready-made list stays folded until that button is pressed. There is one template per kind of business, with the steps this documentation's event dictionary publishes for that vertical. Taking one creates an ordinary saved funnel; nothing about the result remembers it came from a template. Each template is checked against the account's own event catalogue first and says so when it names an event the account has never sent, because a step nobody sends returns a well-formed funnel full of zeroes and that is indistinguishable from a real audience of nobody.

Two things about the library are worth knowing even if you only ever use the API.

A card's chart is not computed when the page loads. A background job recomputes each saved funnel on a timer and stores the answer on the row, because drawing them live would be one windowFunnel over the whole events table per card on every visit. So a card can be a few hours behind, and every card says how old its number is. Opening a funnel and running it gives the number as of now.

A saved definition holds the steps, the conversion window, the strict flag and the breakdown, and not the range. The range is the question asked of a saved definition rather than part of it; freezing "the last thirty days" into the row would mean every saved funnel silently aged, and a year later the library would be a set of questions about last spring.

#Filters

Each filter has three fields: {"prop": "...", "op": "...", "value": "..."}. value is always a JSON string, including for the numeric operators.

GroupOperatorsColumn compared
texteq, ne, contains, prefixprops_str
numericgt, gte, lt, lte, num_eq, num_neprops_num

An unknown operator is a 400. prop is capped at 128 bytes and value at 512.

One behaviour that costs an afternoon: if the value of a numeric operator does not parse as a float, it renders as the literal 0 rather than erroring, so it matches nothing. The reason is that a half-typed number in the UI must not blank the whole chart with a stack trace. What it means for you is that a typo in a numeric filter silently produces an empty funnel.

JSON
{
  "steps": [
    {"name": "product_viewed",
     "filters": [{"prop": "category", "op": "eq", "value": "mobile"}]},
    {"name": "purchase",
     "filters": [{"prop": "amount", "op": "gte", "value": "500000"}]}
  ],
  "range": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
  "window": "2d"
}

#Breakdown

split_by is either one of these allow-listed keys, or the form prop: followed by a property name.

split_byColumn
platformos_name
osos_name
devicedevice_type
app_versionapp_version
countrycountry
citycity
regionregion
provinceregion
utm_sourceutm_source
utm_campaignutm_campaign
browserbrowser_name

prop:category groups by props_str, deliberately not props_num: a numeric property used as a breakdown produces one bucket per distinct value, which is a chart with four thousand bars. The key after prop: is never validated; an unknown key groups everything under the empty string, which is a true and readable answer rather than an error.

An unknown value that does not start with prop: is a 400 carrying the raw Go text analytics: unknown breakdown "...".

#Funnel response

JSON
{
  "steps": [
    {"index": 0, "name": "product_viewed", "label": "دیدن محصول",
     "users": 1000, "from_start": 1.0, "from_previous": 1.0, "dropped_here": 0},
    {"index": 1, "name": "add_to_cart", "label": "add_to_cart",
     "users": 600, "from_start": 0.6, "from_previous": 0.6, "dropped_here": 400},
    {"index": 2, "name": "purchase", "label": "purchase",
     "users": 300, "from_start": 0.3, "from_previous": 0.5, "dropped_here": 300}
  ],
  "entered": 1000,
  "completed": 300,
  "conversion": 0.3,
  "description": "کاربرانی که «دیدن محصول» سپس ... را به ترتیب انجام دادند، حداکثر در ۷ روز."
}
  • from_start, from_previous and conversion are fractions from 0 to 1, not percentages.
  • from_previous on step 0 is always 1.
  • dropped_here on step 0 is always 0.
  • Division by zero is guarded: an empty funnel yields 0, never NaN.
  • description is generated from the same request the query was built from, so it cannot drift from the numbers underneath it. On this host the sentence and its dates are always Persian and Jalali, because this host never parses Accept-Language and its default locale is Persian.

With split_by, a buckets key appears as well, sorted by entered descending. The top-level steps, entered and completed remain the whole funnel across every bucket, not the largest one.

JSON
{
  "steps": [],
  "buckets": [
    {"value": "ios", "steps": [], "entered": 1000, "completed": 100, "conversion": 0.1},
    {"value": "android", "steps": [], "entered": 200, "completed": 100, "conversion": 0.5}
  ],
  "entered": 1200,
  "completed": 200,
  "conversion": 0.16666666666666666,
  "description": "..."
}

If the only bucket key is the empty string, buckets is absent entirely.

#Retention

POST /v1/reports/retention. Same permission, same cost of 25 units, same 45-second timeout.

Shell
curl -X POST https://api.segmentic.net/v1/reports/retention \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "start":  {"name": "signup"},
        "return": {"name": "purchase"},
        "range":  {"from": "2026-05-01T00:00:00Z", "to": "2026-05-10T00:00:00Z"},
        "granularity": "day",
        "periods": 3
      }'
FieldTypeRequiredDefaultRule
startsame shape as a stepno{}an empty name means any activity
returnsame shape as a stepno{}an empty name means any activity
range{from, to}yesnoneas for the funnel, at most 730 days
granularitystringnodayday, week or month
periodsnumberno301 to 60

start and return are two separate fields because "came back" rarely means "did the same thing again". With both empty the report counts any activity.

A periods of zero or below becomes 30, but above 60 it is a 400 carrying the raw text analytics: at most 60 periods. A grid of more than 120 cohort rows is refused as well: a full year at day granularity is refused, the same range at month granularity is fine.

This endpoint has no event field, and it does not reject unknown keys, it ignores them. Send {"event": "purchase"} and start and return stay empty, so you get an any-activity grid and nothing anywhere says your question was not understood. The only clue is the description sentence, which says «هر فعالیتی». The same defect affects our own ready-made MCP tool; see the MCP page.

#Jalali cohorts

None of ClickHouse's calendar functions is correct for this market, so every bucket boundary is computed in Go in Asia/Tehran and ClickHouse is only asked which bucket a timestamp falls into.

GranularityBucket startNext step
dayTehran midnightone day
weekSaturdayseven days
monthday 1 of the Jalali monthone day past the end of that Jalali month

toStartOfMonth is Gregorian, so a "monthly" cohort report would be cut ten days away from where every Iranian user believes the month starts. toStartOfWeek offers Monday or Sunday, and the Persian week begins on Saturday, so a weekly report would split each week across two rows.

The month arithmetic is not "plus 31 days" either: a Jalali month is 29, 30 or 31 days, and adding 31 days would skip a 30-day month entirely. The boundaries are built in Go and passed to ClickHouse as an Array(Date).

Boundary dates are sent as local Tehran calendar dates, not converted to UTC. Converting to UTC first would move every boundary back three and a half hours and put the small hours of each day in the previous bucket.

A user's cohort is the first period they qualified in within the requested range, not their all-time first. If it were the all-time first, a two-year-old customer who bought something today would drop into this month's cohort and the first cell of the grid would stop meaning anything.

#Cells that are not knowable yet

Every cell carries an observable field. false means "the report has not run long enough to know yet", not zero. A cohort that started yesterday has no day-30 number, and rendering that cell as zero per cent is how a healthy product looks like it is dying.

The boundary is the newest bucket that has fully elapsed, judged against a clock read once per request so every cell in one response is judged against one instant. The period in progress still appears in the grid and is excluded from the averaged curve.

#Retention response

JSON
{
  "granularity": "day",
  "period_label": "روز",
  "cohorts": [
    {
      "cohort": "2026-05-01",
      "label": "۱۱ اردیبهشت ۱۴۰۵",
      "size": 100,
      "cells": [
        {"period": 0, "users": 100, "rate": 1.0, "observable": true},
        {"period": 1, "users": 40, "rate": 0.4, "observable": true},
        {"period": 2, "users": 25, "rate": 0.25, "observable": true},
        {"period": 3, "users": 0, "rate": 0.0, "observable": false}
      ]
    }
  ],
  "average": [
    {"period": 0, "users": 10004, "rate": 1.0, "observable": true},
    {"period": 1, "users": 1004, "rate": 0.10036, "observable": true}
  ],
  "description": "از کاربرانی که برای اولین بار «signup» انجام دادند ..."
}
  • cohort is the machine key and is always a Gregorian YYYY-MM-DD taken from the Tehran instant. label is the human row header, and on this host it is Jalali with Persian digits.
  • cells always holds exactly one more entry than periods, period 0 to the last.
  • rate is a fraction from 0 to 1.
  • average is the weighted curve: total returners over total starters, across observable cells only. It is not the mean of the per-cohort percentages, because a cohort of four people who all came back would pull the curve up as hard as one of forty thousand.
  • Cohorts with no rows at all are omitted from cohorts. An empty result is "cohorts": [].

#Cost, timeout and the shape of an error

These two endpoints are the panel's own internal handlers, registered on the management host as well. That produces one real inconsistency you have to know about:

FailureEnvelopeStatus
no key, wrong kind of key, expired key{"error":{"code":"unauthenticated"}}401
missing permission{"error":{"code":"forbidden","need":"analytics.read"}}403
request budget spent{"error":{"code":"budget_exhausted"}} with Retry-After: 60429
account soft-locked{"error":{"code":"account_locked","details":{"reason":"usage_300"}}}403
malformed JSON{"error":"a Persian sentence"}400
invalid report{"error":"a Persian sentence","code":"invalid_report"}400
warehouse failed{"error":"a Persian sentence"}503

A client that only parses error.code breaks on every 400 and every 503 from these two endpoints, because those responses send error as a string rather than an object. Handle both shapes.

Nine distinct validation faults (too few steps, too many steps, a blank name, a bad range, a range that is too wide, a bad window, a bad granularity, a bad operator, a depth that is too great) all share one code: invalid_report. The sentence changes, the code does not. A warehouse failure carries no code at all.

The request budget is 600 units a minute and is keyed on the API key, not on the account. Each report costs 25, so one key runs 24 reports a minute. PUBLIC_API_BUDGET_PER_MINUTE changes it. If the budget store cannot be read the request is refused rather than let through: budget_unavailable with a 503.

The soft lock closes when usage reaches three hundred per cent of the allowance or an issued invoice is 75 days overdue. It closes exactly four routes: both reports, and queueing and listing exports. Ingest (POST /v1/events) and sending are deliberately left open.

The report timeout is 45 seconds. Note that GET /v1/capabilities publishes query_timeout_sec as 30 and that number is not the report timeout. None of the analytics ceilings (12 steps, 60 periods, 120 cohorts, 730 days, 45 seconds) is published on any endpoint. You have to hardcode them.

#Exports

An export is a background job, not a response. POST /v1/exports queues it and returns 202. GET /v1/exports reports its state. There is no webhook, no callback and no notification; polling is the only mechanism.

The permission is data.export, and it is separate because an export walks out of the building: read access inside a dashboard that logs every query is a different risk from a CSV of every customer's email address on somebody's laptop. The owner, admin, marketer and analyst roles carry it; viewer, approver and finance do not.

#Queueing an export

Shell
curl -X POST https://api.segmentic.net/v1/exports \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "kind": "events",
        "format": "ndjson",
        "spec": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"}
      }'
JSON
{"id": 42, "status": "queued", "kind": "events", "expires_after_hours": 168}

kind is exactly one of four: events, profiles, segment, messages. Anything else is a 422 with the code export_kind_invalid.

format is either exactly the string csv, or anything else, which is silently rewritten to ndjson. Send "parquet" and you get a 202 and an NDJSON file. NDJSON is the default because an export of events with nested properties is not a rectangle, and flattening it into CSV silently loses the nesting.

spec is a free-form object of which exactly three keys are read:

KeyTypeUsed byDefault
fromRFC3339 stringevents, messages90 days ago
toRFC3339 stringevents, messagesnow
segment_idnumbersegmentrequired; 0 is a terminal failure

profiles ignores the window entirely. A reversed range is swapped rather than refused. Anything unparseable in spec is ignored and the default applies.

An installation with no export storage configured answers 503 rather than 202. The download route is not registered at all on such an installation, so a 202 would promise a file with nowhere to be collected from, and the job would sit in the queue looking like work in progress for ever. 503 rather than 400 because the request was fine and the installation is not, which is the difference between fixing your code and asking your operator.

There is no columns field, no max_rows and no limit. The columns are fixed and the row cap is a server constant.

#Polling

Shell
curl https://api.segmentic.net/v1/exports \
  -H "Authorization: Bearer sk_seg_..."
JSON
{
  "data": [
    {
      "id": 42, "kind": "events", "format": "ndjson",
      "spec": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
      "status": "ready", "rows_written": 412334, "bytes": 91203344,
      "location": "/var/lib/segmentic/exports/7/42.ndjson",
      "attempts": 1, "truncated": false,
      "expires_at": "2026-06-08T09:12:00Z",
      "requested_by": "api-key:3",
      "created_at": "2026-06-01T09:04:00Z",
      "finished_at": "2026-06-01T09:12:00Z"
    }
  ],
  "has_more": false
}

Five statuses exist: queued, running, ready, failed, expired. No endpoint publishes that list.

  • location is the file's path on our server and it goes out on the wire. It is of no use to you.
  • attempts is published because "it failed" and "it failed three times and stopped" are different answers. The attempt cap is 3.
  • truncated says the query stopped at the five-million-row cap rather than at the end of the data, so the file is not the whole result. It is absent when false. A result of exactly five million rows is indistinguishable from a truncated one and is reported as truncated: a complete file wrongly labelled incomplete costs one query to check, and a truncated file labelled complete is a number in a report that is quietly wrong.
  • A job stuck in running whose claim is older than 45 minutes is re-claimable, so a worker that was killed does not strand a job forever. One build has 30 minutes.
  • expired means the sweeper deleted the file and kept the row, so the record that an export happened, and who asked for it, outlives the file.

The response carries no next_cursor key at all, so a client that reads it gets nothing rather than an empty string, and has_more is never true. Nothing anywhere in the API assigns next_cursor, so that is how the shared page envelope behaves on every route that returns it, not a quirk of exports. A cursor parameter is read and thrown away. limit defaults to 25 and is clamped to 100. So only the 100 most recent jobs are reachable, newest first, and older ones cannot be seen through the API at all.

GET /v1/exports/{id}, to read one job by id, does not exist. The only route is the list.

#Collecting the file

GET /v1/exports/{id}/download does not exist on the management host. It is registered only on the dashboard's control-plane listener, which is deliberately not addressable from outside the overlay network.

Stated plainly: your integration can build an export through api.segmentic.net and has no programmatic way to fetch its bytes. There is no signed URL, no object storage and no download_url field anywhere in the codebase. A person has to collect the file from the panel, under Reports, on the Exports tab. That screen also queues an export, so a one-off does not need an API key at all.

If your programme needs raw data weekly, the queued export is not your answer until that route opens.

#What is in each export

Every source writes out its column list rather than using SELECT *, because an export is a file that leaves the building and SELECT * means the day somebody adds a column holding a hashed identifier or an internal flag, it silently appears in every customer's next download.

events, 23 columns in this order: message_id, type, name, user_id, anonymous_id, session_id, event_time, received_at, revenue, currency, props_str, props_num, app_version, device_type, os_name, country, region, city, page_url, page_path, utm_source, utm_medium, utm_campaign.

profiles, 27 columns: user_id, email, phone, first_name, last_name, gender, city, region, country, language, timezone, device_type, os_name, app_version, traits, traits_num, has_push, has_email, has_phone, push_opt_in, email_opt_in, sms_opt_in, total_events, total_revenue, order_count, first_seen, last_seen.

ip and national_id are deliberately left out of the profiles export: an export is the copy of the data with the least protection around it, and a national identifier in a spreadsheet on somebody's laptop is the single worst row in this database to lose.

messages, 18 columns, read from Postgres: message_id, user_id, channel, category, transport, campaign_id, journey_id, node_id, variant, topic_id, status, reason, gateway, gateway_id, delivery, delivery_detail, delivered_at, sent_at.

segment, 20 columns: user_id, email, phone, first_name, last_name, gender, city, region, country, total_events, total_revenue, order_count, first_seen, last_seen, has_push, has_email, has_phone, push_opt_in, email_opt_in, sms_opt_in. The traits map is not here because its keys differ per user, so it cannot become a fixed set of columns without scanning the whole audience first.

The segment export uses the same compiler the dashboard preview uses, so the file and the preview cannot disagree about who is in the audience.

#Format and encoding

NDJSON: one complete JSON object per line, keyed by column name so that a column added later does not shift every downstream index by one. Times are RFC3339Nano in UTC. A nil map becomes {}, never null. Numbers stay numbers.

CSV: it starts with a UTF-8 BOM, without which Excel on Windows renders every Persian name as mojibake. Any cell starting with =, +, -, @, a tab or a carriage return is prefixed with a tab, because profile traits come from the customer's own end users, and the person opening the export is an employee of our customer, on their laptop, on their network.

In CSV, booleans become the localised yes or no word, floats use plain notation rather than scientific, and a zero timestamp becomes an empty cell rather than 1970, which looks like a real date somebody might act on.

Column names are Persian in exactly one combination: format of csv and kind of segment. Every other combination ships machine column names, because NDJSON is read by a loader keying on field names and a JSON key in Persian is hostile to every pipeline downstream.

xlsx never comes out of the export queue. Only csv and ndjson have encoders.

#Row cap and expiry

A cap of five million rows is applied to all four kinds, as a bare SQL LIMIT. Past that the file is not something anybody opens, it is a pipeline that should be reading the warehouse directly.

When the cap is reached the job carries truncated: true, and the panel puts a warning on the row. It was silent for a long time: nothing in the job row and nothing in any response said an export had hit the cap, rows_written simply read the cap, and a suspiciously round number is not something anybody notices. If your data can approach five million, split the range yourself rather than relying on the flag to tell you afterwards.

The file is deleted after 7 days (168 hours), and that number is published in the 202 as expires_after_hours. A file containing every customer's email address sitting on a share forever is what turns one careless export into a breach, and nobody remembers to delete it, so the platform does.

The destination is a directory on disk, with directory mode 0700 and file mode 0600. There is no S3 or object-storage implementation; on-premise installs have a mounted volume and no S3 endpoint.

#Importing data

CSV import is panel-only. There is no multipart upload endpoint on api.segmentic.net. POST /v1/imports and GET /v1/imports/{id} do not exist. What ships is synchronous, and it is documented here so you know exactly what the panel does.

All three import endpoints take profile.write, including inspect, which stores nothing: it is still the step that reads the customer's spreadsheet. viewer, analyst, approver and finance get a 403; marketer is accepted.

#Users from a CSV

POST /v1/import/inspect reads the file, returns its guess at a mapping, and writes nothing:

JSON
{
  "header": ["email", "موبایل", "امتیاز"],
  "preview": [["a@b.com", "09123456789", "1500"]],
  "total": 1,
  "mapping": {"columns": [
    {"index": 0, "field": "email", "name": "email"},
    {"index": 1, "field": "phone", "name": "موبایل"},
    {"index": 2, "field": "trait", "name": "امتیاز"}
  ]}
}

preview is at most 5 rows and total counts data rows, excluding blank ones.

POST /v1/import/users actually imports the same file. A multipart form with the file part named file, plus two optional fields: mapping, a JSON object that replaces the guess entirely, and dry_run.

dry_run defaults to off. Only the exact string "true" turns it on and anything else is off. Omit the field and real profiles are written, which campaigns then target.

JSON
{
  "total": 2,
  "accepted": 1,
  "rejected": 1,
  "errors": [{"row": 3, "column": "موبایل", "value": "rubbish", "reason": "..."}],
  "truncated": false,
  "dry_run": false,
  "ingested": 1
}

row is 1-based counting the header as row 1, so the first data row is row 2. truncated says the error list was cut at 50, so that "50 problems" is not mistaken for "exactly 50 problems".

ingested can be lower than accepted if the bus rejected some. It exists because saying "twenty thousand imported" when nineteen thousand arrived is the kind of lie that surfaces a week later as a campaign that reached fewer people than promised.

File-level failures are a 400 with the code invalid_file: no header row, no data rows, no column mapped to user id or email or mobile, too many rows, too many columns, no file chosen, file too large. A partial failure is a 503 with the code partial_import and carries the whole result, because part of the file is already in and telling the operator it all failed would have them upload it a second time.

LimitValue
Data rows per file500,000
Header columns100
Bytes in one cell4096
File size64 MiB (67108864 bytes)
Bad rows reported50

GET /v1/import/fields publishes the field list and the two numbers max_rows and max_bytes.

#Column mapping

The mapping is {"columns": [{"index": 0, "field": "email", "name": "email"}]}. Columns are addressed by index, not by header text, because spreadsheets routinely have duplicate or blank headers and a name-keyed map silently drops one of them.

The fields are user_id, email, phone, first_name, last_name, gender, birthday, national_id, city, region, country, language, trait and ignore. When field is trait, name is the trait key.

The auto-guess recognises Persian and English headers together, because an Iranian marketing team exports from a Persian CRM and a foreign analytics tool in the same week. «شناسه», «کد کاربر», «ایمیل», «رایانامه», «موبایل», «شماره تماس», «نام خانوادگی», «کد ملی», «استان» and their English counterparts are all recognised. Headers are folded through the Persian matcher, so the Arabic forms of ye and kaf match the Persian ones.

A header that matches nothing becomes a trait keyed by the header text. A second column claiming an identity field that is already taken becomes a trait too, because two email columns means one of them is something else. A blank header becomes ignore.

The delimiter is detected, not assumed. Comma, semicolon and tab are all considered, and the score is consistency across the first six lines rather than a raw count, so a comma inside quoted Persian text does not win. Excel on a Persian Windows locale writes semicolons, and a file that silently parses as one giant column is the most common import support ticket there is.

A leading UTF-8 BOM is stripped, otherwise email arrives with an invisible character in front of it and silently becomes a custom trait. Ragged rows are tolerated and blank rows are skipped.

#Row conversion

Per-row failures are collected rather than stopping the job: a twenty-thousand-row export with three bad phone numbers should import nineteen thousand nine hundred and ninety-seven people and tell the operator about the three.

FieldRule
any fielda cell longer than 4096 bytes rejects the row
any fieldan empty cell is skipped entirely
user_idPersian digits are folded to ASCII
phonestored as E.164; invalid rejects the row
emaillowercased and shape-checked; invalid rejects the row
national_idchecksum verified; invalid rejects the row
birthdaystored as Gregorian YYYY-MM-DD; invalid rejects the row
gendernormalised to male or female; never fails
traitnumeric-looking values stored as numbers, the rest as text; never fails

The phone is normalised here rather than downstream, because a phone stored in two shapes is two profiles for one human. ۰۹۱۲۳۴۵۶۷۸۹ becomes +989123456789.

Gender accepts male, m, «مرد», «آقا», «پسر» and female, f, «زن», «خانم», «دختر».

Numeric traits: a value with a leading zero stays text, and a value longer than 15 characters stays text, because a postcode of 01234 parsed as 1234 is wrong and a national id past the limit of float precision loses its last digits. Persian digits count as numbers.

Dates are read in both calendars and both digit sets, with /, - or . as separators. A year under 1700 is read as Jalali and converted to Gregorian; the calendars are far enough apart that there is no ambiguous range a person would actually type. 1370/05/12 becomes 1991-08-03, and 1370/13/45 is an error, because the Jalali month and day bounds are checked before conversion.

With no user_id, the email is used, then the phone. With none of the three the row is rejected, because an identify with nothing to identify creates an anonymous profile nobody can ever reach.

The import's output is an ordinary identify envelope, not a direct profile write. Writing profiles directly would be a second, divergent way of building the same rows, and the first time an imported profile disagreed with an SDK-built one, a phone stored as 09123456789 in one path and +989123456789 in the other, nobody would be able to say which path was wrong. The account is always taken from the credential, never from the payload.

#Historical events

POST /v1/import/events writes events at past timestamps. It is panel-only too, and deliberately not on the collector: the collector authenticates with a write key, which by design ships inside mobile apps and website bundles, so anyone who views source has one. A public credential that could write events at arbitrary past timestamps is a credential that can rewrite a competitor's funnel.

JSON
{"events": [
  {"type": "track", "event": "order_completed", "user_id": "u1",
   "timestamp": "2025-04-02T10:00:00Z"}
]}
  • Body capped at 64 MiB.
  • An empty array is a 400.
  • More than 10,000 events in one request is a 400, and nothing reaches the bus.
  • Ten-minute timeout.
  • There is no dry_run on this endpoint. The field exists on the response struct but nothing sets it.

The permitted window comes from the account's own retention policy. Zero days of event retention means keep forever, which is a window of 3650 days. If the policy cannot be read the window falls back to 30 days. For what that number means, see personal data.

The difference from live ingest is the whole point: live ingest silently moves an out-of-window timestamp onto the window's edge and answers 200, which turns a year of orders into one enormous day. This endpoint refuses the row and names it by row number.

JSON
{
  "total": 10000, "accepted": 9997, "rejected": 3,
  "errors": [{"row": 412, "reason": "..."}],
  "truncated": false, "dry_run": false,
  "oldest": "2024-03-01T08:00:00Z",
  "newest": "2026-05-01T21:30:00Z"
}

oldest and newest are the range actually written, so an operator can confirm the import landed where they meant before running the next batch of half a million. The error list here is cut at 100, not 50. A partial failure is a 503 with the code partial_backfill.

For synchronous ingest from your own server, POST /v1/events is on the same management host and takes up to 500 events per call. Do not migrate history through it. The rules on this page do not apply there: the window is a fixed 30 days, the account's retention policy is never read, and anything older is silently moved onto the edge of that window and answered with a 200. A year of orders becomes one enormous day and nothing in the response says so. See sending from a server.

#What exists only in the panel

These are built, working and tested, and none of them has an address on api.segmentic.net:

  • Path analysis. POST /v1/reports/paths does not exist.
  • Churn, engagement and RFM scores, both the summaries and the member lists.
  • Event exploration and the account overview.
  • Journey and campaign time series.
  • Scheduled reports and the event debugger.
  • The saved funnel library. GET/POST /v1/funnels and the rest of its CRUD are on the control plane only; see the saved funnel library.
  • The dashboard builder engine, that is /v1/widgets/query, funnel, cohort and validate. Rendering a saved dashboard does not exist either.
  • The panel message log, searchable by date, campaign, user, recipient, message id, channel, outcome and test status. GET /v1/messages.csv and GET /v1/messages.json stream the complete filtered result rather than only the current page.
  • The billing ledger export and the audit log export.
  • GET /v1/segments/{id}/export, the only place xlsx is produced. It streams, and its cap is one million rows, again silently. It also carries a defect written into the code itself: the 200 and the headers are committed before the first row is read, so a mid-stream failure cannot become a 503 and the connection is dropped instead. There is no row count and no completeness flag on the wire.
  • CSV import and event backfill, described above.

#What is not possible

The honest list of what a management key cannot do:

  • Collect the bytes of a queued export.
  • Read one export job by its id.
  • Page beyond the 100 most recent export jobs.
  • Get xlsx out of the export queue.
  • Choose columns or a row cap for an export.
  • Read the analytics ceilings programmatically. GET /v1/reports/limits does not exist and GET /v1/capabilities publishes none of them.
  • Get a delivery and engagement rollup. GET /v1/reports/messages does not exist.
  • Discover the values of a property. No endpoint lists the distinct values of one; GET /v1/schema/events gives keys only.
  • Be told that an event name you wrote is wrong.
  • Choose the response language. Accept-Language is never parsed on this host and every localised string is Persian.
  • Get a distinct error code for an invalid report or a warehouse failure.
  • Receive a callback when an export or an import finishes. There is no webhook for either.

For the error shapes and codes, see errors. For the ceilings, see limits.

PreviousIn-app and inboxNextAPI reference

On this page

  • Funnel
  • Retention
  • Cost, timeout and the shape of an error
  • Exports
  • Importing data
  • What exists only in the panel
  • What is not possible

Segmentic

This page is written from the code