Docs Running it
Privacy: deletion and export
Deleting a person completely, and exporting everything Lyraflow holds about them.
DELETE /v1/persons/:id erases a person’s data — the same subject
GET /v1/persons/:id describes: the id is resolved through the same alias and
device-id lookup, so deleting a device id or a since-merged id reaches the
right person. Server-key only, like every endpoint below it that reads or
mutates a person’s data.
curl -i -X DELETE http://localhost:3000/v1/persons/user-42 \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
{
"request_id": 118,
"person_id": "user-42",
"suppressed_at": "2026-08-07T09:30:00.000Z"
}
person_id is the canonical id, which can differ from the one you sent —
deleting an id that was later merged into another still resolves to, and
erases, the survivor of that merge. suppressed_at is the boundary: events at
or before it stop appearing anywhere, immediately.
The boundary belongs to the person, not to the single id you named, and a
person is every id merged into them. So the boundary that applies to a read
can move as identities merge: if two people who were each deleted at different
times are later merged with /v1/alias, the surviving person carries the
later of the two boundaries, and events after the earlier deletion but at
or before the later one become hidden too.
That direction holds for the profile read and the export, which resolve the
whole merged group and take the strictest boundary in it. It does not hold
for segment counts and member lists. Those resolve a person through the
identity dictionaries and read whichever person the merge produced — so merging
a recently-deleted person into one deleted earlier can make some of the first
person’s erased events countable in a segment again, until the purge worker
removes the rows for real. This only ever concerns subjects who have already
been deleted, it is bounded by the purge (usually under a minute), and no
never-deleted person is affected. If you need the guarantee to be absolute
rather than eventual, wait for GET /v1/deletions/:id to report completed
before treating a deletion as final — which is the right thing to do anyway,
since only the purge actually removes data.
Deletion is asynchronous. The moment the API answers 202, the person’s past
data stops appearing in segment counts, member lists, profile reads and
exports — that is the suppression list, and it takes effect immediately,
including for a /v1/segments/preview result already sitting in the
in-process cache (see Segments above): a DELETE clears that
project’s cached entries as part of the same request. The rows are then
erased for real by a worker inside the server process, usually within a
minute. Until it finishes, person-level aggregates (first_seen, last_seen,
event counts) can still reflect erased events for someone whose activity
straddles the deletion instant, because those are pre-aggregated per month
and a month cannot be split. Event-level reads are exact throughout. Poll
GET /v1/deletions/:id for status: "completed".
A saved segment’s last_count does not know a deletion happened. It is a
snapshot from whenever the segment was last run (POST /v1/segments/:id/preview
or its own creation), not a live figure — a deletion changes what an ad hoc
/v1/segments/preview reports on the very next call, but it does not touch
last_count on any saved segment, which stays exactly as stale as it already
was until something explicitly re-runs that segment. This is true regardless
of caching; it is simply what “snapshot, not a live count” already meant.
Suppression is scoped in time, not permanent. Erasure is a right to have past data deleted, not a promise never to be measured again — if the same user keeps using your application, they eventually reappear as a person with a history of their own.
But that history does not start at the 202. Under suppression alone,
an event recorded between the 202 and the purge finishing genuinely is
visible — every read path filters by the boundary, and this new event is
after it. The purge, though, is not boundary-aware: by design, it deletes
every event the person has, with no “at or before suppressed_at” clause
— honouring the boundary here would mean keeping the identity bindings that
say those events are this person’s, and unsuppressed bindings for a deleted
person are the exact leak the purge’s step order exists to prevent. So an
event landing in that gap is shown by every read path for the minute or so
the purge takes, and then erased along with everything older. The person’s
surviving history begins after the purge completes, not after the
request is accepted, and activity recorded in that gap does not survive — it
is erased with the rest. Requesting deletion again moves the boundary
forward and erases whatever accumulated since, including while a previous
request is still waiting on the purge worker, which is exactly the case an
operator re-requesting after a failed attempt needs to work. If a previous
request failed part way through — its events already erased, its identity rows
not — the repeat DELETE reopens that request and returns its original
request_id, instead of reporting the now-eventless person as 404. Once the
purge has actually finished, a repeat request for a person with no activity
since then finds nothing left to erase, and answers 404 like any other id
nothing has recorded.
Not covered: backups. Lyraflow deletes from the live stores it manages. A backup you took before the deletion still contains the person’s data, and restoring it will restore them — the suppression list itself is in Postgres and is backed up with it, so a restored person stays hidden from queries, but their rows are back. Rotating or re-taking backups after a deletion is the operator’s responsibility, and this is stated rather than pretended.
One exception to “stays hidden”: a device the erased person used, later bound to a genuinely DIFFERENT person, then a backup from before the deletion restored after that. The erased person’s anonymous activity on that device is attributed by device id when nothing else claims it; once someone else’s identity has since taken over that device, a restored anonymous event resolves to the NEW person instead, and nothing hides it — it appears as theirs, inflating their history. This needs all three of the purge having completed, that device rebound to someone else, and a backup predating the original deletion restored afterwards; a person’s own identified events (anything carrying its own user id) are unaffected regardless. Narrow, and stated rather than silently left for an operator to discover.
A deletion request with no subject is 404:
{ "error": "person_not_found" }
Read that 404 carefully — it does not mean “this id was never seen.” It
means no events could be resolved for a person from the id you sent. Erasure,
export and the profile read all cover people the identity graph knows
about, and an id only enters that graph through /v1/identify (or /v1/alias).
A purely anonymous visitor — an anonymous_id that has sent events but has
never been identified — cannot be resolved from that anonymous_id alone, and
answers 404 here even though their events are sitting in the store. If you
have been handed a raw cookie or device id by a data-subject request and get a
404, that is the case to rule out first: it is not evidence the id was never
recorded. Resolve it to a user id (anything you have ever called /v1/identify
with for that device) and request erasure for that instead. Widening resolution
to cover never-identified visitors is a change we intend to make; today it is a
documented limit rather than a silent one.
401 for a missing or invalid server key.
Checking on a deletion
GET /v1/deletions/:id reports what happened to a request returned by the
DELETE above:
curl -s http://localhost:3000/v1/deletions/118 \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
{ "status": "completed", "requested_at": "2026-08-07T09:30:00.000Z", "completed_at": "2026-08-07T09:30:41.000Z" }
status | Meaning |
|---|---|
pending | Waiting for the purge worker. If an attempt has already failed, error carries why and the request is waiting to be retried |
in_progress | A worker is erasing this person’s rows right now |
completed | Erasure finished — completed_at is set |
failed | The worker gave up after repeated attempts; error carries the last one. This is not an API error — the request was accepted, and this is telling you it did not finish |
failed does not mean nothing happened. The purge erases in a fixed order
— events first, identity last — so a request that failed part way through has
usually already deleted some of the person’s data. Treat failed as “partly
erased, stopped”, never as “no change”. The recovery is to send the same
DELETE /v1/persons/:id again: it picks the unfinished request back up and
returns 202 with the same request_id, rather than 404-ing a person
whose events are already gone. Keep polling that id. Rejected payloads that
name any of the person’s ids are erased in the same purge, matched the way
the export finds them.
:id belonging to another project, or to no request at all, is 404 with
{ "error": "deletion_not_found" } — never 403, which would confirm the id
exists. A non-numeric :id is 400 with { "error": "invalid_deletion_id" }.
Exporting a person
GET /v1/persons/:id/export answers a subject-access request: everything
Lyraflow has recorded about one person, as streamed NDJSON — one JSON object
per line, not a single JSON document. Server-key only, like every endpoint in
this section.
curl -s http://localhost:3000/v1/persons/user-42/export \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
{"type":"person","person_id":"user-42","ids":["user-42","visitor-1"],"traits":{"plan":"pro"},"first_seen":"2026-08-01T12:00:00.000Z","last_seen":"2026-08-06T09:30:00.000Z"}
{"type":"event","event_id":"…","timestamp":"2026-08-01T12:00:00.000Z","event_name":"page","properties":{…},…}
{"type":"event","event_id":"…","timestamp":"2026-08-06T09:30:00.000Z","event_name":"import_started","properties":{…},…}
{"type":"rejection","received_at":"2026-08-03T04:00:00.000Z","reason":"validation_failed","detail":"…","payload":"…","match":"quoted-id-substring"}
{"type":"end","events":2,"rejections":1}
Four line shapes. The first line is always type: "person" — the same
identity GET /v1/persons/:id returns (person_id, ids, first_seen,
last_seen), plus traits, and without that read’s events count: the
count moved to the terminator below, where it can be checked against what
was actually received. Then one type: "event" line per event, oldest
first, carrying every field recorded for it. Then any type: "rejection"
lines. The last line is always type: "end", and events is the number of
event lines that actually preceded it.
type: "rejection" lines are payloads that were refused at ingest
(validation_failed, too_many_properties and the other reasons the
Feed’s Rejected tab shows) and never became events. A refused
payload has no identity — it is stored as the raw text that failed to parse —
so these are found by searching that text for each of the person’s ids in
quoted form, exactly as a deletion finds them to erase. That is a heuristic
and match says so; a rejected payload that mentions this person’s id inside
someone else’s data would be included too. Rejected payloads are kept for 30
days and then dropped by the table’s own TTL, so an export taken later shows
none. rejections on the end line counts them the way events counts the
event lines.
The export is a stream, and it terminates itself. The response status and
headers are sent before the first line, which means a failure part-way
through cannot be reported as an HTTP error — the connection would already
be committed to 200. Instead, on a mid-stream failure the response simply
ends without ever writing the final end line. A response without a
final {"type":"end","events":N} line is incomplete and must be discarded.
Always check for that line, and check that its events count matches the
number of event lines you actually received, and its rejections count
matches the number of rejection lines you actually received — a truncated
response that happens to look complete is exactly the failure a
subject-access export cannot afford to miss.
The export honours deletion the same way the person read does: a person who
has been deleted exports only the events recorded after the deletion
boundary, and a person with nothing left after that boundary is 404, the
same { "error": "person_not_found" } an unresolvable id gets. As with
DELETE, that 404 also covers a visitor who has never been through
/v1/identify — see Privacy: deletion and export above, where the same limit is
described in full. An anonymous_id alone is not enough to export a subject.
Traits are omitted entirely once a boundary exists — a trait carries no
event time (it is the latest value known for that key, not a timestamped
fact), so it cannot be split at the deletion instant the way an event can;
returning it would be a way to read back exactly what the deletion asked to
remove.
The same device-window cap GET /v1/persons/:id enforces applies here too:
past 200 device windows the export answers 400
person_history_too_fragmented, identically to the person read. Unlike
DELETE /v1/persons/:id, which chunks and must never refuse to erase the
most fragmented people, refusing to render an export for them is an
acceptable answer — nothing about their data goes unerased because of it.
Every query behind this endpoint runs under a ceiling — 300 seconds and the same 4 GiB memory ceiling segment queries use. For almost every person this is invisible; for someone with an exceptionally large recorded history, hitting one is expected behaviour, not a bug, but which one you hit produces a different, distinguishable symptom, and it matters which:
- The summary query and, when it runs, the traits lookup both
execute before the response is sent at all. If either one hits a
ceiling, the export never starts: you get a
503— the same generic failure response every other endpoint gives an internal error, withretry-afterset. - The per-event and rejection queries both stream after the
response has already started. If either hits a ceiling partway through,
the export cannot become an HTTP error any more — the stream simply ends,
without its final
endline, exactly like any other mid-stream failure above. This is what the discard rule was written for.
If you self-host and an export is being cut short for one particular
person, an unusually large history hitting one of these ceilings is the
first thing to check: a 503 means it never started, a response missing
end means it started and was cut short — before assuming either is a bug.
401 for a missing or invalid server key.
This page is the Privacy: deletion and export section of the product
README at v0.15.0. It is generated from that file rather than
written here, so a correction belongs upstream.