Deletion and erasure
Honor deletion requests within the deadline.
If you store anything you read from Tahoe, this page describes obligations rather than features. Tahoe tells you about three kinds of removal, and each needs a different response. Miss one and your copy keeps data that its owner, or the person it describes, asked to have removed.
Three signals
| Signal | What it means | What you must do |
|---|---|---|
Deletion events, such as applicant.deleted | A record was removed from Tahoe. | Delete your copy of that record. |
applicant.unsubscribed | The person asked not to be contacted. | Stop contacting them. Keep the record; stop the outreach. |
Erasure notices (data_subject.* events and GET /erasures) | The person exercised a right, or a removal was required. | Delete your copy within 7 days and stop processing them. |
Deletion events
There are six: job.deleted, application.deleted, applicant.deleted, sourced_profile.deleted, list.deleted and list_membership.removed.
Their payload carries identifiers, never the data that was deleted. Deleted jobs, applications, applicants and sourced profiles also carry a reason: user_deleted, workspace_deleted, data_subject_erasure, retention_expiry, legal_hold_release, provider_takedown (a source or platform required removal) or suppression.
What you do does not depend on the reason: delete the record. Log the reason so you can explain later why the record went, and so a required removal can be told apart from a person’s own request.
DELETIONS = {
"job.deleted": "job",
"application.deleted": "application",
"applicant.deleted": "applicant",
"sourced_profile.deleted": "sourced_profile",
"list.deleted": "list",
"list_membership.removed": "list_membership",
}
def handle_deletion(store, event):
object_type = DELETIONS[event["type"]]
item = event["data"]["object"]
reason = item.get("reason") # list events carry no reason
# Delete for real. A soft delete that keeps the personal data in a table
# that still answers queries is not a deletion.
store.purge(object_type, item["id"])
store.audit("deleted", object_type, item["id"], reason=reason)Unsubscribes are narrower, and absolute
applicant.unsubscribed means stop contacting, not stop holding. Keep the application record, because your customer still runs their pipeline, but never put that person into an outreach sequence again.
Erasure notices: the seven-day clock
This is the strongest signal in the API. It reaches you in two ways: on the erasure feed, and as the data_subject.suppression_applied and data_subject.erasure_completed events.
{
"object": "erasure",
"id": "ers_5Nx3jLm7Qd2s",
"action": "suppression_applied",
"reason": "data_subject_erasure",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"resources": [
{ "object": "sourced_profile", "id": "cnd_8Fj3kLm2Qd7s", "workspace_id": "wsp_4Kd8sPm2Qx7L" }
],
"action_required": "cease_processing_and_delete_your_copy",
"deadline_at": "2026-09-14T14:02:11.408Z",
"destruction_pending": true,
"destruction_reason": "retention_floor",
"at": "2026-09-07T14:02:11.408Z",
"subject_identified": false
}Four things about that notice:
deadline_atis seven days out. That is your window.destruction_pending: truemeans Tahoe itself must still keep the record under a legal retention rule. Some employment records must be kept for up to four years. This does not change your deadline.workspace_id: nullmeans the notice belongs to no single workspace. It applies to everyone who holds that person’s data, so it is sent to every key. Never filter erasure notices by workspace.- It may come without a suppression first. When Tahoe can destroy a record straight away, you receive only
erasure_completed. Delete on whichever notice reaches you first.
def honor_erasure(store, notice):
"""Delete our copy and record that we did, within the 7-day window."""
for resource in notice["resources"]:
store.purge(resource["object"], resource["id"])
# A tombstone that keeps the person OUT: a later sync must not bring
# them back the next time they appear in a list. Store the handle
# and nothing else about them.
store.suppress_forever(resource["object"], resource["id"])
store.audit(
"erased",
notice_id=notice["id"],
reason=notice["reason"],
deadline_at=notice["deadline_at"],
)
def on_data_subject_event(store, event):
# data_subject.suppression_applied and data_subject.erasure_completed
# carry the same notice as GET /erasures, in data.object.
honor_erasure(store, event["data"]["object"])Write a tombstone, and never expire it
Catch up in the right order
If your reader has been down, read the erasure feed first. It is small, it pages on its own, apart from your event watermark, and it is the feed where being behind matters most.
def catch_up(session, store):
# Erasures first. Reading lists and the feed may write rows, so the list
# of suppressed handles must be current BEFORE anything is written, or
# you bring back someone who asked to be removed.
consume_erasures(session, store)
drain(store)Checklist
- Read the change feed or take webhooks. Deletions arrive no other way.
- Handle all six deletion events: the five
*.deletedtypes andlist_membership.removed. - Check
unsubscribedbefore every send. - Read the erasure feed at least once a day.
- Delete on
suppression_appliedwithout waiting forerasure_completed, and onerasure_completedtoo if you still hold the record. - Never filter erasure notices by workspace.
- Keep a permanent list of removed handles, and never let it expire.
- Log what you deleted, when, and under which notice.