RideKit
Docs/Reference/Account deletion — API contract

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.

source: docs/account-deletion.md (ships identically in the product zip)