Guides

Deletion and erasure

If you store anything you read from Tahoe, this page describes obligations rather than features. Three signals, three different responses.

Three signals

SignalMeansYou must
*.deleted eventsA record was removed from Tahoe.Delete your copy of that record.
applicant.unsubscribedThey asked not to be contacted.Stop contacting them. Keep the record; stop the outreach.
data_subject.suppression_appliedThey exercised a right, or a takedown was required.Delete your copy within 7 days and stop processing them.

Deletion events

job.deleted, application.deleted, applicant.deleted, sourced_profile.deleted, list.deleted and list_membership.removed.

Their payload carries identifiers and a reason, never the data that was deleted. The reason is one of user_deleted, workspace_deleted, data_subject_erasure, retention_expiry, legal_hold_release, provider_takedown or suppression.

Your action does not vary by reason — delete the record. Log the reason so you can answer why later, and so a provider takedown is distinguishable from a person’s own request.

Deletion handlers
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"]]
    handle = event["data"]["object"]["id"]
    reason = (event["data"]["object"].get("reason")
              or event["data"].get("reason"))

    # Delete, do not soft-delete into a table that still answers queries. A
    # tombstone that keeps the personal data is not a deletion.
    store.purge(object_type, handle)
    store.audit("deleted", object_type, handle, reason=reason)

Unsubscribes are narrower, and absolute

applicant.unsubscribed means stop contacting, not stop holding. Keep the application record — your customer still needs to run their pipeline — but that person must not enter an outreach sequence again.

Suppression: the seven-day clock

This is the strongest signal in the API. Read it from the erasure feed or as the data_subject.suppression_applied event.

A suppression notice
{
  "object": "erasure",
  "id": "ers_5Nx3jLm7Qd2s",
  "action": "suppression_applied",
  "reason": "data_subject_erasure",
  "action_required": "cease_processing_and_delete_your_copy",
  "workspace_id": null,
  "at": "2026-09-07T14:02:11.408Z",
  "deadline_at": "2026-09-14T14:02:11.408Z",
  "destruction_pending": true,
  "destruction_reason": "retention_floor",
  "resources": [
    { "object": "sourced_profile", "id": "cnd_8Fj3kLm2Qd7s", "workspace_id": "wsp_4Kd8sPm2Qx7L" }
  ]
}

Three things about that payload:

  • deadline_at is seven days out. That is your window.
  • destruction_pending: true means Tahoe itself is still holding the record under a retention floor it cannot lawfully ignore — employment records have statutory retention in several jurisdictions, up to four years. This has no bearing on your deadline.
  • workspace_id: null means the notice belongs to no single workspace — a pool suppression obliges every holder of that person’s data, so it is delivered to every credential.
Honouring a suppression
def honour_suppression(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, not a soft delete: this must keep the person OUT, so a later
    # sync cannot re-harvest them the next time they appear in a list. Store the
    # handle and nothing else about them.
    for resource in notice["resources"]:
        store.suppress_forever(resource["object"], resource["id"])

    store.audit(
        "suppressed",
        notice_id=notice["id"],
        reason=notice["reason"],
        deadline_at=notice["deadline_at"],
    )

Write a tombstone, and never expire it

Reconcile, do not assume

If your consumer has been down, catch up on the erasure feed first. It is small, cursored independently of your event watermark, and it is the feed where being behind actually matters.

Catch-up order after an outage
def catch_up(session, store):
    # Erasures first. If we are going to re-read lists and re-create rows, the
    # suppression list must be current BEFORE we write anything, or we will
    # re-harvest someone who asked to be removed.
    consume_erasures(session, store)
    drain(session, store)

Checklist

  • Consume the change feed or webhooks. Deletions arrive no other way.
  • Handle all six *.deleted / *.removed events.
  • Check unsubscribed before every send.
  • Consume the erasure feed at least daily.
  • Act on suppression_applied, never on erasure_completed.
  • Do not filter the erasure feed by workspace.
  • Keep a permanent, never-expiring suppression list.
  • Log what you deleted, when, and under which notice.