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

Personal data: export and deletion

What to do when a person asks to see or delete their data, and what Segmentic does on its own.

An erasure request cannot be filed over the API. Every /v1/privacy/* route and /v1/settings/retention lives on the panel's control-plane listener, which is deliberately not addressable from outside the overlay network. There is no privacy endpoint on api.segmentic.net at all. If you wanted your users' requests to reach Segmentic automatically, that route does not exist today and a person has to file them in the panel.

DEPLOYMENT
Customer systemsSource of truth
Segmentic cloudHosted inside Iran
On-premiseYour infrastructure
SEGMENTICCustomer data boundaryRegion, access and retention stay explicit
GOVERNANCE
Access requestsExport customer data
ErasureDelete within scope
Audit trailRecorded operations
The Segmentic customer data boundary across deployment, access, erasure and audit operations

In the legal relationship you are the controller and Segmentic is the processor. This page states exactly what the platform does and does not do, so that your own privacy policy does not promise something the code will not perform.

#When somebody asks what you hold about them

There is no "give me everything about this person" endpoint. Assembling an answer to a subject access request means five separate routes, all of them profile.read:

RouteWhat it returnsCap
GET /v1/profiles/{user_id}the profile: traits and computed columnsone profile
GET /v1/profiles/{user_id}/timelinethe person's own eventsthe 100 most recent
GET /v1/profiles/{user_id}/messageswhat we sent, and what we deliberately did not sendthe 50 most recent
GET /v1/profiles/{user_id}/devicesinstalls and their push stateall
GET /v1/profiles/{user_id}/preferenceschannel and topic settingsall

The cap of 100 means the timeline is not the full history. If that person has a thousand events, this route does not answer a subject access request completely, and there is no other way to fetch the rest through the API.

An export of kind profiles covers the whole account, not one person. It carries no filter for a single id. So building a subject access response is manual work: open those five views and read them.

The "not sent" rows in the message history are the valuable half of that list. "We sent nothing because you unsubscribed from this topic on the 3rd" is the answer to the complaint itself, and it exists only because the send path records refusals as carefully as it records sends.

#Filing an erasure

The permission split here is sharper than on any other screen, because these are the only two endpoints on the platform that destroy a customer's data on purpose.

RoutePermission
GET /v1/privacy/erasuresprofile.read
GET /v1/privacy/erasures/{id}profile.read
POST /v1/privacy/erasuresprofile.write
POST /v1/privacy/erasures/{id}/rejectsettings.write
GET /v1/settings/retentionsettings.read
PUT /v1/settings/retentionsettings.write

Filing an erasure names a person, so it takes the same pair of permissions that guards every other route to an individual. Retention is different in kind: it is one number that decides how much of the account's own history survives, so it is settings.write, held by owners and admins and nobody else. A marketer who can send to two million people still cannot decide that last year's events stop existing.

Rejecting a request is settings.write as well, because a refusal to honour a statutory request is a decision about the account rather than about the person.

#The request

You cannot issue these with curl. What follows is the request the panel makes on the control plane, shown here so you know exactly what is recorded and what comes back.

HTTP
POST /v1/privacy/erasures HTTP/1.1
Content-Type: application/json

{"kind": "phone", "identifier": "+98 912 345 6789"}

kind is one of user_id, email or phone, and omitting it means user_id. The identifier is normalised before it is hashed rather than trusted as sent: two spellings of one number that hashed differently would be two obligations for one person, with two deadlines, and the second would never be found by a search for the first.

  • user_id is only trimmed. It is your own key and case may well be significant in it.
  • email is lowercased and must contain an @ that is neither the first nor the last character.
  • phone keeps only its digits, and Persian and Arabic digits count. The four shapes 09123456789, +989123456789, 00989123456789 and 9123456789 all resolve to one. An erasure that missed three of the four would report success while leaving the person perfectly reachable.

The response is a 202:

JSON
{
  "id": 41,
  "subject_hash": "Q1p4bF9y...",
  "subject_ref": "09123456789",
  "subject_kind": "phone",
  "status": "pending",
  "requested_by": "member:12",
  "requested_at": "2026-08-07T09:20:00Z",
  "due_at": "2026-09-06T09:20:00Z",
  "report": {
    "profiles": 0, "events": 0, "messages": 0, "devices": 0, "consent": 0,
    "journey_state": 0, "identities": 0, "suppressions_kept": 0,
    "segment_membership": 0
  },
  "attempts": 0,
  "status_label": "در صف",
  "kind_label": "شمارهٔ همراه",
  "overdue": false,
  "removed_total": 0,
  "days_remaining": 30,
  "requested_label": "۱۶ مرداد ۱۴۰۵"
}

The failures:

ConditionStatus
the identifier is unreadable, or kind is unknown400
an outstanding request already exists for this person409
no hashing key is configured on this install503

That 503 is deliberate. The hash is the whole record; without a key there is nowhere to put the permanent proof, so the request is refused rather than filed as something that could never afterwards be reported on. Reading the queue still works in that state: an install that cannot file new requests must still be able to show the ones already on the books.

#The queue and the deadline

due_at is written at insert as thirty days after requested_at, so a policy change later does not move deadlines that were already running. overdue is computed on the server, not in the browser: "has the statutory month elapsed" must not depend on the clock of whichever laptop is looking at the screen.

HTTP
GET /v1/privacy/erasures?limit=50&offset=0 HTTP/1.1
JSON
{"erasures": [], "total": 0, "overdue": 0}

overdue is surfaced separately so the screen can lead with it. A list sorted by date buries the one request that has blown its deadline. limit runs from 1 to 200 and anything outside that falls back to 50.

Five statuses exist: pending, running, completed, failed, rejected. The janitor looks at the queue every minute and drains up to 50 in a row, so a support desk that filed forty requests on a Sunday does not wait forty minutes to start the fortieth. A failing request is retried up to 5 times.

If nobody matches the identifier the request is recorded as completed, not failed: there is nothing of theirs to remove, and a request that retried forever against an id that was never here would sit in the queue past its statutory deadline looking like a breach.

The janitor is deliberately its own binary. Everything it does is either a ClickHouse mutation or a large Postgres delete, and both compete for exactly the disk and merge capacity the ingest path needs. It is also the binary a nervous operator can stop without halting sending.

#Rejecting a request

HTTP
POST /v1/privacy/erasures/41/reject HTTP/1.1
Content-Type: application/json

{"reason": "این حساب تحت نگه‌داشت قانونی پرونده‌ی فلان است"}

reason has to be at least 10 characters or it is a 400. A refusal with no stated reason is the one an auditor asks about first, and "the operator did not say" is not an answer a controller can give.

Only a pending or failed request can be rejected; anything else is a 409. Rejecting clears subject_ref as well: a refused request still must not become a store of the identifier. The refusal itself is recorded rather than deleted, because a refusal that leaves no trace is indistinguishable from a request nobody read.

JSON
{"id": 41, "status": "rejected"}

#What is deleted

The order is the contract:

  1. The email address is added to the suppression list first, so a failure part way through leaves the person protected rather than merely half deleted.
  2. Postgres next, in one transaction, so the operational state is either all gone or all present.
  3. ClickHouse last, because a mutation is asynchronous and cannot be part of anybody's transaction.

From Postgres, these tables are cleared on user_id: devices, inapp_messages, journey_instances, journey_timers, campaign_timers, message_log, messenger_identities, topic_consent, user_consent, webpush_subscriptions. Device tokens go first because they hang off device_id rather than the user, and once the devices are gone there is nothing left to join them to, so the tokens would sit there being pushed to forever.

That list is written out by hand rather than discovered from the catalogue, because information_schema would also return audit_log, memberships and sessions, whose user_id is an integer naming a member of your own staff. An erasure that matched on column name would delete the account of whichever employee happened to share an id with the erased customer.

From ClickHouse: profiles, events, segment_members, daily_user_stats, engagement, message_touch. daily_user_stats holds only counts, but it is keyed on user_id, so a row in it is a statement that this person existed on this day, which is exactly what an erasure is supposed to remove. engagement and message_touch are the attribution ledgers: "this person opened that message" is as much a fact about them as the purchase was.

Then the pre-login history: events carrying an empty user_id, reachable only through the anonymous ids in identity_map. That happens before identity_map is cleared, and the order is the whole of it. The first version of this code deleted the map first, so the subquery matched nothing, the delete removed nothing and reported success: the person's browsing history from before they signed in, which is a large part of what "forget me" means, would have survived every erasure on the platform silently. An integration test caught it.

identity_map goes last, because it is the index into everything above.

Every mutation runs with mutations_sync = 2, which waits for every replica to finish before returning. Slower, and the only setting under which "completed" means completed; the default returns as soon as the mutation is queued, which would have the report succeed while the rows are still there.

The report is counts, not rows. A report that listed what it deleted would be a copy of the deleted data, sitting in the table whose whole purpose is to record that the data is gone.

No shipped install runs without a warehouse: the API and the janitor both open a ClickHouse connection at startup and refuse to start if it does not answer. The store does carry a no-warehouse branch, and it is silent rather than self-describing. It skips the ClickHouse step and leaves profiles, events, identities and segment_membership at zero, which is the same report a warehouse that was searched and held nothing would produce. The report is nine counters with no field for the difference, so whether that step ran is a fact about the deployment, not something the report can be read for.

#What is kept, and why

An erasure deletes user_consent and topic_consent. That means the person's push, SMS and topic opt-outs go with them. Only email is protected, by a permanent suppression row. Re-import the same phone number tomorrow and that person is reachable by SMS again, unless they had previously replied «لغو», which is recorded against the number itself and is left untouched.

After a completed erasure, these remain on the system:

  • The request row itself. subject_hash, subject_kind, requested_by, the dates and the report counts all survive; subject_ref, the readable identifier, is cleared the moment the work finishes. Holding the identifier after the work is done means the table recording the deletion is itself a record of the person who asked to be deleted.
  • That person's email address, forever. The suppression row is written with the reason erasure and an expiry of NULL, whatever it was before. We state this plainly because it matters in a legal document: after an erasure, Segmentic still holds the email address. Without it a soft-bounce row for the same address would eventually age out and re-permit mail to somebody who asked to be forgotten.
  • A number that previously replied «لغو». The SMS opt-out table is keyed by the number itself, not by user id, and an erasure does not touch it. That is deliberate: the reply arrives from a handset, and the person behind it may match no profile, may match several, or may match one that gets deleted and re-imported tomorrow. Deleting that row means the suppression evaporates in all three cases.
  • These tables, which an erasure never reaches: the engagement score and the churn score, both in Postgres and in their ClickHouse mirrors; on-site survey responses; the relay delivery queue, which keeps a JSON copy of the event itself. None of them clears the erased user's row, and none of them appears in the report counts.
  • The RFM scores and the journey trigger memberships are rewritten wholesale on each recomputation, so the person's row disappears on the next run by itself.
  • The daily active-user meter for Tehran, which is an aggregate state rather than rows about people. It is not rewritten, so an erased person is still inside the distinct count for past days.
  • Export files already built. An erasure does not touch them. They expire on their own seven-day clock. If somebody has already downloaded one onto a laptop, that copy is beyond our reach.

The application log line records the request id and the role of whoever filed it, and never the identifier itself. That line is the audit trail, and one that named the person would be a copy of exactly what is about to be deleted.

The hash is an HMAC over the account id and the normalised identifier, keyed from the install's secret. Being keyed is the whole point: an email address carries far too little entropy to survive SHA-256 on its own, and a table of plain hashes is reversible with a wordlist in minutes, so it would be a table of email addresses wearing a hat. The account id is inside the MAC, so the same address at two customers produces two different tokens; without that, a leaked hash table would let one customer test whether a given person is also a customer of another.

#Retention

Erasure is owed to one person who asked. Retention is owed to everybody, whether or not anyone asks, and a platform that implements only the first passes the audit question and still holds four years of somebody's browsing history.

HTTP
GET /v1/settings/retention HTTP/1.1
JSON
{
  "policy": {
    "events_days": 365,
    "messages_days": 180,
    "bounces_days": 90,
    "inactive_profile_days": 0,
    "last_swept_at": "2026-08-06T02:14:00Z",
    "updated_at": "2026-05-02T11:00:00Z",
    "updated_by": "member:1"
  },
  "min_days": 30,
  "max_days": 3650
}
HTTP
PUT /v1/settings/retention HTTP/1.1
Content-Type: application/json

{"policy": {"events_days": 365, "messages_days": 180,
            "bounces_days": 90, "inactive_profile_days": 0}}
FieldWhat it removes
events_daysraw behavioural events
messages_daysthe send ledger: who was messaged, when, and why they were not
bounces_daysbounce and complaint reports
inactive_profile_daysprofiles nobody has seen since

Zero means keep forever, on every field. That is the right default for a platform that must not delete a customer's data because somebody left a box empty: deletion is opt-in, always, and an unset policy is not one that deletes nothing by accident, it is one that deletes nothing on purpose.

Any non-zero value has to be at least 30 days and at most 3650. A value outside that is refused with a reason rather than clamped. Silently clamping is how somebody comes to believe they set a limit they did not, and here the belief is about how long their customers' data survives.

The floor of 30 days is not there because a shorter window is technically hard. Below roughly a month an account cannot answer "what happened last month", every month-on-month report is empty, and, the part that actually causes support tickets, a campaign's own attribution window outlives the events it is measured against, so the campaign reports zero conversions from real purchases. The ceiling of 3650 days is the outer edge of any Iranian commercial record-keeping obligation; past it the number is not a policy, it is a forgotten field.

The janitor looks for an account to sweep every 15 minutes, sweeps any one account at most once every 24 hours, and touches at most 5 accounts per pass. A failed sweep does not write last_swept_at, so the next pass retries it; marking a failed sweep as done is how an account's retention silently stops working.

On events, any wholly expired month goes with DROP PARTITION, which unlinks files and costs almost nothing. The events table is partitioned by (account, month) and the account leads deliberately so that this is possible. Only the month straddling the cutoff takes a real mutation, and that one is exact, so the policy means the number of days it says rather than somewhere between N and N plus thirty.

The email suppression list is deliberately absent from this policy and is never swept. A block list that expires re-permits mail to an address that bounced, complained, or belongs to somebody who asked to be forgotten, which is the single failure most likely to cost a sending domain its reputation, and the one an operator is least likely to notice.

#The platform's own fixed limits

Independent of your policy, a few numbers sit in the warehouse schema itself:

WhatHow longIndependent of your policy
the events table400 daysyes
the engagement ledger400 daysyes
rejected claims in engagement (a bad signature and the like)30 daysyes
an export file7 daysyes
the event debugger's recording window30 minutesyes

So a retention policy of 3650 days on events does not give you 3650 days of events. The table itself drops rows after 400.

In the other direction, these tables have no TTL and the retention sweep does not touch them: daily_user_stats, segment_members, identity_map, message_touch. A ninety-day events policy therefore still leaves one row per user per day in daily_user_stats, forever. That row is not the event itself, it is that person's daily counts, but it still says that person was active that day.

#The open pixel and click tracking

Email clicks pass through no redirect of ours. Links inside a message carry two parameters, sg_mid and sg_t, and the recipient lands directly on your own site, where the SDK that is already there reports the arrival. The reason is that a corporate mail gateway follows every link in every message while scanning it for malware, so a counting redirect would report most of a business list as having clicked.

What that means for your privacy policy: Segmentic sees a click only when your own site or app sends the event. If the landing page has no SDK there is no click data at all. That event is recorded by your system, with whatever your system attaches to it.

An open is different, because it has no landing page. It needs a pixel, and that pixel is served on the ingest host:

HTTP
GET /e/o?sg_mid=c104.u_9137&sg_t=8mBv2h7oQ1w HTTP/1.1
Host: in.segmentic.net

The response is always a 1 by 1 transparent GIF, 43 bytes. That holds for a forged token, for a message we have no record of, and for our own database being down. A broken image in the middle of a marketing email is the most visible defect a recipient can see, and none of those failures is theirs.

When the signature verifies and the recipient resolves, a message_opened event is recorded carrying:

  • the recipient's user id and the message id.
  • the IP address of whatever fetched the image, and the country, region and city derived from it.
  • the User-Agent string of that request, which is parsed for browser, operating system and whether it is a bot.
  • the time, from our clock and never from a header. The lag between send and open is one of the few honest numbers email has, and a value the claimant controls is not a measurement.

The pixel is served with Cache-Control: no-store and Pragma: no-cache, because Gmail and the other large providers proxy and cache remote images. Without those headers the proxy fetches once, serves its copy forever, and every reopen after the first is invisible.

Open tracking is only enabled where there is a signing token. Without one the pixel is not injected at all: anyone can fetch a URL, so counting an unsigned hit would inflate the one number this channel is judged on.

Unsubscribe is on the same host, and the thing to know is that unsubscribing with a GET does not unsubscribe. GET /e/u renders a page with a button that POSTs. Security scanners at most Iranian banks and large retailers follow every link in every incoming message before the recipient ever sees it; a GET that opted people out would unsubscribe an entire company list the moment the campaign arrived, silently, and the customer's first sign of it would be a reach report that collapsed. POST /e/u is the RFC 8058 one-click path the mailbox providers themselves call, and it has no confirmation step by design. GET /e/p is the recipient's own settings screen.

What you have to disclose in your own privacy policy is at least this: marketing email contains a tracking image; the fact and time of an open are recorded along with the IP address and the mail client's user agent; links in the message carry identifiers that connect later activity on your own site to that message; and this is done through a processor.

#What every event stores

The IP address is stored in full. There is a column called ip on the events table, and no masking, no truncation and no hashing is applied to it. Where the install is configured to trust proxy headers, the leftmost value of X-Forwarded-For is taken; otherwise the address of the connection itself.

From that IP the country, region and city are derived and stored in separate columns, unless the SDK supplied a location of its own.

The rest of what an event can carry into the warehouse: the page URL, path, title and referrer, the utm_* parameters, the device type, model and manufacturer, the operating system name and version, the browser name and version, the network carrier, the locale, the timezone, the app version, and any property you set on the event yourself.

The raw User-Agent string is not stored on the events table; only what is extracted from it survives, plus the bot flag.

Profiles hold the email address, the mobile number, first and last name, gender, date of birth, national id, city, region and country, language, timezone, and your own map of traits. The profiles export deliberately carries neither ip nor national_id; see reports and exports.

The event debugger in the panel shows a live tail of raw incoming payloads: email addresses, order contents, and sometimes a phone number in a property nobody meant to include. That is why it takes profile.read, why its response sets Cache-Control: no-store, and why its recording window is 30 minutes.

What it records is single calls only, POST /v1/track and its siblings, plus webhook deliveries. The batch path is not recorded at all, and every shipped SDK batches, so an SDK install shows nothing on that screen. To confirm an app's events are arriving, the connect screen for that app is the tool that works.

#Anonymisation

There is no anonymisation endpoint. There is no mode anywhere that keeps a row and blanks its identifier. What exists is two things: deleting one person, and expiring by policy.

The only anonymisation primitive in the whole platform is the HMAC that records an erasure, described under what is kept. Its key is derived from the install's secret, and that secret has to be at least 32 characters or no key is built at all and filing an erasure returns a 503.

An anonymous visitor who never identified themselves cannot be erased by any request. Their events carry an empty user_id, and the only route to them is the anonymous ids that identity_map ties to a user id. If that browser was never connected to a user, there is no identifier for a request to name. Those events go when the retention policy or the table's own 400-day TTL takes them, not when somebody asks to be forgotten.

Bots are not deleted either. They are flagged with is_bot and left out of reports.

#What does not exist

The honest list, because a gap you know about is worth more than a plausible sentence you do not:

  • No privacy endpoint on api.segmentic.net. Not filing an erasure, not reading the queue, not reading or changing the retention policy. All panel only.
  • No "everything about this one person" export. The timeline caps at 100 events and the profiles export has no single-person filter.
  • No callback and no webhook when an erasure completes. You re-read the queue.
  • No way to erase an anonymous visitor who was never connected to a user id.
  • No per-person retention. The policy is account-wide only.
  • No anonymisation mode as an alternative to deletion.
  • An erasure does not reach the engagement scores, the churn scores, the survey responses or the relay delivery queue. If your undertaking is complete removal, those four are manual work today.
  • No suppression row is written for an erased person's mobile number. Only email gets that protection.
  • The counts inside an erasure report cover only the tables the erasure touched, and nothing else.

For how a person changes their own channels and topics, see consent and unsubscribing. For what goes into an event in the first place, see events.

PreviousWebhooksNextVersioning

On this page

  • When somebody asks what you hold about them
  • Filing an erasure
  • What is deleted
  • What is kept, and why
  • Retention
  • The platform's own fixed limits
  • The open pixel and click tracking
  • What every event stores
  • Anonymisation
  • What does not exist

Segmentic

This page is written from the code