Development
https://api-dev.speedoo.in/api/v1

IMPLEMENTATION STATUS — verified against the backend on 24 Sep 2026

This reference was generated from the master API integration document, which is a target specification. Much of it is not built yet. The table below is the result of reading the routers in src/modules/ and calling every live endpoint. Build against the LIVE rows only; treat the rest as design intent with no server behind it.

ModuleStatusNote
3. Authentication (OTP)LIVEPlus GET /driver/auth/check-phone, added §3.0
4. RegistrationPARTIALLive, but the request schema is snake_case and has no payment/bank step. Uploads need AWS_S3_BUCKET, currently unset.
5. Profile StatusLIVEGET /driver/profile/status
6. Admin VerificationPARTIALApprove / reject / hold + resubmit are live. /driver/profile/review-status and the audit-log table are not built.
7. TrainingNOT BUILTNo routes, no tables
8. Dashboard & Scheduled RidesNOT BUILTNo routes, no tables
9. Presence / Location / MatchingPARTIAL/drivers/go-online, /drivers/go-offline, /drivers/location/update, /matching/*
10. Ride LifecyclePARTIALLive as PATCH /rides/{id}/accept|arrive|start|end|cancel — not the paths/verbs in this doc
11. Wallet & SettlementNOT BUILTTables exist (migration 0009); no routes, no Razorpay integration
12. Rewards & BenefitsNOT BUILTNo routes, no tables
13+. SOS, partner offers, call maskingNOT BUILTNo routes

Base URL. The *.speedoo.in hosts named on this page are not live and there are no separate dev/staging/prod deployments yet. render.yaml defines a single web service, reachable at https://speedo-backend-node.onrender.com/api/v1; local development runs on http://localhost:3004/api/v1. Swagger UI at /api/v1/docs is generated from the running code and is the most reliable contract for anything marked LIVE above. OTP. OTP_PROVIDER is currently fixed, so the OTP is always 1234 and no SMS is sent; MSG91 credentials are blank. App launch. GET /api/v1/app/config?app=driver&platform=android&version=<build> returns the version-check / force-update / maintenance gate and should be called before the auth flow.

Speedoo Driver App โ€” API Reference (Development)
Base URL: https://api-dev.speedoo.in/api/v1  ยท  19 documented modules (6 with live endpoints)  ยท  62+ endpoint references, 13 implemented  ยท  MVP v1.6  ยท  verified against code 24 Sep 2026
All requests use the response envelope, idempotency, and encryption conventions defined in Module 1. Sensitive fields (Aadhaar, PAN, DL, bank details) are never logged. See Module 15 for the consolidated Security Guidelines.

1. Introduction & Cross-Cutting Conventions

These conventions apply to every API in this document and every module added later. Lock these now so nothing added downstream drifts from it.

Errors:

Sensitive field policy โ€” applies to Aadhaar, PAN, Driving Licence, Bank Account Number, IFSC, UPI ID:

  • Never logged, anywhere (app logs, error tracking, request logs).
  • Encrypted at rest with AES-GCM.
  • Duplicate detection via a separate deterministic blind index (HMAC-SHA256, server-side secret key), not the encrypted column. See ยง4.3.

Idempotency policy โ€” applies to any state-creating POST/PATCH (registration, resubmission, later: ride accept, payout requests):

  • Store idempotency_key in Redis, not Postgres, TTL 24โ€“48h.
  • On repeat key: return the original stored response, do not re-execute.

File uploads โ€” applies to any document/image/photo upload, current and future modules:

  • Flutter never sends file bytes to the backend. Backend issues presigned URLs; Flutter uploads directly to R2/S3.
  • Backend receives only object keys in JSON.

Migration note (Cloudflare โ†’ AWS): avoid Cloudflare-specific primitives with no direct AWS equivalent (Workers KV, D1, Durable Objects) for anything that needs to survive the move. Redis and S3-compatible storage (R2) both have drop-in AWS equivalents (ElastiCache, S3) โ€” keep usage patterns portable.

Response Envelope
{
  "success": true,
  "message": "string, optional",
  "data": { }
}
Response Envelope
{
  "success": false,
  "message": "human-readable",
  "errorCode": "SCREAMING_SNAKE_CASE"
}
Required headers
Authorization: Bearer <accessToken>
Idempotency-Key: <uuid>   // required on all POST/PATCH that create or mutate state

2. Base URLs

Flow
Development   https://api-dev.speedoo.in/api/v1
Staging       https://api-staging.speedoo.in/api/v1
Production    https://api.speedoo.in/api/v1

3. Authentication Flow

Flow
Open App โ†’ GET /app/config            (force update / maintenance gate)
        โ†’ Enter Mobile
        โ†’ GET  /driver/auth/check-phone  (backend decides: new or returning)
        โ†’ POST /driver/auth/send-otp     (SMS via MSG91 when OTP_PROVIDER=msg91)
        โ†’ Enter OTP
        โ†’ POST /driver/auth/verify-otp
        โ†’ nextStep = HOME                  โ†’ Dashboard
          nextStep = PENDING_VERIFICATION   โ†’ Waiting screen
          nextStep = ACTION_REQUIRED        โ†’ Correction screen
          nextStep = REGISTRATION           โ†’ Registration wizard

3.0 Check Phone (pre-OTP)

GET/driver/auth/check-phone

GET /driver/auth/check-phone?phoneNumber=9876543210 โ€” no authentication.

Called from the mobile-number screen before any OTP is sent, so the app knows where the driver will land once verification succeeds. The backend decides this, not Flutter. Do not infer "returning driver" from local storage or from the number looking familiar.

FieldMeaning
existsA users row exists for this number
isRegisteredA driver profile exists. A rider who later signs up to drive has exists: true but isRegistered: false and must still complete onboarding.
verificationStatusPENDING | APPROVED | REJECTED | HOLD, or null when not registered
profileCompletedOnboarding finished
nextStepREGISTRATION | PENDING_VERIFICATION | ACTION_REQUIRED | HOME โ€” same vocabulary as GET /driver/profile/status
Registered + approved
{
  "success": true,
  "message": "Phone check complete",
  "data": {
    "exists": true,
    "isRegistered": true,
    "verificationStatus": "APPROVED",
    "profileCompleted": true,
    "nextStep": "HOME"
  }
}
Not a driver yet
{
  "success": true,
  "message": "Phone check complete",
  "data": {
    "exists": false,
    "isRegistered": false,
    "verificationStatus": null,
    "profileCompleted": false,
    "nextStep": "REGISTRATION"
  }
}
400 โ€” invalid number
{ "success": false, "message": "phoneNumber must be a valid 10-digit Indian mobile number." }

3.1 Send OTP

POST/driver/auth/send-otp

POST /driver/auth/send-otp

FieldRule
phoneNumberrequired, 10 digits, India
PropertyValue
Length4 digits
Expiry12 min (720s)
Resend timer59s
Max OTP3/hour/mobile
Max verify attempts5
StorageRedis (otp:driver:<phone>, TTL 720s)
Rate limit keyRedis (otp-limit:<phone>, TTL 3600s)
Request
{ "phoneNumber": "9876543210" }
Success
{
  "success": true,
  "message": "OTP Sent Successfully",
  "data": { "otpExpiresIn": 720, "resendAfter": 59 }
}
429 โ€” limit exceeded
{ "success": false, "message": "OTP limit exceeded. Please try again after one hour.", "errorCode": "OTP_LIMIT_EXCEEDED" }

3.2 Verify OTP

POST/driver/auth/verify-otp

POST /driver/auth/verify-otp

FCM token is stored immediately on success so ride requests / admin notifications can reach the device before any further screens load.

EventTriggerNote
AUTH_LOGIN_SUCCESSlogin successsync session
AUTH_NEW_DEVICElogin from new devicesecurity notice
AUTH_LOGOUT_REMOTEsession revoked server-sideforce logout on device
AUTH_OTP_LIMITOTP cap hitinform user

OTP value itself is never sent via push โ€” SMS only.

  • Validate mobile locally before calling send-otp.
  • Disable Verify button while request in flight.
  • Store tokens in encrypted local storage.
  • Resend FCM token to backend whenever Firebase refreshes it, not just at login.
Request
{
  "phoneNumber": "9876543210",
  "otp": "1234",
  "deviceId": "android-uuid",
  "fcmToken": "AAAA...."
}
Registered driver
{
  "success": true,
  "message": "OTP verified successfully",
  "data": {
    "isRegistered": true,
    "accessToken": "<JWT>",
    "refreshToken": "c4a82fe570ea146d86be2a3c1662978a...",
    "driverId": "591d6e1a-5dfe-4483-852b-9322fb1d24d2",
    "profileCompleted": true,
    "verificationStatus": "APPROVED",
    "nextStep": "HOME"
  }
}

refreshToken is NOT a JWT. It is an opaque 96-character hex string (48 random bytes); do not attempt to decode it. Only accessToken is a JWT. driverId is a UUID, not a DRVโ€ฆ code. nextStep is the backend's routing answer and is one of HOME, PENDING_VERIFICATION, ACTION_REQUIRED or REGISTRATION โ€” branch on it rather than deriving navigation in Flutter.

New driver
{
  "success": true,
  "message": "OTP verified successfully",
  "data": {
    "isRegistered": false,
    "registrationToken": "<JWT>",
    "nextStep": "REGISTRATION"
  }
}

registrationToken is a normal signed access token, not a separate short-lived credential โ€” send it as Authorization: Bearer <token> on the registration call. It carries role customer until an admin approves the driver, which is why registration endpoints check authentication only, not role.

Errors
{ "success": false, "message": "Invalid OTP", "errorCode": "INVALID_OTP" }
{ "success": false, "message": "OTP Expired", "errorCode": "OTP_EXPIRED" }

4. Driver Registration

Flutter validates all 5 steps locally, persists in memory/local storage, and calls the backend once at the end. Backend re-validates everything โ€” client validation is never trusted.

once
OTP Verified โ†’ Step1 Profile โ†’ Step2 Vehicle โ†’ Step3 Documents โ†’ Step4 Payment
โ†’ Review โ†’ POST /driver/auth/register โ†’ Backend Validation โ†’ PENDING_VERIFICATION

4.1 Upload Flow (Presigned URLs)

This replaces a direct multipart-to-backend upload. Required for production; acceptable to defer only if you are knowingly trading it for MVP speed, with a firm plan to cut over before real users register (see migration note in ยง1 โ€” cutting over after drivers already have data under the old scheme is more painful than building this first).

Backend still enforces file type/size via the presign policy itself (content-type + max-size conditions on the presigned URL), and should trigger an async virus scan after upload lands in storage โ€” this is mandatory, not optional, since these are ID and financial documents. Until scan completes:

Flow
Flutter โ†’ POST /drivers/documents/presign โ†’ Backend generates presigned PUT URLs
โ†’ Flutter uploads file bytes directly to R2/S3 โ†’ Flutter collects object keys
โ†’ keys are sent (not files) in POST /driver/auth/register
async virus scan
documentStatus: PENDING_SCAN

4.2 Registration Submission

POST/driver/auth/register

POST /driver/auth/register โ€” JSON (no file bytes; see ยง4.1) Header: Idempotency-Key: <uuid> โ€” required (see ยง4.4)

StepFieldRule
1 Profilephotorequired
1 ProfilefullNamerequired, 3โ€“60 chars
1 Profiledobage โ‰ฅ 18
1 Profilegenderrequired
2 VehiclevehicleType, vehicleNumber, brand, model, color, ownershiprequired
3 Documentsall docsjpg/jpeg/png/pdf, โ‰ค10MB each
4 PaymentupiIdvalid UPI format
4 Paymentaccount/confirmAccountnumeric, must match
4 PaymentIFSCvalid format
  • Registration token valid, mobile verified, driver doesn't already exist.
  • Age โ‰ฅ 18, vehicle number format valid.
  • Duplicate checks โ€” mandatory, not conditional: Aadhaar, PAN, Driving Licence, active vehicle number. (Original draft marked Aadhaar/PAN duplicate checks as optional "if business rules require" โ€” for a driver-facing app this must be mandatory; one identity document tied to multiple accounts is a fraud vector.)
  • File type/size re-verified server-side even though presign policy already constrains it.
  • UPI/bank format re-validated server-side.
  • Check against banned identifiers table (ยง6.3) โ€” separate from the active-duplicate check.
Conceptual payload
{
  "full_name":         "Ramesh Kumar",          // 3-60 chars
  "profile_photo_key": "drivers/<id>/photo.jpg",
  "vehicle_type":      "CAR_MINI",              // BIKE | SCOOTY | E_BIKE_SCOOTY | AUTO
                                                // | CAR_MINI | PRIME_SEDAN | SUV
  "vehicle_model":     "Swift Dzire",
  "vehicle_number":    "TN09AB1234",
  "brand":             "Maruti",
  "color":             "White",
  "ownership":         "OWNED",                 // OWNED | LEASED | FINANCED
  "license_no":        "TN0920200001234",
  "aadhaar_number":    "123456789012",          // exactly 12 digits
  "pan_number":        "ABCDE1234F",
  "rc_no":             "TN09AB1234",
  "vehicle_permit_no": null,                    // optional
  "gender":            "MALE",                  // optional: MALE | FEMALE | OTHER
  "dob":               "1995-05-01",            // age must be >= 18
  "documents": {
    "license_key":       "drivers/<id>/dl.jpg",
    "rc_key":            "drivers/<id>/rc.jpg",
    "aadhaar_front_key": "drivers/<id>/aadhaar_front.jpg",
    "aadhaar_back_key":  "drivers/<id>/aadhaar_back.jpg",
    "insurance_key":     "drivers/<id>/insurance.pdf",
    "pollution_key":     "drivers/<id>/puc.pdf",
    "permit_key":        null                   // optional
  }
}

Field names are snake_case, not camelCase, and unknown fields are rejected outright (HTTP 400) โ€” the schema does not ignore extras. Note what the implemented schema does not accept today: no isEv, no nested vehicle{}/owner{} objects, no panKey document, and no payment / UPI / bank-account block at all. Aadhaar is two separate document keys (front and back), and the licence document key is license_key, not dlKey. Step 4 (Payment) of the wizard has no backend counterpart yet โ€” see the implementation-status banner at the top of this page.

Success
{
  "success": true,
  "message": "Driver registration submitted successfully",
  "data": {
    "driverId": "591d6e1a-5dfe-4483-852b-9322fb1d24d2",
    "status": "PENDING",
    "estimatedReviewTime": "24-48 Hours"
  }
}

status here is the raw drivers.verification_status enum value โ€” PENDING, not PENDING_VERIFICATION. The PENDING_VERIFICATION spelling belongs to GET /driver/profile/status, which maps the enum to a navigation state. driverId is a UUID.

Server-side sequence
Validate โ†’ confirm uploaded files exist at given keys โ†’ virus/file scan status check
โ†’ Save Postgres (driver, vehicle, owner, payment, document records)
โ†’ status = PENDING_VERIFICATION โ†’ notify admin โ†’ return success

4.3 Sensitive Data Encryption Policy

Applies to Aadhaar, PAN, Driving Licence, Bank Account Number, IFSC (optional), UPI ID (optional, policy-dependent).

Why not just UNIQUE on an encrypted column: AES-GCM uses a random IV per encryption, so the same real Aadhaar number produces different ciphertext every time. A UNIQUE constraint on the encrypted column will never catch a duplicate โ€” two rows with the same real-world Aadhaar look like different values. This is the actual mechanism, not a style choice, so it can't be skipped.

ColumnPurposeConstraint
aadhaar_encryptedAES-GCM ciphertext, for authorized admin viewingnone
aadhaar_hashHMAC-SHA256(normalized value, server-side secret)UNIQUE

Duplicate checks at registration query aadhaar_hash, never aadhaar_encrypted. Same pattern for PAN and Driving Licence (DL was flagged as "must be unique" in the source draft but not flagged for encryption โ€” it should get both, it's as sensitive as PAN).

Never log encrypted or plaintext values, or the hash inputs, in application logs.

4.4 Idempotency Policy

Store in Redis (not Postgres โ€” this is a short-lived dedup record, not permanent data):

On repeat key within TTL: return the stored response verbatim, do not re-run registration logic. Applies to POST /driver/auth/register and PATCH /driver/profile/resubmit, and should be the default for any future state-creating endpoint (ride accept, payout request, etc.).

Flow
Header: Idempotency-Key: <uuid>  (client-generated, per logical request)
Redis
key: idempotency:<key>
value: { status, response_body }
TTL: 24-48h

5. Driver Profile Status

GET /driver/profile/status Header: Authorization: Bearer <accessToken>

Called every app open post-login. Single source of truth for navigation.

ACTION_REQUIRED shape is defined in ยง6.1 โ€” this endpoint and the review-status endpoint should return the same shape for that status so Flutter has one parser, not two.

GET/driver/profile/status
Flow
Splash โ†’ Verify JWT โ†’ GET /driver/profile/status
โ†’ APPROVED โ†’ Dashboard
โ†’ PENDING   โ†’ Waiting Screen
โ†’ ACTION_REQUIRED โ†’ Correction Screen
โ†’ INCOMPLETE โ†’ Resume wizard at lastCompletedStep
Responses
// Approved
{ "success": true, "data": { "status": "APPROVED", "profileCompleted": true, "canGoOnline": true } }

// Pending  (verification_status = PENDING)
{ "success": true, "data": { "status": "PENDING_VERIFICATION", "estimatedReviewHours": 24 } }

// Action required  (verification_status = REJECTED or HOLD)
{ "success": true, "data": { "status": "ACTION_REQUIRED",
                             "reason": "Aadhaar photo is blurry, please re-upload",
                             "rejectedFields": ["aadhaar_front_key"] } }

// Incomplete  (no drivers row yet)
{ "success": true, "data": { "status": "INCOMPLETE", "lastCompletedStep": 0 } }

Two corrections against the implementation. First, the ACTION_REQUIRED shape returned here is { reason, rejectedFields[] } โ€” a flat list of rejected field keys โ€” not the issues[{ field, step, reason }] shape described in ยง6.1. There is one parser, and this is it. Second, lastCompletedStep is always 0: registration is a single atomic submission, so there is no server-side partial step to resume. Flutter keeps wizard progress locally; do not expect the backend to restore it.

6. Admin Verification Lifecycle

ACTION_REQUIRED is used instead of a hard REJECTED for normal document issues (blur, expiry, mismatch) โ€” the account isn't dead, specific items need fixing. A separate, rare, explicit permanent rejection path exists for fraud/forgery/blacklist (ยง6.3).

Status is computed, not dual-stored: the overall status shown to Flutter is derived at read time from the set of per-document statuses (see table below), not stored as an independent field that a separate write can update. If it must be cached for query performance, it is written in the *same transaction* as any per-document status change โ€” never as a separate follow-up call. Two independently-updatable status fields (overall + per-document) will drift the moment one write succeeds and the other doesn't.

Flow
SUBMITTED โ†’ UNDER_REVIEW โ†’ (per-document checks) โ†’
  all valid โ†’ APPROVED
  any issue โ†’ ACTION_REQUIRED โ†’ driver fixes โ†’ resubmits โ†’ UNDER_REVIEW

6.1 Review Status API

GET/driver/profile/review-statusNOT IMPLEMENTED

This endpoint does not exist in the backend. Use GET /driver/profile/status (ยง5), which is the single source of truth for post-login navigation and already returns the ACTION_REQUIRED reason and rejected field list. The per-document checklist table below is not exposed by any endpoint today โ€” per-document statuses live in driver_documents and are readable only by admin via GET /admin/drivers/{id}/documents.

GET /driver/profile/review-status

Action required โ€” every outstanding issue returned together, not one at a time:

ItemStatus
ProfileAPPROVED
PANAPPROVED
AadhaarAPPROVED
LicenceREJECTED
RCAPPROVED
InsuranceREJECTED
PollutionAPPROVED
Commercial PermitAPPROVED
PaymentAPPROVED

Overall status = APPROVED only if every item is APPROVED; ACTION_REQUIRED if any item is REJECTED and none is under a permanent ban; PERMANENTLY_REJECTED overrides all if set (fraud path).

Under review
{ "success": true, "data": { "status": "UNDER_REVIEW", "submittedAt": "2026-08-06T09:30:00Z", "estimatedReviewTime": "24 Hours" } }
Approved
{ "success": true, "data": { "status": "APPROVED", "approvedAt": "2026-08-06T13:00:00Z" } }
Action required
{
  "success": true,
  "data": {
    "status": "ACTION_REQUIRED",
    "issues": [
      { "field": "drivingLicenseFront", "step": 4, "reason": "Image is blurred" },
      { "field": "insurance", "step": 4, "reason": "Insurance expired" },
      { "field": "upiId", "step": 5, "reason": "Invalid UPI ID" }
    ]
  }
}

6.2 Resubmission API

PATCH/driver/profile/resubmit

PATCH /driver/profile/resubmit Header: Idempotency-Key: <uuid> (ยง4.4)

Open question, not yet resolved in this doc: the flow for getting new files in place before this call. Expected: driver calls POST /drivers/documents/presign again for just the flagged fields, uploads to storage, then calls resubmit with the new keys โ€” but this needs to be written explicitly as its own sub-flow before implementation, not assumed.

Versioning requirement: resubmitted documents must not overwrite the original object key. Use a versioned key per attempt (e.g. drivingLicenseFront_v2), keeping prior versions retrievable. Silently overwriting the original loses the audit trail of what was actually reviewed and rejected โ€” needed for any future dispute or fraud investigation.

Driver re-uploads only flagged fields, not the full document set.

Example
PATCH /driver/profile/resubmit
Authorization: Bearer <accessToken>
Idempotency-Key: <uuid>

{
  "full_name": "Ramesh Kumar",                  // optional
  "profile_photo_key": "drivers/<id>/photo_v2.jpg",   // optional
  "documents": {                                 // send ONLY the corrected keys
    "license_key":   "drivers/<id>/dl_v2.jpg",
    "insurance_key": "drivers/<id>/insurance_v2.pdf"
  }
}

The implemented request body is a partial object keyed by the same snake_case field names as registration โ€” every field optional, only what changed is written. There is no resubmissionId and no updatedFields[{ field, newKey }] array; those were never built. On success the driver returns to PENDING_VERIFICATION; calling this from a state that does not allow resubmission returns 409.

6.3 Admin Actions & Audit

Audit log โ€” required, not optional, for KYC-adjacent onboarding. Permanent, no deletion. Every approve/reject action must be recorded, not just reflected in a current-state reason field:

IP/device are captured on a best-effort basis (whatever the admin panel's session already has) โ€” not a new tracking system to build, just don't drop those fields if they're already available in the request context. A single overwritable reason string (as in the original draft) loses history the moment a second review happens on the same document. Rows are never deleted, only appended โ€” this is the mechanism that makes the audit trail actually trustworthy under dispute. Given driver payouts (ยง11) touch payment-aggregator-adjacent flows, treat this as a compliance requirement, not a nice-to-have.

Rationale: "is this Aadhaar already an active driver" (ยง4.2 unique constraint) and "is this Aadhaar permanently banned" are different business rules. A permanently-rejected driver's identifier hash sitting in the active-driver unique index doesn't stop them re-registering with a *different* Aadhaar while their original vehicle/DL stays flagged โ€” check both tables independently at registration time.

StatusMessage
UNDER_REVIEW"Your documents are under verification."
ACTION_REQUIRED"Some documents require corrections. Tap to review."
APPROVED"Congratulations! Your driver account has been approved. You can now start accepting rides."
PERMANENTLY_REJECTED"Your registration could not be approved. Please contact support."
Actions available
Approve All
Approve Selected (per-document)
Request Corrections (โ†’ ACTION_REQUIRED)
Reject Permanently (rare โ€” fraud, forged docs, blacklist, policy violation)
Add Internal Notes
Audit log โ€” required, not optional, for KYC-adjacent onboarding. Permanent, no deletion.
document_review_log:
  admin_id, document_id, driver_id, action, reason, timestamp,
  ip_address (if available), device_info (if available)
Banned identifiers โ€” separate table, separate check from the active-duplicate check in ยง4.2:
banned_identifiers:
  identifier_hash (Aadhaar/PAN/DL, HMAC โ€” same scheme as ยง4.3), reason, banned_at, banned_by

7. Driver Training Module

Training is required after approval, before "Go Online" is enabled โ€” not optional post-approval content. Content is fully admin-managed (title EN/TA, YouTube URL, duration, mandatory flag, quiz flag, display order) so new modules never require an app release.

after approval, before "Go Online" is enabled
Approved โ†’ Training Assigned โ†’ Complete All Mandatory Modules โ†’ (Quiz, if required)
โ†’ Training Completed โ†’ Go Online Enabled

7.0 MVP Scope ๐ŸŸข

Ship this now โ€” no heartbeat, no Redis progress tracking, no watch-percentage validation:

One check stays server-side even at MVP (ยง7-MVP note): complete for module N must reject if module Nโˆ’1 isn't already marked complete in the driver's record โ€” this is a single lookup, not a build project, and it's the difference between a training requirement and a training suggestion. Everything else in ยง7.2/ยง7.3 below (heartbeat cadence validation, resume-from-timestamp, plausibility checks on watch %) is ๐Ÿ”ต Phase 2 โ€” noted for the target architecture, not built now.

Quiz, MVP version: admin toggles quizRequired: true/false per module. If true: fixed 5 MCQs (not randomized โ€” ยง7.4 question-bank randomization is ๐Ÿ”ต Phase 2), pass threshold 80%. Fail โ†’ retry quiz or re-watch, admin-configured which.

Flow
Video starts โ†’ embedded YouTube player, skip disabled client-side
โ†’ Video ends โ†’ "Complete" button enables
โ†’ Driver taps Complete โ†’ POST /driver/training/complete

7.1 Get Training Modules

GET/driver/training/modules

GET /driver/training/modules

language is set by admin per driver (or defaulted), never chosen client-side. Falls back to English if the assigned language's content doesn't exist for a module. locked reflects sequential-unlock state (ยง7.2) โ€” Flutter uses it to render, but the backend independently enforces it (see below), it is not a UI-only rule.

Success Response
{
  "success": true,
  "data": {
    "language": "ta",
    "totalModules": 5,
    "completedModules": 2,
    "progress": 40,
    "modules": [
      {
        "id": "MOD001",
        "title": "Ride Acceptance",
        "youtubeUrl": "https://youtube.com/...",
        "durationSeconds": 203,
        "completed": true,
        "mandatory": true,
        "displayOrder": 1,
        "locked": false
      }
    ]
  }
}

7.2 Video Watch Tracking (Anti-Cheat) โ€” ๐Ÿ”ต Phase 2

Not built at MVP (ยง7.0) beyond the sequential-unlock check. Documented here as the target architecture for when video-completion fraud becomes an actual observed problem, not a hypothetical one:

Decided: Server-side completion validation and sequential-unlock enforcement are the target architecture; heartbeat/anti-cheat portion is ๐Ÿ”ต Phase 2 per ยง7.0 โ€” the sequential-unlock check alone ships at MVP since it's a single lookup, not a build project.

Resume: if the app closes mid-video, Redis progress key lets playback resume from last recorded timestamp rather than restarting.

Sequential unlock โ€” enforced server-side. POST /driver/training/complete for module N must reject (not silently ignore) if module Nโˆ’1 isn't already complete in the driver's record. Flutter's disabled/locked UI state is a convenience, not the control.

Flow
Video starts โ†’ Flutter sends heartbeat every 10โ€“15s (driver:training:heartbeat or REST)
โ†’ Backend stores progress in Redis: training:progress:<driverId>:<moduleId>
โ†’ Backend validates heartbeat cadence against wall-clock time and duration
  (implausible jumps in watchPercentage, or heartbeats arriving faster than
  real-time playback allows, are rejected/flagged, not accepted at face value)
โ†’ Reaches 95โ€“100% (validated, not client-reported) โ†’ mark complete
โ†’ Final completed record persisted to Postgres; Redis progress key can expire

7.3 Completion API

POST/driver/training/complete

POST /driver/training/complete Header: Idempotency-Key: <uuid> (per ยง4.4 โ€” a duplicate submit must not double-process)

watchPercentage in the request is advisory only โ€” backend computes the authoritative value from stored heartbeat data (ยง7.2) and does not trust this field for the pass/fail decision.

All modules complete โ†’ training status flips to COMPLETED, canGoOnline becomes true (reflected in Dashboard ยง8.1 and Profile Status ยง5).

Request
{ "moduleId": "MOD001", "watchPercentage": 100 }
Success
{
  "success": true,
  "message": "Module completed",
  "data": { "moduleId": "MOD001", "completedModules": 3, "totalModules": 5, "progress": 60, "nextModuleUnlocked": "MOD004" }
}
Rejected (sequence violation or insufficient validated watch time)
{ "success": false, "message": "Previous module not completed", "errorCode": "TRAINING_SEQUENCE_VIOLATION" }

7.4 Optional Quiz

Per-module or end-of-training, admin-configured (quizRequired: true/false). Pass threshold recommended 80%.

Decided: ๐Ÿ”ต Phase 2 โ€” randomized question bank deferred; MVP uses a fixed 5-MCQ set per ยง7.0. Revisit once quiz-sharing among drivers is an observed problem, not before.

EventMessage
Training assigned"Your training is ready. Complete all modules to activate your account."
Training completed"Training completed successfully. You can now go online."
Decided:
Quiz attempt โ†’ Pass (โ‰ฅ80%) โ†’ Module/Training marked complete
             โ†’ Fail โ†’ Retry quiz or re-watch video (admin-configured which)

8. Driver Dashboard

Single aggregated API for cold load; Socket.IO for everything live thereafter (ยง8.3). This is the pattern to reuse for any future screen that mixes summary data with real-time fields โ€” don't build N separate REST calls for one screen.

8.1 Dashboard API

GET/driver/dashboard
PATCH/driver/status

GET /driver/dashboard

driver.canGoOnline reflects training completion (ยง7) and approval status (ยง6) combined โ€” Flutter gates the online toggle on this field, not on inferring it from multiple other calls.

Explicit online/offline toggle (driver-intentional action only โ€” not the presence recovery mechanism, see ยง9.1): PATCH /driver/status

Success Response
{
  "success": true,
  "message": "Dashboard loaded successfully",
  "data": {
    "driver": {
      "driverId": "DRV001",
      "name": "Raj Kumar",
      "profilePhoto": "https://cdn.speedoo.in/profile/drv001.jpg",
      "rating": 4.92,
      "vehicleType": "AUTO",
      "isOnline": false,
      "canGoOnline": true
    },
    "todaySummary": {
      "totalEarnings": 847.00,
      "totalRides": 12,
      "onlineDuration": "05:32:18",
      "completedRides": 12,
      "cancelledRides": 1
    },
    "wallet": { "balance": 2450.50, "pendingSettlement": 350.00 },
    "activeIncentive": {
      "title": "Complete 15 rides",
      "completed": 12,
      "target": 15,
      "reward": 200,
      "progress": 80
    },
    "scheduledRides": {
      "count": 4,
      "nextRide": {
        "rideId": "SR1001",
        "pickupTime": "2026-08-07T08:00:00Z",
        "pickupAddress": "Anna Nagar",
        "destinationAddress": "Tidel Park"
      }
    },
    "notifications": { "unread": 2 }
  }
}
Explicit online/offline toggle
{ "status": "ONLINE" }
Explicit online/offline toggle
{ "success": true, "message": "Driver is now online", "data": { "status": "ONLINE" } }

8.2 Scheduled Rides

GET/driver/scheduled-rides

GET /driver/scheduled-rides โ€” full list, fetched on-demand when the driver taps the summary card, not on every dashboard load.

Assignment model: customer creates a recurring schedule template (not a 30-day driver lock). A scheduled job runs 30โ€“60 min before each occurrence, tries the preferred driver first (if online/available), falls back to nearest eligible driver if no response within a configured timeout. See ยง9.3 for the locking requirement on this job โ€” concurrent job runs must not double-assign the same occurrence.

Success Response
{
  "success": true,
  "data": [
    {
      "rideId": "SCH001",
      "pickupTime": "2026-08-07T08:00:00Z",
      "pickupAddress": "Anna Nagar",
      "destinationAddress": "Tidel Park",
      "customerName": "Arun Kumar",
      "rideType": "Corporate",
      "status": "UPCOMING"
    }
  ]
}

8.3 State Sync Rule (REST vs Socket)

This rule applies to the dashboard and to any future screen with the same shape (summary + live fields):

Example: scheduledRideCancelled arrives โ†’ Flutter decrements scheduledRides.count locally from 4 to 3. It does not call GET /driver/dashboard again to get the new count. Refetching on every event defeats the purpose of having sockets and reintroduces the staleness window it's meant to close.

Flow
Dashboard/list REST calls  โ†’ cold load only (app launch, pull-to-refresh)
Socket events              โ†’ update local Flutter state directly
Socket event               โ†’ NEVER triggers a REST refetch to "sync"
Socket events (dashboard-relevant):
driver:online / driver:offline
ride:new / ride:cancelled
scheduledRide:new / scheduledRide:cancelled / scheduledRide:assigned / scheduledRide:updated
wallet:updated
incentive:updated

9. Real-Time Presence, Location & Matching

This is the highest-risk module in the app โ€” get the failure modes (crash, force-kill, network loss, concurrent accept) right here, since they're invisible until real drivers hit them at scale.

9.1 Online/Offline โ€” Socket + Redis TTL

PATCH/driver/status

PATCH /driver/status (ยง8.1) is for explicit, intentional toggles only. It is never the recovery mechanism for crashes, force-kills, OS suspension, or signal loss โ€” those cannot reliably call a REST endpoint on the way out, so presence must be transport-layer managed.

[DECIDED โ€” see Final Decisions table] Redis is now load-bearing for online status, GEO location, and locking simultaneously โ€” a Redis restart/failover with no persistence (AOF) or reconciliation step silently drops every driver to invisible at once. AOF persistence is enabled now (cheap to turn on, no reason to defer it); the reconciliation job (reconnecting sockets re-push GEO + online key automatically rather than requiring a manual toggle) remains a pre-production item, not urgent during the current Cloudflare/testing phase. Redis is a cache/presence layer only โ€” PostgreSQL remains the source of truth for anything that must survive a Redis flush (driver records, ride history, wallet balances); nothing that matters long-term is ever Redis-only.

explicit, intentional
Driver socket connects โ†’ Redis: driver:online:<driverId>, TTL 45s
Flutter heartbeat every 15โ€“20s โ†’ backend runs EXPIRE (refresh TTL 45s)

Clean disconnect (socket 'disconnect' event fires):
  โ†’ remove driver from Redis GEO immediately
  โ†’ mark socket session inactive โ†’ broadcast driver offline

Unclean disconnect (no 'disconnect' event โ€” crash, kill, dead radio):
  โ†’ no heartbeat refresh โ†’ Redis TTL expires within 45s โ†’ driver auto-offline
  โ†’ no ghost drivers persist past the TTL window

9.2 Live Location (Redis GEO)

Postgres stores only last-known location + ride history โ€” never the live location stream. Nearby-driver search uses GEORADIUS/GEOSEARCH against Redis.

Flow
Flutter GPS update โ†’ Socket.IO โ†’ Redis GEOADD

9.3 Ride Matching & Assignment Locking

Cost control on ETA ranking โ€” calling the routing API on every eligible driver in radius doesn't scale: pre-filter to a wider pool by Redis GEO distance alone (e.g. top 30โ€“40 nearest), then call Mappls only on that trimmed set for ETA ranking. Cache Mappls responses briefly (few seconds, keyed by rounded driver-location + pickup grid cell) since nearby drivers often share overlapping route segments. Unbounded per-candidate routing calls scale linearly with ride volume and put dispatch latency on the critical path of a third-party API.

Offer locking (prevents double-offer, not just double-accept): when a driver is selected for a broadcast round, atomically mark them offered โ€” SET NX in Redis, TTL matching the 15s window โ€” *before* sending the popup, not just before processing the accept. Without this, two concurrent dispatch rounds (different rides) can both select and notify the same driver simultaneously.

Per-driver offer timeout: no response within 15s โ†’ release that driver from the current offer, do not wait indefinitely on one unresponsive socket.

Each round re-runs the eligible-driver query excluding everyone already offered in a prior round (offeredDriverIds set, ride-scoped, Redis, TTL = ride's max dispatch window) โ€” expansion must not re-notify a driver who already rejected or timed out on the same ride. No drivers found at max radius โ†’ customer sees "No drivers are currently available nearby. Please try again in a few minutes."

Mid-search cancellation: if the customer cancels while status is still SEARCHING (before any accept), all outstanding popups must close and dispatch state (offer locks, offered-driver set) must be torn down โ€” not left to expire on their own TTLs.

Multi-server requirement: ride:takenByAnother and other cross-driver broadcasts require a shared Socket.IO adapter (e.g. socket.io-redis) once there's more than one Socket.IO server instance behind the load balancer โ€” otherwise a broadcast only reaches drivers whose socket happens to be held by the same server instance that processed the accept, silently breaking the "close all other popups" guarantee.

Scheduled-ride assignment (ยง8.2) uses the identical ride_assign_lock:<rideId> pattern before the scheduler job assigns a driver to a recurring-schedule occurrence โ€” prevents overlapping job runs from double-assigning the same occurrence.

Dispatch sequence (broadcast with ETA priority and radius expansion):
Ride created โ†’ status SEARCHING
โ†’ Redis GEO search, initial radius 3km
โ†’ Filter: online, approved, correct vehicle type, not busy, no other pending offer
โ†’ Candidate pool ranked by ETA (Mappls Distance Matrix), not raw straight-line distance
โ†’ Top 10-20 by ETA sent the request (never broadcast to every eligible driver โ€” popup spam)
โ†’ 15s accept window per round
Accept (first-driver-wins):
Driver taps Accept โ†’ Redis lock: SET ride_assign_lock:<rideId> NX EX 30
Lock acquired  โ†’ ride ASSIGNED, driver BUSY, removed from matching pool,
                  broadcast ride:takenByAnother to every other offered driver (popups close immediately)
Lock not acquired (late accept) โ†’ { "success": false, "message": "Ride already assigned." }, popup closes
Radius expansion (nobody accepts within 15s):
3km โ†’ 4.5km โ†’ 6km โ†’ 8km (configurable max)

9.4 Socket Reliability & Auth

  • Heartbeat every 15โ€“20s (also drives ยง9.1 TTL refresh).
  • Automatic reconnect with exponential backoff.
  • JWT verified at socket handshake.
  • [DECIDED โ€” see Final Decisions table] Socket re-authenticates via a refresh event 2โ€“5 minutes before the access token actually expires โ€” not at expiry. Refreshing ahead of the deadline is what avoids an actual disconnect gap; refreshing exactly at expiry risks a race where the old token is rejected before the new one is confirmed accepted.

10. Ride Lifecycle (Production)

Ride State Machine

Canonical states (used everywhere in this module โ€” the transition-guard implementation must validate against this exact list, not a shortened one):

CANCELLED and NO_SHOW are reachable from multiple earlier states (see ยง10.12โ€“10.13) โ€” they are not purely terminal-from-COMPLETED, they branch off mid-flow.

Backend must reject invalid transitions (e.g. COMPLETE before START, START before OTP_VERIFIED, ARRIVED after COMPLETED) using the full state list above, not a subset โ€” this matters most under retries on poor network conditions, where a duplicate/out-of-order request is the actual attack surface, not a hypothetical.

Canonical states
REQUESTED โ†’ ASSIGNED โ†’ DRIVER_EN_ROUTE โ†’ ARRIVED โ†’ WAITING โ†’ OTP_VERIFIED โ†’ IN_PROGRESS โ†’ COMPLETED
                                                                                    โ†“
                                                                    (or) CANCELLED / NO_SHOW

10.1 Accept Ride

POST/rides/{rideId}/accept

POST /rides/{rideId}/accept Header: Idempotency-Key: <uuid>

  • Verify driver JWT.
  • Acquire Redis lock (ride_assign_lock:<rideId>, per ยง9.3) โ€” only the lock-winner's accept commits.
  • Ride status โ†’ ASSIGNED. Driver status โ†’ BUSY. Remove driver from matching pool (ยง9.3).
  • Fetch route (driver โ†’ pickup) via Mappls.
  • All other candidate drivers receive ride:takenByAnother (per ยง9.3) so their popups close.

10.2 Driver Navigation (to Pickup)

Called once after accept, not repeatedly โ€” mid-trip guidance uses the Navigation SDK client-side (per ยง"one improvement" already established), backend only receives GPS pings via socket for live tracking (ยง9.2).

Flow
Driver current GPS (Redis, per ยง9.2) โ†’ Pickup GPS (Postgres, set at booking, immutable post-assignment)
โ†’ Mappls Route API โ†’ polyline, distance, ETA

10.3 "I've Arrived" โ€” Geofence Validation

PATCH/rides/{rideId}/arrived

PATCH /rides/{rideId}/arrived Header: Idempotency-Key: <uuid>

Backend validates, does not trust the client's claim of arrival:

Pass โ†’ status ARRIVED, waiting timer starts (ยง10.4). Fail โ†’

Flow
distance(driver's last Redis GEO position, ride.pickup_lat/lng) โ‰ค 100โ€“150m (configurable)
AND
age of that GEO position โ‰ค ~10-15s (freshness check โ€” a stale-but-nearby position from before
the driver moved away must not pass; distance alone is not sufficient)
Error Response
{ "success": false, "message": "You must be near the pickup location before marking arrival.", "errorCode": "GEOFENCE_NOT_MET" }

10.4 Waiting Timer & Maximum Wait (No-Show)

PATCH/rides/{rideId}/no-show

Starts only after geofence validation succeeds โ€” never on the raw "I've Arrived" tap.

At maximum, driver gets a Customer No Show โ†’ Cancel Ride action.

PATCH /rides/{rideId}/no-show Header: Idempotency-Key: <uuid>

  • Status โ†’ NO_SHOW.
  • No-show fee applied per business policy โ€” charging mechanism must be explicit: deduct from customer's stored payment method or wallet balance. If neither exists (COD-only customer), post the fee as a blocking negative balance on the account rather than attempting a charge with no valid target.
  • Driver released โ†’ status ONLINE, re-added to Redis GEO matching pool immediately.
Flow
Free wait: 2 min โ†’ Chargeable wait: up to 8 min โ†’ Maximum: 10 min total

10.5 OTP Generation

Generated only on transition to ARRIVED (not at booking) โ€” Redis-stored, TTL 10 min, pushed via FCM to customer app. No SMS needed (customer already has the app open).

10.6 OTP Verification

POST/rides/{rideId}/verify-otp

POST /rides/{rideId}/verify-otp Header: Idempotency-Key: <uuid>

Success โ†’ status OTP_VERIFIED, OTP deleted from Redis immediately (not left to expire).

10.7 Start Ride

PATCH/rides/{rideId}/start

PATCH /rides/{rideId}/start Header: Idempotency-Key: <uuid> โ€” required here specifically because a retried start call must not overwrite start_time/start_lat/start_lng a second time with a later value.

Stores once: start_time, start_lat, start_lng. Fetches route (pickup โ†’ destination) once via Mappls; Navigation SDK + client-side rerouting handles the rest.

10.8 Live Tracking (In-Trip)

Same transport as ยง9.2 โ€” no separate mechanism.

Flow
Driver GPS every 3โ€“5s โ†’ Socket.IO โ†’ Redis GEO โ†’ Customer live map

10.9 Complete Ride

PATCH/rides/{rideId}/complete

PATCH /rides/{rideId}/complete Header: Idempotency-Key: <uuid>

Stores: end_time, end_lat, end_lng, actual_distance, actual_duration. Driver released โ†’ ONLINE, re-added to Redis GEO. Triggers fare finalization (ยง10.14).

10.10 Idempotency โ€” Applies Across This Entire Module

Every state-mutating endpoint above (accept, arrived, no-show, verify-otp, start, complete, cancel) requires Idempotency-Key per the policy already defined in ยง4.4. A repeated request with the same key returns the stored original response โ€” it never re-executes or partially overwrites state.

10.11 Customer Cancellation (Before Arrival)

POST/rides/{rideId}/cancel

POST /rides/{rideId}/cancel (actor: customer) Header: Idempotency-Key: <uuid>

Driver released back to matching pool, status โ†’ CANCELLED. Penalty per business rules (e.g. free before driver accepts, fee after driver is en route).

10.12 Driver Cancellation (Before Ride Starts) โ€” ๐ŸŸก hybrid at MVP, auto-suspension Phase 2

POST/rides/{rideId}/cancel

POST /rides/{rideId}/cancel (actor: driver) Header: Idempotency-Key: <uuid>

Request field: reason โ€” enum, not free text (customer_unreachable, wrong_pickup_location, vehicle_issue, personal_emergency, other). Ride re-enters dispatch (ยง9.3) for a new driver. Cancellation rate is tracked per driver with reason breakdown, not as a single undifferentiated rate โ€” a driver correctly cancelling because of a bad pickup pin should not count the same as a driver cancelling to avoid a low fare.

  • Cancellation rate (trailing 20 rides) tracked continuously per driver.
  • Admin dashboard shows a color band: ๐ŸŸข 0โ€“20% ยท ๐ŸŸก 20โ€“30% ยท ๐Ÿ”ด above 30% โ€” reduces manual monitoring load without needing full automation.
  • Automatic in-app warning notification fires the moment a driver crosses 20% โ€” the driver is told, not just tracked silently.
  • No automatic suspension: admin makes the suspension call manually, informed by the color band and reason breakdown, not by an automatic rule.

๐Ÿ”ต Phase 2: automatic suspension at the 30% threshold (e.g. 48h dispatch hold pending review), once real cancellation-rate baseline data exists to calibrate the threshold with confidence โ€” thresholds set from zero production data risk over- or under-penalizing drivers.

10.13 Fare Finalization

Estimated fare (shown at booking): distance matrix + ETA + pricing rules. Final fare (computed at complete): actual GPS distance + actual duration + waiting charges + tolls (if applicable) + surge.

Decided: Surge pricing is locked at booking โ€” the customer-trust-friendly option over the revenue-protective alternative (surge floating to actual trip time). Chosen as the simpler, more supportable default for launch.

Customer always sees an itemized breakdown (base + distance + time + waiting + toll + surge), not just a total.

10.14 Call Masking

Customer โ†” driver calls proxy through a telephony provider (e.g. Exotel) using a virtual number pair โ€” neither party's real number is exposed to the other's device dialer or call log. Both directions required: customer-initiates-call-to-driver (as originally specified) and driver-initiates-call-to-customer (common in practice โ€” driver asking "which gate/entrance" โ€” and not covered in the source flow, needs the same virtual-number mechanism, not a plain tel: fallback for this direction only).

11. Driver Wallet & Settlement (Production)

11.1 Wallet Structure

Only Available Balance is withdrawable. Balance is never updated directly โ€” every change is a ledger transaction (ยง11.10); the displayed balance is derived from the ledger, not a mutable field.

Flow
Available Balance     โ€” usable for withdrawal
Pending Withdrawal     โ€” reserved, awaiting settlement
Outstanding Balance    โ€” negative, driver owes Speedoo (cash-ride collections)
Total Lifetime Earnings โ€” cumulative, informational

11.2 Wallet Overview

GET/driver/wallet

GET /driver/wallet

Example
{ "availableBalance": 2500, "pendingWithdrawal": 1000, "outstandingBalance": 0, "totalLifetimeEarnings": 48200 }

11.3 Withdrawal Request (Reservation โ€” Not Just a PENDING Row)

POST/driver/wallet/withdraw

POST /driver/wallet/withdraw Header: Idempotency-Key: <uuid> (per ยง4.4/ยง10.10 convention)

Backend, in a single transaction:

The amount is reserved at request time, not at settlement โ€” this is what prevents two concurrent withdraw taps (or a withdraw racing a cash-ride debit) from both passing a stale balance check.

Example
{ "amount": 1000 }
Flow
BEGIN
SELECT wallet FOR UPDATE
CHECK available_balance >= requested_amount
  (this same check rejects a rapid double-tap: the second concurrent request
   sees the already-reserved balance under the row lock, not the pre-reservation figure)
MOVE amount: available_balance -= amount, pending_withdrawal += amount
CREATE withdrawal_request (status = PENDING)
COMMIT

11.4 Settlement Window & Worker

Admin-configured settlement time (e.g. daily 5PM). Worker:

Flow
Find withdrawals with status = PENDING (and, per ยง11.6, not held for admin approval)
โ†’ Revalidate each (ยง11.5) โ†’ Call Razorpay Payout (ยง11.7) โ†’ await webhook (ยง11.8)

11.5 Revalidation Before Payout

Immediately before calling Razorpay, re-check (state may have changed since the original request):

If the wallet has gone negative since the request (e.g. a cash ride collected in the interim):

Reserved money stays reserved (not returned to available, not paid out) while held. On-hold requests must not sit indefinitely โ€” auto-cancel after a configured max hold period (e.g. 7โ€“14 days) and return the reserved amount to available_balance net of whatever outstanding amount remains, with driver notification, rather than freezing the driver's money with no resolution path.

Flow
pending withdrawal still exists
AND outstanding_balance == 0
AND driver account active (not banned/suspended, ยง6.3)
AND bank account verified (ยง11.9)
Flow
status โ†’ ON_HOLD

11.6 Admin Approval โ€” Threshold-Gated (๐ŸŸข MVP-revised)

GET/admin/withdrawals
PATCH/admin/withdrawals/{id}/approve
PATCH/admin/withdrawals/{id}/reject

MVP: minimum withdrawal โ‚น500, maximum withdrawal โ‚น10,000, settlement processed at three admin-configured daily windows (e.g. 11AM / 3PM / 7PM) rather than a single daily run. No admin approval gate at MVP โ€” since the max withdrawal (โ‚น10,000) equals the threshold that would trigger review, every withdrawal in practice settles automatically. This is a deliberate MVP simplification, not an oversight: raising the max-withdrawal cap later is the trigger to re-enable the threshold gate below, at which point it starts actually filtering requests rather than sitting dormant.

๐Ÿ”ต Phase 2 target architecture (kept in the doc, inactive at current caps): withdrawals above a configurable threshold require explicit admin approval before the settlement worker will process them; withdrawals at or below the threshold flow straight through ยง11.4 with no manual step.

Build these endpoints now even though the gate is inactive at MVP caps โ€” they're also the admin's manual dispute/reversal tool regardless of the threshold question, and cost nothing extra to have ready. The settlement worker query in ยง11.4 must explicitly exclude anything still pending the admin gate once it's active โ€” a request rejected at 4:59PM must not be picked up by a 7:00PM run that doesn't check the gate.

11.7 Razorpay Payout Call

Every payout call carries its own idempotency key to Razorpay (separate from the internal request header), keyed to the internal withdrawal ID:

This protects against the settlement worker itself retrying a timed-out call to Razorpay โ€” without it, a retry after a network timeout can create a second real payout for one withdrawal request. Razorpay returns the original payout instead of creating a duplicate when the key repeats.

Flow
Razorpay Idempotency-Key: withdrawal_<withdrawalId>

11.8 Razorpay Webhook โ€” Idempotent Processing

Never trust the Flutter success callback or the synchronous API response โ€” only the webhook confirms money actually moved.

Webhook redelivery (normal Razorpay behavior, not an edge case) must not double-process โ€” the event-ID check above is what makes that safe, not "we only trust webhooks" alone.

Payout failure: status โ†’ FAILED, reserved amount returned pending_withdrawal โ†’ available_balance, driver notified with reason, no ledger debit recorded (money never actually left).

Flow
Webhook: payout.processed
โ†’ Check Razorpay event ID already processed? YES โ†’ ignore (no-op)
                                              NO  โ†’ store event ID,
                                                    update withdrawal โ†’ SUCCESS,
                                                    pending_withdrawal -= amount,
                                                    create ledger entry

11.9 Driver Outstanding Payment (Wallet Top-Up)

POST/driver/wallet/pay-outstanding

POST /driver/wallet/pay-outstanding โ€” Razorpay Checkout.

Hard rule: the Flutter checkout success callback never unblocks the driver or clears outstandingBalance โ€” only the server-side payment.captured webhook does, verified with the same event-ID dedup pattern as ยง11.8. A client-side callback is spoofable; only clear the block, update the wallet, and resume dispatch eligibility after backend-side webhook confirmation.

11.10 Bank Account Verification โ€” ๐ŸŸก Final for MVP

POST/PATCH /driver/bank-account must not mark bankVerified: true on save alone.

MVP: account status is PENDING_VERIFICATION immediately on save and stays there until admin manual review (submitted details shown alongside the driver's registered KYC name for visual cross-check) flips it to VERIFIED or REJECTED. Only VERIFIED accounts pass the ยง11.5 revalidation gate before payout. This is weaker than penny-drop but non-zero, and matches the current testing-phase reality of low withdrawal volume where manual review is actually feasible.

Phase 2: swap manual review for penny-drop verification (Razorpay/Cashfree โ€” a โ‚น1 test transfer confirms the account-holder name matches the driver's registered name programmatically), gated behind a feature flag so the switch is a config change, not a re-architecture โ€” the PENDING_VERIFICATION โ†’ VERIFIED/REJECTED state model stays identical, only what triggers the transition changes. This becomes a hard requirement once payout volume grows past what admin can manually eyeball โ€” a bank account belonging to someone other than the registered driver is a real fraud vector that manual review only slows down, not eliminates.

11.11 Wallet Ledger (Immutable, Single-Entry, Migration-Ready)

Every action creates a transaction record โ€” the wallet balance is a derived sum, never written directly:

Table structure โ€” deliberately includes balance_before/balance_after on every row even without double-entry accounting, so a proper ledger can be introduced later without redesigning the table:

This alone gives dispute resolution, withdrawal history, and an audit trail without needing full double-entry bookkeeping โ€” and balance_before/balance_after on every row means a future migration to double-entry has a verifiable trail to reconcile against, rather than starting from a single running total with no history of how it got there.

Double-entry bookkeeping โ€” out of scope for v1. It was proposed with an incorrect example (both legs shown as debits to different accounts, which doesn't balance to zero and would never reconcile against Razorpay/bank statements). The single-entry ledger above is correct and sufficient on its own for MVP. If double-entry is wanted later โ€” genuinely useful once transaction volume and internal accounts multiply โ€” it needs a proper chart of accounts (Driver Wallet as a liability account, Speedoo Bank/Cash as an asset account, Platform Revenue as its own account, with real matching debit/credit pairs) built with an accountant or someone who owns the books, not derived from this doc.

Flow
Ride Credit, Platform Fee, GST, Cash Ride Debit, Outstanding Payment,
Withdrawal Reserved, Withdrawal Success, Withdrawal Failed (reversal),
Admin Adjustment, Bonus, Penalty, Refund
Table structure
wallet_transaction
-------------------
id
driver_id
type              (one of the transaction types above)
amount
balance_before
balance_after
reference_id      (rideId / withdrawalId / adjustmentId โ€” links back to source)
created_at

11.12 State Machine

Flow
AVAILABLE โ†’ (withdraw request, ยง11.3) โ†’ RESERVED
RESERVED โ†’ (settlement worker + revalidation passes) โ†’ PROCESSING โ†’ SUCCESS | FAILED
RESERVED โ†’ (revalidation fails, ยง11.5) โ†’ ON_HOLD โ†’ (cleared) โ†’ back to PROCESSING
                                                   โ†’ (max hold exceeded) โ†’ returned to AVAILABLE
FAILED โ†’ reserved amount returned to AVAILABLE, no ledger debit

11.13 APIs

GET/driver/wallet
GET/driver/wallet/transactions
GET/driver/wallet/withdrawals
POST/driver/wallet/withdraw
POST/driver/wallet/pay-outstanding
GET/driver/bank-account
POST/driver/bank-account
PATCH/driver/bank-account
GET/driver/earnings
GET/admin/withdrawals
PATCH/admin/withdrawals/{id}/approve
PATCH/admin/withdrawals/{id}/reject
PATCH/admin/wallet-settings

All business-rule values configurable from admin โ€” no deploy required to change pricing.

Admin
GET    /admin/withdrawals               (queue, filterable by threshold-gate status)
PATCH  /admin/withdrawals/{id}/approve
PATCH  /admin/withdrawals/{id}/reject
PATCH  /admin/wallet-settings           (platform fee, GST, TDS, negative-wallet limit,
                                          min/max withdrawal, settlement schedule,
                                          approval threshold, waiting/cancellation/no-show fees)

12. Rewards & Benefits (Workflow Engine)

Fully admin-managed โ€” no Flutter release needed for a new benefit type. Phased scope:

Flow
Phase 1 (launch): Restaurant Offers (QR) ยท Vehicle Service (Booking) ยท Education Support (Approval) ยท Welcome Kit (Shipping)
Phase 2: Fuel ยท Insurance ยท Medical ยท Festival Gifts ยท Legal ยท Cashback/Coupons โ€” same engine, new admin config only

12.1 Data Model

Two independent fields per benefit, as established: Category (icon/grouping/analytics โ€” Vehicle Service, Restaurant, Education, Welcome Kit, Medical, Fuel, Coupon; Medical/Fuel/Coupon reserved for Phase 2 benefits, kept in the enum now so adding those benefits later needs no schema change) and Fulfillment Type (drives the UI action and workflow).

๐ŸŸข MVP fulfillment enum โ€” matches Phase 1 benefits exactly (ยง12 intro):

Fulfillment TypeDriver ButtonWorkflow StatesNotes
QRShow QRAVAILABLE โ†’ QR_GENERATED โ†’ REDEEMED โ†’ COMPLETEDQR generated on-demand, 5 min expiry, regenerate after
BOOKINGBook ServiceAVAILABLE โ†’ BOOKED โ†’ CONFIRMED โ†’ SERVICE_DONE โ†’ COMPLETEDRequires partner + slot
APPROVALApplyAPPLIED โ†’ UNDER_REVIEW โ†’ (BANK_REVIEW if loan) โ†’ APPROVED/REJECTED โ†’ COMPLETEDRequires documents (ยง12.4)
SHIPPINGClaim / TrackCLAIMED โ†’ APPROVED โ†’ PACKED โ†’ SHIPPED โ†’ OUT_FOR_DELIVERY โ†’ DELIVEREDRequires delivery address (ยง12.4)
Fulfillment TypeDriver ButtonWorkflow States
DOWNLOADDownload Couponstateless โ€” view/download event logged for analytics only, no redemption lifecycle
LINKOpen Websitestateless โ€” same as above

Fulfillment Type stays the authoritative enum regardless of phase โ€” no new value ships without a defined workflow or an explicit stateless designation, per the pattern above.

12.2 Admin Benefit Creation

POST/admin/benefits

POST /admin/benefits

Eligibility rule kind matters for state behavior: count/hours/referral-based rules (TOTAL_RIDES, ONLINE_HOURS, REFERRAL_COUNT) are monotonic โ€” once reached, eligibility is permanent. Rate-based rules (RATING_ABOVE, ACCEPTANCE_RATE_ABOVE, CANCELLATION_RATE_BELOW) can cross back below threshold. Decided: Rate-based eligibility is locked in at the moment first reached and does not get revoked if the rate later drops โ€” silently pulling back an already-shown "eligible" badge is worse for driver trust than the alternative.

Example
{
  "title": "Free Vehicle Service",
  "category": "VEHICLE_SERVICE",
  "fulfillmentType": "BOOKING",
  "description": "...",
  "eligibilityRule": { "type": "TOTAL_RIDES", "targetValue": 1000 },
  "documentsRequired": [],
  "partnerId": "PTR_TVS_001",
  "vehicleTypes": ["AUTO", "CAB"],
  "cities": ["Chennai"],
  "maxClaims": null,
  "expiryDate": "2027-01-01",
  "active": true
}

12.3 Driver โ€” List & Details

GET/driver/benefits
GET/driver/benefits/{id}

GET /driver/benefits

progress, eligible, and expiryDate are required fields, not optional โ€” the driver rewards screen's progress bars and eligibility badges depend on them; a stripped-down list response loses that UI. Filtered server-side by the requesting driver's vehicleType and city against the benefit's configured vehicleTypes/cities โ€” a driver never sees a benefit they're categorically excluded from, not shown-then-rejected on claim.

GET /driver/benefits/{id} โ€” full details (terms, partner, documents required, expiry) per benefit.

Example
{
  "totalPoints": 2450,
  "nextMilestone": { "title": "Free Vehicle Service", "required": 1000, "completed": 720 },
  "benefits": [
    {
      "benefitId": "RW100", "title": "Free Vehicle Service", "category": "VEHICLE_SERVICE",
      "fulfillmentType": "BOOKING", "eligible": false, "progress": 72, "expiryDate": "2027-01-01"
    },
    {
      "benefitId": "RW101", "title": "Restaurant Discounts", "category": "RESTAURANT",
      "fulfillmentType": "QR", "eligible": true, "offers": 12
    }
  ]
}

12.4 Driver โ€” Action (Single Endpoint, Type-Specific Payload)

POST/driver/benefits/{id}/action

POST /driver/benefits/{id}/action Header: Idempotency-Key: <uuid> โ€” required; a duplicate tap on Book/Apply/Claim must not create two bookings/applications/claims.

Backend re-validates eligibility, claim limits, expiry, and driver-active status on every call regardless of type โ€” this is not a one-time check performed only when the badge first turned "eligible."

Request body shape is determined by the benefit's fulfillmentType (backend dispatches internally; each shape is a stable contract Flutter can rely on):

Response varies correspondingly (QR image + token + 5-min expiry / booking confirmation / application ID + status / claim ID + tracking status).

Flow
// QR โ€” no body needed
{}

// BOOKING
{ "partnerId": "PS1002", "preferredDate": "2026-08-20", "preferredTime": "10:30" }

// APPROVAL
{ "remarks": "...", "documentKeys": ["drivers/DRV.../admission_letter.pdf"] }
// Document keys come from the SAME presigned-upload pipeline as registration (ยง4.1) โ€”
// not a separate multipart path. Same file-type/size/virus-scan handling applies.

// SHIPPING
{ "deliveryAddress": { "line1": "...", "city": "...", "pincode": "..." } }
// Defaults from driver profile if an address exists there; if registration (ยง4.2 Step 1)
// never captured one, this is the first time it's collected โ€” add it to the profile
// record here rather than treating it as benefit-specific data.

12.5 Points Ledger

Immutable, same pattern as the wallet ledger (ยง11.11) โ€” never update a points total directly:

Balance is always derived from the ledger.

Flow
+10  Ride Completed
+5   5-Star Rating
+100 100-Ride Milestone
-50  Admin Adjustment

12.6 Event-Based Progress Calculation

Progress is computed on the relevant event (ride completed, rating received, referral confirmed), not recalculated on every app open:

Flow
Ride Completed โ†’ event queue โ†’ reward engine โ†’ update progress
โ†’ milestone reached? โ†’ notification created โ†’ FCM push โ†’ dashboard reflects on next load/socket update

12.7 Partner Redemption (QR Type)

POST/partner/offers/redeem

POST /partner/offers/redeem โ€” requires partner-level authentication (API key or partner JWT), not just a valid QR token. A valid-looking token alone doesn't establish that the caller is the authorized partner; without caller auth, a token interception or replay against any endpoint can redeem a driver's benefit ahead of the legitimate partner. This applies even though the partner-facing portal UI itself is future scope (ยง15 of the source material) โ€” the auth model for this endpoint isn't deferrable just because the UI is.

Backend checks: token not already used, not expired (5 min from generation), driver still eligible/active โ†’ mark REDEEMED.

12.8 Admin Claims/Applications/Bookings Management

GET/admin/benefit-claims
PATCH/admin/benefit-claims/{id}
GET/admin/benefits/analytics

12.9 Database Tables

Flow
benefit_master, benefit_rule, benefit_partner, benefit_document,
driver_benefit_progress, driver_benefit_claim, benefit_application,
benefit_booking, benefit_shipping, driver_points_ledger

13. Driver Settings & Support

13.1 Profile Settings

GET/driver/settings/profile
PATCH/driver/settings/profile
PATCH/driver/settings/notifications

13.2 Help & Support

GET/driver/support/topics
GET/driver/support/topics/{id}
POST/driver/support/tickets
GET/driver/support/tickets
GET/driver/support/tickets/{id}
POST/driver/support/tickets/{id}/reply
POST/driver/sos

Ticket states: OPEN โ†’ IN_PROGRESS โ†’ RESOLVED โ†’ CLOSED (or REOPENED from RESOLVED if the driver replies again within a configurable window, e.g. 7 days โ€” after which a reply creates a new ticket rather than reopening an old one).

Emergency SOS (already referenced in the ride-lifecycle bottom card, ยง10) gets its own path, separate from the general ticket system โ€” it needs to reach a human faster than a support queue:

Triggers immediate admin/ops alert (not a queued ticket) with driver location and active-ride context attached. Define the actual escalation path (ops dashboard alert, SMS to a duty number, or integration with local emergency services) before this ships โ€” an SOS button that just creates a normal-priority ticket defeats its purpose.

Emergency SOS
POST /driver/sos
Header: Idempotency-Key: <uuid>
Body: { "rideId": "RID123", "lat": ..., "lng": ... }

13.3 App Info & Legal

GET/driver/app-config
GET/driver/legal/terms
GET/driver/legal/privacy-policy

app-config should be checked at every cold launch (same pattern as ยง5 profile status) โ€” this is how you handle a critical bug fix or policy change without waiting on app-store review lag, by gating access at the config level rather than the code level.

13.4 Logout & Account

POST/driver/auth/logout
POST/driver/account/deactivation-request

Invalidates the current device's refresh token server-side (not just a client-side token wipe โ€” per ยง3, AUTH_LOGOUT_REMOTE push exists specifically because sessions can be revoked from the backend, so logout must actually reach the backend, not just clear local storage) and unregisters the FCM token for this device.

Distinct from logout โ€” this is a driver requesting account closure. Given the KYC/financial data on file (ยง4, ยง11), this should route to admin review rather than execute instantly (outstanding wallet balance, active rides, pending withdrawals all need resolution first).

14. Rate Limits (Consolidated)

Extends the OTP-specific limits already defined in ยง3.1. Every state-mutating endpoint in this document gets an explicit limit โ€” "we'll rate-limit it" stated once here beats repeating the same sentence in every module.

Endpoint classLimitWindowStore
Send OTP3/mobile1 hourRedis (ยง3.1, already defined)
Verify OTP attempts5/OTPper-OTP lifetime (12 min)Redis
Registration submit3/registration token24 hoursRedis
Wallet withdraw request10/driver24 hoursRedis
Wallet pay-outstanding10/driver24 hoursRedis
Ride accept (per driver)soft-limited by dispatch offer timeout (ยง9.3), not a hard rate limitโ€”โ€”
SOS triggerunlimited โ€” never rate-limit an emergency pathโ€”โ€”
Support ticket creation10/driver24 hoursRedis
Benefit action (ยง12.4)20/driver24 hoursRedis
General authenticated API (catch-all)300 requests/driver5 minutesRedis, sliding window

429 response shape โ€” consistent across every limited endpoint, matching the pattern already established for OTP in ยง3.1:

429 response shape
{ "success": false, "message": "Rate limit exceeded. Please try again later.", "errorCode": "RATE_LIMIT_EXCEEDED", "retryAfterSeconds": 120 }

15. Security Guidelines (Consolidated)

This section indexes security decisions already made throughout the doc โ€” it does not introduce new policy, it exists so a security review doesn't require reading all 16 modules to find them.

  • All traffic over HTTPS/WSS only, no plaintext fallback, in every environment including current Cloudflare testing.
  • JWT access tokens short-lived; refresh flow re-authenticates the socket connection before expiry (ยง9.4) rather than allowing a stale token to persist on a long-held connection.
  • Authorization: Bearer on every authenticated REST call; JWT verified at socket handshake (ยง9.4).
  • Aadhaar, PAN, Driving Licence, Bank Account Number encrypted at rest (AES-GCM) with a separate HMAC blind index for duplicate detection โ€” never a unique constraint on the encrypted column itself (ยง4.3).
  • None of the above ever appear in application logs, error tracking, or request/response logging middleware โ€” this needs to be enforced at the logging-library config level (field redaction), not left to developer discipline.
  • Banned-identifier table (ยง6.3) checked independently from the active-duplicate check at registration.
  • No file bytes ever transit the app backend โ€” presigned URLs only, direct-to-storage (ยง4.1). This applies to every document upload in the app, including benefit applications (ยง12.4) and resubmissions (ยง6.2), not just initial registration.
  • Mandatory async virus/malware scan post-upload before any document is available to admin review; PENDING_SCAN gate blocks review until clear.
  • Every state-mutating POST/PATCH across the entire app carries Idempotency-Key, stored in Redis with 24โ€“48h TTL (ยง4.4) โ€” applies uniformly to registration, ride lifecycle (ยง10.10), wallet (ยง11), training (ยง7.3), and benefits (ยง12.4).
  • Ride state transitions validated server-side against the full canonical state list (ยง10), rejecting out-of-order or duplicate transitions rather than trusting client-supplied state.
  • Financial reservation-then-settle pattern (ยง11.3) for anything involving money leaving the platform โ€” never a bare status flag with no held funds.
  • Client-side payment callbacks (Razorpay Checkout success, etc.) never trigger a state change (unblock, credit, clear) โ€” only server-verified webhooks do, deduped by provider event ID (ยง11.8, ยง11.9).
  • Outbound calls to Razorpay carry their own idempotency key, separate from the app's internal header convention (ยง11.7).
  • Penny-drop bank verification before any account is payout-eligible (ยง11.10), environment-gated so it can't be silently skipped in production.
  • Geofence + freshness check before "arrived" can be marked (ยง10.3) โ€” distance alone isn't sufficient, the position must also be recent.
  • Server-side ride-request offer locking (ยง9.3) prevents a driver receiving two simultaneous offers or a stale-position bypass of dispatch logic.
  • Partner-authenticated redemption (ยง12.7) โ€” a valid QR token alone never authorizes a redemption without verifying the caller is the registered partner.

Rate Limiting โ€” see ยง14 for the full table; the principle is every state-mutating endpoint has an explicit limit, none are "unlimited by omission" except the SOS path, which is deliberately exempted.

  • Admin document-review actions logged per-action, not overwritten (ยง6.3) โ€” required given KYC-adjacent onboarding data.
  • Immutable ledgers (wallet ยง11.11, points ยง12.5) โ€” balances are always derived, never directly mutated, which is itself a security property (no code path can silently corrupt a balance without leaving a trace).

16. Sequence Diagrams

Full swimlane diagrams are better produced as actual diagram artifacts once the Flutter/backend team is assigned and API contracts are frozen โ€” ASCII sequence blocks in a markdown file degrade fast for anything with more than 4-5 actors (Flutter, Backend, Redis, Postgres, Razorpay/Mappls/MSG91 all appear in the ride-lifecycle and wallet flows). The state-machine and step-by-step flows already embedded in each module (ยง10 ride lifecycle, ยง9.3 dispatch, ยง11.3โ€“11.9 withdrawal) serve as the sequence reference for now. Recommend generating proper sequence diagrams (Mermaid or similar) per module once you're ready to hand this to a Flutter dev โ€” happy to produce those in Mermaid syntax on request for any specific flow.

17. Testing Credentials

Do not commit real values here or anywhere in this document โ€” this section is a placeholder structure, not actual secrets.
Dev/Staging OTP bypass: a fixed test OTP (e.g. 0000) should be enabled ONLY when phoneNumber
matches a whitelisted test-number pattern (e.g. +91999999XXXX), gated by environment variable,
never active in production regardless of config error โ€” this needs a hard code-level check
(environment == 'production' โ†’ bypass path does not exist in the compiled binary/build),
not just a runtime flag that could be flipped by mistake.

Test Razorpay: use Razorpay's test-mode keys and test card/UPI credentials (from Razorpay's
own docs) โ€” never real payment credentials in dev/staging.

Test MSG91: MSG91 sandbox/test route if available, or the OTP-bypass pattern above.

Test Mappls: Mappls provides sandbox API keys with usage caps โ€” use those in dev/staging,
separate from production keys, so dev testing never consumes production quota/billing.

18. Appendix

18.1 Global Error Code Reference

Consolidates error codes introduced across modules โ€” new codes should be added here as they're introduced, not left scattered.

CodeMeaningModule
OTP_LIMIT_EXCEEDEDOTP send rate limit hitยง3.1
INVALID_OTP / OTP_EXPIREDOTP verification failureยง3.2
GEOFENCE_NOT_METArrival marked outside pickup radiusยง10.3
TRAINING_SEQUENCE_VIOLATIONModule completed out of orderยง7.3
RATE_LIMIT_EXCEEDEDGeneric rate limit (see ยง14 table)ยง14

*(Extend this table as each remaining module โ€” Ride cancellation edge cases, Settings, etc. โ€” introduces new codes.)*

18.2 Glossary

TermMeaning
Blind indexHMAC-based deterministic hash of a sensitive field, used for uniqueness checks without decrypting (ยง4.3)
Reservation patternMoving funds to a held state at request time, not just at settlement (ยง11.3)
Presigned URLTime-limited direct-to-storage upload URL issued by backend, bypassing app-server file proxying (ยง4.1)

18.3 Environment Reference

(Duplicated from ยง2 for quick reference โ€” keep both in sync if this ever changes.)

Flow
Development   https://api-dev.speedoo.in/api/v1
Staging       https://api-staging.speedoo.in/api/v1
Production    https://api.speedoo.in/api/v1

19. Resubmission Upload Sub-Flow (ยง6.2 โ€” Now Resolved)

Referenced from ยง6.2, written out in full per the decision above:

Flow
1. Admin marks specific fields as needing correction (ยง6.1 issues array: field, step, reason)
2. Driver opens Action Required screen, sees only the flagged fields
3. For EACH flagged field: driver calls POST /drivers/documents/presign scoped to that field only
   (not a full re-presign of all documents)
4. Driver uploads directly to storage at the returned presigned URL
5. New object key is VERSIONED, not overwritten: e.g. drivingLicenseFront_v2
   (original drivingLicenseFront_v1 remains retrievable โ€” audit trail requirement from ยง6.2)
6. Driver calls PATCH /driver/profile/resubmit with the new versioned keys:
   { "resubmissionId": "RSB_12345", "updatedFields": [{ "field": "drivingLicenseFront", "newKey": "drivers/DRV.../dl_front_v2.jpg" }] }
7. Backend re-runs virus scan on new uploads (PENDING_SCAN gate applies again, ยง4.1)
8. Per-document status (ยง6.1 table) for the flagged field resets to UNDER_REVIEW; other
   already-approved documents are untouched
9. Overall status recomputes per ยง6 rule (derived from per-document statuses, not independently set)