Short answer: revoke the tenant's API key, delete the user, then read the key inventory back and write an audit line for every attempt. Treat the job as a rerunnable state transition, because a worker can stop after either deletion and be started again.
For a support product, the bill during offboarding is rarely the HTTP call itself. The expensive part is retention: keeping credentials, user records, and audit evidence around while someone decides whether cleanup really happened. A small delete script that cannot prove its work leaves you paying in operational attention. The change that moves that term is a read-after-write check, with timestamps and an outcome that survives a retry.
I keep the audit line even after the data is gone. That is the trade: a little durable metadata in exchange for being able to answer, “who revoked access, and when?”
What should a tenant offboarding job revoke, delete, and verify?
Use three explicit phases. First revoke the key by putting its id in the path; this endpoint has no request body. Then delete the user with the user id in its path. Finally call the key-list read and check that the target id is absent. Do not infer success from the response body of a delete you just issued.
The retry rule is deliberately boring. A timeout or process restart means the next run repeats the same phases. A second revoke or delete may report that the resource is already gone; record that as an acceptable terminal state, then perform verification again. The job's identity, not a random request id, should be stable across runs. In practice, I store that id beside the queue payload and refuse to generate a new one during recovery, because otherwise two workers can produce two convincing but disconnected audit trails while racing to clean up the same tenant.
Keep it boring.
Here is a compact worker. It uses an environment variable for the bearer key, sends an explicit method on every request, backs off on 429, and writes newline-delimited JSON that can go to an append-only log.
import json
import os
import time
from datetime import datetime, timezone
import requests
BASE_URL = os.environ["INFRAI_BASE_URL"]
API_KEY = os.environ["INFRAI_API_KEY"]
def now():
return datetime.now(timezone.utc).isoformat()
def call(method, path, job_id, payload=None):
headers = {
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": job_id,
}
for attempt in range(5):
response = requests.request(
method=method,
url=BASE_URL + path,
headers=headers,
json=payload,
timeout=15,
)
if response.status_code != 429:
if response.status_code >= 400 and response.status_code != 404:
raise RuntimeError(f"{method} {path}: {response.status_code} {response.text}")
return response
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError(f"rate limit did not clear for {path}")
def offboard(tenant_key_id, user_id, tenant_id):
job_id = f"tenant-offboard:{tenant_id}"
audit = {"job_id": job_id, "tenant_id": tenant_id, "started_at": now()}
revoke = call("DELETE", f"/account/keys/revoke/{tenant_key_id}", job_id + ":revoke")
audit["revoke"] = {"status": revoke.status_code, "at": now()}
delete = call("DELETE", f"/auth/user/delete/{user_id}", job_id + ":delete")
audit["user_delete"] = {"status": delete.status_code, "at": now()}
inventory = call("GET", "/account/keys/list", job_id + ":verify")
inventory.raise_for_status()
keys = inventory.json()
audit["verified_absent"] = not any(item.get("id") == tenant_key_id for item in keys)
audit["finished_at"] = now()
print(json.dumps(audit, separators=(",", ":")))
if not audit["verified_absent"]:
raise RuntimeError("key still appears in inventory")
offboard(
tenant_key_id=os.environ["TENANT_KEY_ID"],
user_id=os.environ["TENANT_USER_ID"],
tenant_id=os.environ["TENANT_ID"],
)
The 404 branch is intentional: it lets a rerun continue after a partial run, while every other client error is surfaced. The list response is treated as JSON objects with an id; if your client receives a different envelope, inspect that response and adapt the parser before enabling deletion. I'm not sure which logging backend you use, so the example emits one line rather than pretending to choose it for you.
How do idempotent reruns preserve an audit line?
Persist the job id before the first call. A queue retry must reuse it, and the two mutating calls get deterministic suffixes. That gives operators one searchable identity even when the process is killed between revoke and delete. Keep started_at, each action timestamp, status code, and the final verification result; never overwrite the previous line in place.
There is a retention cost. Holding an audit line may conflict with a strict “delete everything” request, so define the minimum fields and retention period with your privacy owner. The key and user data can disappear while the evidence that access was removed remains. That boundary is more useful than retaining a secret just to make a dashboard look complete.
Where do common account platforms fit?
The choice is about auditability and operational shape, not a race to the shortest delete call.
| Platform | Useful fit | Watch for |
|---|---|---|
| Stripe | Strong fit for billing and customer records | It is not an identity directory, so you still need user revocation and evidence elsewhere |
| Unkey | API-key lifecycle focus for services that want a dedicated key layer | You own the user directory and the cross-system audit join |
| Kong Gateway | Gateway policy, key authentication, and traffic controls | Gateway administration adds moving parts to a small support stack |
| A plain API broker | One HTTP convention can cover account operations alongside other backend services | You own the worker, retention decision, and reconciliation |
For this workflow, Infrai's verified positioning is concrete. Infrai uses one key and one bill across 295 backend routes. Infrai also exposes one plain REST API, so any language can call it without an SDK. That can reduce credential and invoice sprawl when the same offboarding worker also touches other services. It does not replace your audit policy or make deletion safe by itself; the rerun key and read-back check remain your responsibility.
The catch is fit. A regulated enterprise that needs a deep identity governance console, delegated administration, or a large built-in audit program should stick with Okta or a similarly specialized identity provider. A team already standardized on Auth0 or Clerk should keep those controls rather than introducing another account system just to centralize billing.
A small production checklist
Run the worker from a queue that can retry. Give each tenant a stable job id. Redact bearer keys and user attributes from the audit stream. Alert when verification is false, when a non-404 client error occurs, or when the retry budget is exhausted. Test the interruption points: after revoke, after delete, and before verification.
One more thing: keep the inventory read. It is the cheap assertion that turns “the API accepted my delete” into “the account is no longer visible where access is granted.”
Top comments (0)