Skip to content

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

SignalWhat it meansWhat you must do
Deletion events, such as applicant.deletedA record was removed from Tahoe.Delete your copy of that record.
applicant.unsubscribedThe 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.

Deletion handler
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.

An erasure notice
{
  "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_at is seven days out. That is your window.
  • destruction_pending: true means 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: null means 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.
Honoring an erasure notice
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.

Catch-up order after an outage
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 *.deleted types and list_membership.removed.
  • Check unsubscribed before every send.
  • Read the erasure feed at least once a day.
  • Delete on suppression_applied without waiting for erasure_completed, and on erasure_completed too 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.