Short answer: for a metered media account, freeze new usage and revoke tenant credentials before deleting user data, but retain a minimal billing and audit record until the invoice is final. The ordering is a control boundary, not a preference: deletion is hard to reverse, while a credential can be revoked and reissued if an authorized export still has to run.
The constraint is easy to miss in a busy offboarding queue. A customer may request erasure at 14:02, while an ingest worker receives one last event at 14:02:03 and the invoice job closes at 14:03. If the API key disappears after the data tables, that late event can be accepted without an owner. If the key disappears first and the whole account is rejected, you can lose usage needed to produce a defensible invoice. The design has to separate traffic admission, personal data, and metering evidence.
Start with an offboarding state machine
Treat offboarding as durable state, not a script that runs once. A useful sequence is active -> draining -> frozen -> erased, with an explicit billing_hold branch when an invoice is still open. Every transition gets an idempotency key and an audit event. Retries then advance the state instead of repeating destructive work.
In draining, stop issuing credentials and mark the tenant read-only. Workers finish messages that were accepted before the cutoff, but new API calls receive a clear refusal and a correlation ID. In frozen, revoke every active key, invalidate sessions, and close the usage window. Only after those checks pass should personal records enter the deletion queue.
Freeze first.
That ordering also handles a common race: a key may be cached in a gateway for a few seconds. Revocation should therefore be checked at the authorization boundary and propagated to workers, while the tenant's cutoff timestamp is checked again when a usage event is written. A request that crossed the cutoff is either charged under a documented grace rule or refused; it must not silently become unowned traffic.
Keep one narrow record after erasure: tenant ID, invoice ID, usage totals, cutoff and deletion timestamps, and hashes of the relevant audit events. Do not keep message bodies, recipient addresses, phone numbers, or raw tokens in that record. Retention is a policy decision, so let legal and finance set the period instead of burying it in application code.
What should a SaaS tenant offboarding sequence preserve for billing?
Metering is an accounting stream, not a copy of product data. For each event, store an immutable event ID, tenant ID, quantity, unit, event time, ingestion time, and a schema version. A deduplication key makes retries harmless. The payload can be reduced to those fields before it reaches the long-lived ledger.
The ledger should have a close operation with a monotonic version. A late event can be placed in an adjustment window, but it cannot mutate a previously issued invoice in place. This is where spend ceilings and refused traffic pull in different directions:
| Choice | Spend ceiling | Refused traffic | Operational consequence |
|---|---|---|---|
| Hard cutoff at freeze | Predictable | Higher | Return 401/403 or a tenant-closed response and record the refusal |
| Short grace window | Less predictable | Lower | Keep authorization closed, but accept already-authenticated work until a timestamp |
| Post-close adjustments | Bounded after review | Lowest during drain | Reconcile late events on the next invoice with an auditable adjustment |
For media workloads, I usually choose a short, measured grace window for already-accepted uploads and a hard cutoff for new API credentials. It protects a customer's final usage count without leaving an open door for a replayed token. Your mileage may vary when contracts require immediate cessation or when content deletion must be provable within minutes.
Three numbers make the policy testable: the freeze timestamp, the maximum grace duration, and the invoice close version. Emit them in the event log and expose them to the reconciliation job. A dashboard that shows only “deleted” hides the exact race you will need to explain during a dispute.
Here is the longer incident-shaped case I use in design reviews. A documentary customer schedules an export at 14:00, asks for account closure at 14:02, and has an invoice close at 14:03. The export service accepts a manifest at 14:01:59, the gateway caches the credential for five seconds, and a queue retry arrives at 14:02:04. If the deletion worker removes the user and project rows first, the retry can still carry a valid tenant claim but no durable owner; if revocation happens first without a drain rule, the accepted manifest can be discarded and the final invoice will be short. The coordinator therefore records the cutoff, marks the tenant draining, rejects newly signed requests, revokes the key identifier, lets only pre-cutoff work finish, and writes a compact usage event. The ledger closes on a version, not on wall-clock luck. A reconciliation job then checks event IDs, applies any documented adjustment, and records why it was allowed. This is slower than a single DELETE statement, but it gives support, finance, and privacy reviewers the same timeline. No shortcut.
How do API key revocation and user-data deletion interact in Node.js SaaS?
The application boundary should make the safe order difficult to bypass. Put the state check in middleware, and make destructive handlers call a single offboarding coordinator. This example is intentionally provider-neutral; the same contract works behind an HTTP gateway or a queue consumer.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass(frozen=True)
class OffboardingPolicy:
grace_seconds: int
retain_billing_days: int
def begin_offboarding(store, tenant_id, policy: OffboardingPolicy, request_id: str):
now = datetime.now(timezone.utc)
state = store.get_state(tenant_id)
if state in {"frozen", "erased"}:
return state
store.transition(tenant_id, expected="active", new="draining", request_id=request_id)
cutoff = now + timedelta(seconds=policy.grace_seconds)
store.set_cutoff(tenant_id, cutoff)
store.revoke_all_keys(tenant_id, request_id=request_id)
store.invalidate_sessions(tenant_id)
store.transition(tenant_id, expected="draining", new="frozen", request_id=request_id)
store.close_usage_window(tenant_id, cutoff=cutoff)
store.enqueue_personal_data_erasure(tenant_id, request_id=request_id)
return "frozen"
In a Node.js service, the equivalent coordinator should be called from the job system, not from an HTTP request that can time out halfway through. Each store method needs an idempotent transaction or an outbox event. Never put a raw API key in the outbox; store a key identifier and let the authorization service perform revocation.
I once assumed deleting the user row would make a token harmless. It did not: the gateway had already attached the tenant ID to a queued message. The fix was a second cutoff check at ledger write time, plus a refusal metric tagged with the tenant state. That small duplication is intentional.
Choosing controls when spend and refusal conflict
A hard cutoff is right for abuse response, regulated deletion deadlines, and accounts with an unknown key inventory. It is a poor fit for a live broadcast that is still flushing segments and has a contractual requirement to count every accepted segment. In that case, freeze issuance, revoke keys, drain accepted work, then close the ledger.
The catch is that a grace window consumes capacity and can exceed a strict spend ceiling. Set a per-tenant byte or event budget for the drain, and reject once it is reached. Conversely, a strict ceiling can create user-visible 429 or 403 responses; document that behavior in the offboarding notice and make the refusal distinguishable from a platform outage.
Do not use deletion as an access-control mechanism. Deletion jobs can be delayed, retried, or partially scoped. Authorization must fail from the tenant state and key revocation record before any personal-data lookup occurs. That separation is also aligned with the OWASP guidance to manage secrets through controlled lifecycle operations rather than scattering tokens through application code.
Not suitable when: your finance system cannot represent adjustments, or your contract promises a zero-second stop. Stick with an immediate freeze and a separately approved invoice snapshot in those cases. The design should follow the obligation, not the convenience of one queue.
A rollout that can survive a real deletion request
Start in shadow mode. For one billing cycle, calculate what would have been refused at each proposed cutoff without changing authorization. Compare the resulting usage totals with issued invoices and inspect duplicate event IDs. Then enable revocation for a small tenant cohort whose keys are easy to inventory.
Test the ugly paths: a retry after frozen, a worker that receives a pre-cutoff message after erasure starts, a clock skew of 30 seconds, and an invoice close racing the deletion queue. Assert that no personal payload is written after the cutoff, while the minimal ledger record remains available for reconciliation.
Watch four signals during rollout: revocation propagation latency, refused requests by tenant state, late-event adjustments, and erasure job age. Alert on missing transitions, not just on HTTP errors. A clean 2xx response from an idempotent retry can still conceal a state machine that never advanced.
This is the practical decision rule: revoke issuance and access first; preserve only the smallest metering evidence needed to close the account; erase personal data after the ledger boundary is explicit. It keeps a media tenant's final bill explainable without turning an offboarding record into a second customer database.
Top comments (0)