> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tawked.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a sign-in flow

> A phone sign-in with Tawked Verify from end to end: the number field, the server calls, a code field that fills itself in, resends, and what to tell the person at each outcome.

A sign-in or sign-up by phone is two screens and two server calls. The person types a number, your server calls `start`, the person types (or autofills) the code, and your server calls `check`. This page covers the parts the [quickstart](/quickstart) leaves out.

<Warning>
  Every call to Tawked comes from your server. The browser or app talks only to your own backend: an API key never leaves the server, and the verification `id` stays in your server-side session.
</Warning>

## 1. Collect the number

Tawked sends to Saudi mobile numbers only, and accepts them in any of five forms (`0551234567`, `551234567`, `966551234567`, `00966551234567`, `+966551234567`). Send what the person typed: you do not need to normalise it. The full list is on [Numbers, language and the SMS](/destinations).

```html Number field theme={"dark"}
<label for="phone">Mobile number</label>
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" placeholder="05xxxxxxxx" />
```

A number Tawked cannot read answers `422 invalid_destination`. Show it under the field ("Enter a Saudi mobile number") and let the person correct it.

## 2. Start the verification on your server

Call `start` with four things beyond `to`:

| Field | Why |
| - | - |
| `lang` | The language of the SMS. Pass `en` for English, anything else sends Arabic. Use the language of your interface. |
| `reference` | Your own id for this attempt (a session or sign-up id, up to 64 characters). It comes back on `start` and on `GET /v1/verify/{id}`, and you can search for it under **Verify → Verifications**. |
| `client_ip` | The person's IP address, as your server sees it. It turns on the per-IP limit of the [protection guard](/reliability#the-protection-guard). Tawked never infers it from your request. |
| `Idempotency-Key` header | One per tap of "Send code". If the request times out, retry with the same key: you get the first answer back, and nothing is sent or charged twice. |

```javascript Node.js (server) theme={"dark"}
import { randomUUID } from 'node:crypto';

app.post('/auth/phone/start', async (req, res) => {
  const attempt = randomUUID();
  const r = await fetch('https://tawked.com/v1/verify/start', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.TAWKED_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': attempt,
    },
    body: JSON.stringify({
      to: req.body.phone,
      lang: req.body.lang === 'en' ? 'en' : 'ar',
      reference: `signin-${attempt}`,
      client_ip: req.ip,
    }),
  });
  const body = await r.json();
  if (r.status !== 201) return res.status(400).json({ error: body.error });

  req.session.verificationId = body.id; // the id never goes to the browser
  res.json({ expiresAt: body.expires_at });
});
```

<Note>
  An `Idempotency-Key` that already started a verification replays that verification, whatever the new body says. Make a new key for every new attempt, and never reuse one for another number.
</Note>

## 3. Let the code fill itself in

Tawked can add lines to the SMS that phones read the code from. Switch them on under **Verify → Settings**, in **Autofill**:

* **Add the domain line to the message** puts `@your-domain #123456` last. iOS and Android browsers offer the code above the keyboard, and the WebOTP API can read it. The domain comes from your application's reviewed website.
* **Android app hash** adds your app's 11-character SMS Retriever hash, so your Android app reads the code without the person typing it.

On `start`, `autofill` (`web`, `android` or `none`) picks the lines for one send, for example `android` when the request comes from your Android app. Leave it out to send every line you switched on. [Numbers, language and the SMS](/destinations#autofill-ready-sms) shows the message and its length limits.

The code field itself:

```html Code field theme={"dark"}
<label for="code">Code</label>
<input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" />
```

`autocomplete="one-time-code"` is what lets iOS and Android suggest the code. Set `maxlength` to your code length (6 by default). On the web, WebOTP can fill the field by itself when the SMS ends with the domain line:

```javascript Browser theme={"dark"}
if ('OTPCredential' in window) {
  const ac = new AbortController();
  navigator.credentials
    .get({ otp: { transport: ['sms'] }, signal: ac.signal })
    .then((otp) => {
      document.getElementById('code').value = otp.code;
    })
    .catch(() => {}); // the person can still type it
}
```

## 4. Check the code and act on the outcome

Your server posts the `id` from the session and the code as typed. Every outcome is a `200` with `verified` and `status`:

| `status` | What happened | What to show |
| - | - | - |
| `verified` | The code matched. | Sign the person in. |
| `invalid_code` | Wrong code. `attempts_remaining` says how many tries are left. | "That code is not right. You have 2 tries left." |
| `too_many_attempts` | That was the last allowed try. The verification is closed. | "Too many tries. Request a new code." Then start a new verification. |
| `failed` | Closed earlier: tries used up, or the send itself failed. | Offer a new code. |
| `expired` | The code's lifetime passed (300 seconds by default). | "This code has expired. Request a new one." |
| `canceled` | You canceled it. | Start again. |

An `id` your key does not own answers `404 not_found`. Treat it as a broken session and go back to the number screen.

```javascript Node.js (server) theme={"dark"}
app.post('/auth/phone/check', async (req, res) => {
  const r = await fetch('https://tawked.com/v1/verify/check', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.TAWKED_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ id: req.session.verificationId, code: req.body.code }),
  });
  const { verified, status, attempts_remaining } = await r.json();

  if (verified) {
    delete req.session.verificationId;
    return res.json({ ok: true }); // sign the person in here
  }
  res.status(400).json({ status, attemptsLeft: attempts_remaining });
});
```

Only the answer your server got from `check` signs someone in. Never accept a "verified" flag from the browser.

## 5. Resend and start again

Give the person a "Send a new code" button behind a short countdown, so an impatient tap does not send two messages. Tawked does not enforce a wait between resends: the countdown is yours to choose.

* **Resend** (`POST /v1/verify/{id}/resend`) sends a new code on the same `id`. The old code stops working, and the attempts and the lifetime start over. Each verification has 3 resends, then answers `429 resend_limit_reached`.
* **An expired or closed verification cannot be resent** (`409 not_resendable`). Call `start` again, with a new `Idempotency-Key`.
* **The number has an hourly cap.** A start and each resend count toward the application's "Codes per number per hour" (5 by default), across all your applications. Past it, `start` and `resend` answer `429 rate_limited`.

Every resend is a send: charged and limited like a new code.

## 6. Errors the person can see

Most errors are for you, not for the person. Map the few they can act on, and log the rest with the response's `X-Request-Id` header.

| `error` | Tell the person |
| - | - |
| `invalid_destination` | Enter a Saudi mobile number. |
| `rate_limited` | Too many codes were sent to this number. Try again later. |
| `resend_limit_reached` | Start again with a new code. |
| `ip_rate_limited` | Too many attempts from this network. Try again later. |

These are about your setup, so show a general "We could not send the code right now" and alert yourself: `insufficient_credits` (top up), `spend_cap_reached`, `application_paused`, `account_not_active`, `unauthorized`, `insufficient_scope`. The [Errors](/errors) page lists every code, and [Reliability and limits](/reliability#what-to-retry) says which to retry.

## Optional: hear about outcomes by webhook

If something else needs to know (an audit log, a fraud check), set a webhook under **Application settings → Webhooks**: it receives `verification.verified`, `verification.failed` and `verification.expired`. Your sign-in itself should rely on the `check` answer, which is immediate. See [Webhooks](/webhooks).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.