# Sign-in OTP API — what changed, and what the app should do

**For the mobile app developer (and any cloud/AI assistant helping on the app).**
Date: 2026-10-06. Backend branch: `fix/otp-verify-throttle-2026-10-06`.

## TL;DR

The WhatsApp OTP sign-in was hardened on the backend. **The happy path did not change at
all**: send a code, enter the correct code, register — same requests, same success responses,
same shapes. The app keeps working with **no changes required**.

What is new is a small set of **extra error responses** that only appear in abuse or edge
cases (too many wrong codes, too many sends, an expired code, or a register call that skipped
verification). The app will not crash on them — any HTTP client treats them as a normal
non-success response — but to show the user a clear message instead of a generic error, the
app should read the `message` field on these responses. Details below.

## The endpoints involved

- `POST /api/v2/send-otp` style send (the existing send-otp call)
- `POST /api/v2/verify-otp`
- `POST /api/v2/auth/register`

## What is unchanged (no app work needed)

- **Sending a code**, then **verifying the correct code**: identical request and response to
  before. `verify-otp` still returns `200` with `{ "result": true, "action": "register" | ... }`
  and the canonical `phone`, exactly as today.
- **A wrong code** still returns **`401`** with `{ "result": false, "message": "Invalid or expired OTP" }`
  — same status and shape the app already handles.
- **Registration** of a verified number: identical success response.

## What is new (please handle the `message`, no breakage either way)

All of these return `result: false` (or `success: false`) with a human message in `message`.
None of them are reached by a normal user entering the right code in time.

| Case | Endpoint | HTTP status | `message` (example, localized) | When it happens |
|---|---|---|---|---|
| Too many wrong codes | `verify-otp` | **429** | "Too many incorrect attempts. Please wait a little and try again." | After 3 wrong codes for one number within 10 min. The number is locked for a short cool‑off. |
| Code expired | `verify-otp` | `401` | "The code has expired. Please request a new one." | The code was sent more than 10 minutes ago. |
| Too many sends | send-otp | **429** | "You have requested a code several times. Please wait a little and try again." | A code was already sent in the last 60 s, or more than 5 in the last hour, for that number. |
| Not verified before register | `auth/register` | `422` | "Please verify your phone number before creating an account." | A register call arrived for a number that did not just pass `verify-otp`. The normal flow never hits this, because the app always verifies first. |

Notes:
- **`429` is the only genuinely new status.** If the app currently assumes every response is
  `200`/`401`, please make sure a `429` (and the existing `422` on register) simply shows
  `message` rather than falling into a generic "something went wrong" path.
- **`422` on register is not new** — register already returned `422` for validation errors
  (e.g. phone already taken), with `message` as an **array**. The new "verify first" case
  keeps that same shape (`message` is an array of strings) so nothing about parsing changes.
- All messages come from the server already translated to the user's language, so the app can
  display `message` directly.

## Recommended handling in the app (optional but nicer)

1. On `verify-otp` and send-otp, if the response status is `429`, show `message` and, if you
   want, disable the "resend"/"verify" button for a few seconds. The server also sends the
   wait time on the send path.
2. On the "code expired" `401`, treat it like a wrong code but show its `message` and offer
   "resend".
3. You do **not** need to add anything for the register `422` "verify first" case — it cannot
   occur in the normal flow (the app always verifies first). It exists only to block a request
   built by hand.

## Why these were added (one line each)

- Lock after a few wrong codes → a 4‑digit code can no longer be brute‑forced.
- Send limit → nobody can run up the WhatsApp bill or spam a person.
- Expiry → an old code stops working.
- Register requires a verified number → no account can be opened for a number that was never
  verified.

## What did **not** change and is safe to rely on

- Request bodies and field names are unchanged.
- Success responses (`result: true` / `success: true`) are byte‑for‑byte the same.
- Phone normalization is unchanged; the server still returns the canonical `+<code><number>`.
- No database schema change; nothing to migrate on the app side.

If anything here is unclear, or the app relies on a response detail not covered above, tell the
backend side before this ships so we can keep the contract exactly as the app expects.

## Update 2026-10-07 — a correct code was refused on the live site

A customer typed the code from the WhatsApp message, got "Invalid OTP" three times and the
number locked. Cause: two sends for one number landed together (the sign-in button pressed
twice), each generated its own code, and verify read the wrong row. Fixed on the server; what
the app sees:

- `send-otp` tapped twice within ~15 s: the second call answers `success: true` **without**
  generating a second code — the first message is the valid one. Nothing to change in the app,
  but there is no need to disable the button any more either.
- Verify always checks against the **newest** code for the number, whatever spelling the number
  was sent with (`+963…`, `963…`, `00963…`).
- A new send (**resend**) clears the wrong-code lock for that number. So after a `429` on
  `verify-otp`, offering "resend" is the way out (the send limits still apply: once a minute,
  five an hour).
- Request bodies, field names and success responses are unchanged.

### Addresses and governorate coupons (same date)

- `user/shipping/create`: the customer's **first** address is now saved with `set_default = 1`
  (later ones stay `0`, `make_default` works as before). Before, nothing marked a default, so
  everything judged by the default address saw "no address".
- The coupon list and `coupon-apply` judge a governorate coupon by the default address, or, when
  none is marked default, by the address added or edited last. A customer who chose Damascus in
  the app now sees the Damascus coupon.
