Account deletion — API contract
Apple (App Store Review 5.1.1(v)) and Google Play both require an app that creates accounts to offer account deletion from inside the app; Google additionally requires a route reachable from the open web. One backend capability serves the rider app, the driver app, the web route and the admin, so a rule cannot be true in one client and false in another.
Backend: backend/src/account/** · migration 1700000066000-AccountDeletion.
Endpoints
Base path is the API prefix (/api by default).
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/account/deletion |
Bearer | Is a request already pending? |
GET |
/account/deletion/eligibility |
Bearer | May I delete, what stops me, what happens |
POST |
/account/deletion/otp |
Bearer | SMS a re-auth code to the account's own number |
POST |
/account/deletion |
Bearer | Delete (re-authentication required) |
DELETE |
/account/deletion |
Bearer | Withdraw a pending request |
GET |
/account/deletion/web |
none | HTML page (the Play Console URL) |
POST |
/account/deletion/web/otp |
none | { phone } → SMS a code |
POST |
/account/deletion/web |
none | { phone, code, reason? } → delete |
GET |
/admin/account-deletions |
account.delete |
Operator queue |
POST |
/admin/account-deletions/:id/cancel |
account.delete |
Withdraw on the person's behalf |
POST |
/admin/account-deletions/:id/purge |
account.delete |
Erase before the grace window ends |
GET /account/deletion/eligibility
{
"eligible": false,
"blockers": [
{ "code": "wallet_balance",
"message": "Your wallet still holds INR 360.00. Withdraw or spend it first — …",
"detail": { "balanceMinor": "36000", "currency": "INR" } }
],
"graceDays": 30,
"purgeAfter": "2026-10-05T00:40:46.577Z", // if requested now
"reauthMethods": ["otp", "password", "social"], // only what THIS account can use
"phoneMasked": "+91•••0901",
"erases": ["name","phone","email","avatar","dateOfBirth","gender",
"savedPlaces","trustedContacts","devices","kycDocuments",
"bankDetails","lastLocation"],
"retains": ["ledgerEntries","taxInvoices","tripRecord","auditLog"],
"pending": null // or the live request
}
erases / retains are stable codes, not prose — each app localises them
into its own catalog. message on a blocker is an English fallback for the web
route and API consumers; apps should translate from code and interpolate
detail (money via the app's own money helper — detail amounts are minor
units).
POST /account/deletion
// body — supply exactly one proof
{ "otp": "123456", // from POST /account/deletion/otp
"password": "…", // accounts that have one
"provider": "google", "idToken": "…", // social-only accounts
"reason": "optional free text",
"app": "rider" | "driver" } // audit fact only
| Status | Body | Meaning |
|---|---|---|
201 |
{ id, status:"pending", requestedAt, purgeAfter, requestedFrom } |
Accepted |
400 |
{ message: "Confirm it is you before deleting…" } |
No proof supplied |
401 |
{ message: "That code is not right…" } |
Re-auth failed |
409 |
{ code:"blocked", message, blockers:[…] } |
Refused — see codes below |
409 |
{ code:"already_pending", message, request:{…} } |
A request is already live |
Refusal codes
code |
Why |
|---|---|
active_ride |
A trip is requested/accepted/arrived/in progress (as rider or driver) |
active_parcel |
A delivery is still in someone's custody |
wallet_balance |
The platform owes the person money — withdraw or spend first |
outstanding_debt |
Negative wallet: unremitted cash commission or an unpaid fee |
pending_payout |
A withdrawal is in flight to a bank |
corporate_debt |
The person owns a company account carrying an unbilled balance |
All applicable blockers come back at once, never just the first.
What happens, and when
On request (day 0) — the account stops being usable immediately:
every refresh token revoked, every push device token deleted, the driver forced
offline, and POST /drivers/online refused while the request stands. The app
should clear its own tokens and land on the signed-out screen.
At purgeAfter (default 30 days, ACCOUNT_DELETION_GRACE_DAYS) — an hourly
sweep runs the erasure. It re-checks every blocker first: money can move back
into a wallet during the window (a failed payout does exactly that), and a
request that has become blocked is deferred, not cancelled.
During the window the person can still sign in (that is how they cancel) and
GET /account/deletion reports the pending request — show a banner with a
"Keep my account" action wired to DELETE /account/deletion.
Erase vs retain
A settled ride's ledger entries are immutable by database trigger and its tax invoice is gapless within its series and filed with a tax authority. Destroying either falsifies the operator's books. So this is anonymisation of the person, not destruction of the accounting record.
The users row becomes a tombstone (no name, phone, email, photo, DOB, gender,
referral code, roles; anonymized_at set). Because that is the only row that
ever said who a user id belonged to, every table keyed by user id is
anonymised for free. The erasure therefore only visits tables holding a
second copy of identity: saved_places, trusted_contacts, device_tokens,
refresh_tokens, driver_documents (rows and the stored files),
vehicles (plate redacted, retired), payout_requests.beneficiary (reduced to
rail + last 4), driver_profiles (location/destination/gender cleared), and
ride_messages.sender_id (detached, bodies kept as part of the ride record).
Retained untouched: ledger_entries / ledger_postings / ledger_accounts,
tax_invoices, rides, audit_logs.
The account_deletion_requests row survives the person as the operator's
evidence. It holds no recoverable identity — only an HMAC digest of the
identifier, which can confirm "this number was erased on that date" without
storing the number.