> For the complete documentation index, see [llms.txt](https://docs.fraudcheck.co.za/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.fraudcheck.co.za/consumer-service/identity/phone-otp.md).

# Phone OTP

Phone OTP sends a one-time password by SMS to a South African mobile number, then lets you verify the code the person enters. Fraudcheck generates the code, stores only a secure hash, and delivers the SMS. Use this when you need to prove that someone can receive messages on a given number before you continue onboarding, login, or a high-risk action.

## When to use this

Use this service to:

* Confirm possession of a South African mobile number during onboarding or account recovery
* Gate a sensitive action behind an SMS OTP after identity or risk checks
* Poll delivery and verification status without exposing the OTP code

## How it works

1. **Send** — `POST /phone-otp/send` generates a 6-digit OTP, stores a secure hash, and sends the SMS. The response returns a `request_id` (keep this for verify and status).
2. **Verify** — `POST /phone-otp/verify` checks the code the user entered against the stored hash.
3. **Status** — `GET /phone-otp/status/{request_id}` returns lifecycle and optional SMS delivery status. The OTP code is never returned.

| Property              | Value                                   |
| --------------------- | --------------------------------------- |
| Response type         | `Synchronous`                           |
| Typical response time | < 5s (send depends on SMS delivery)     |
| Result retrieval      | Inline; also via status by `request_id` |
| Consent required      | `Yes` (on send)                         |
| Region                | South Africa                            |

### Lifecycle defaults

| Setting          | Value                                      |
| ---------------- | ------------------------------------------ |
| OTP length       | 6 digits                                   |
| Expiry           | 10 minutes (`600` seconds)                 |
| Max verify tries | 5                                          |
| Resend cooldown  | 60 seconds per account + normalised number |

Statuses: `pending` → `verified`, or `expired` / `failed`.

***

## Authentication

```http
Authorization: Bearer {your_access_token}
```

Send, verify, and status all require a bearer token. See [Authentication](/consumer-service/authentication.md).

***

## Endpoints

```http
POST /phone-otp/send
POST /phone-otp/verify
GET  /phone-otp/status/{request_id}
```

Paths are relative to the [API base URL](/consumer-service/environments-and-base-url.md).

***

## Send OTP

If SMS delivery fails, the API returns an error.

### Headers

| Header          | Required | Value              |
| --------------- | -------- | ------------------ |
| `Authorization` | Yes      | `Bearer {token}`   |
| `Content-Type`  | Yes      | `application/json` |

### Body parameters

| Field                          | Type    | Required | Description                                                                                                 |
| ------------------------------ | ------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| `IdentityNo`                   | string  | Yes      | 13-digit South African ID number                                                                            |
| `FirstName`                    | string  | Yes      | Consumer's first name                                                                                       |
| `LastName`                     | string  | Yes      | Consumer's surname                                                                                          |
| `CellNo`                       | string  | Yes      | South African mobile number. Local (`0721234567`) or international (`+27721234567` / `27721234567`) format. |
| `ConsentObtainedByDataSubject` | boolean | Yes      | Must be `true`. Confirms POPIA consent.                                                                     |
| `AccountID`                    | string  | No       | Account to use when your token can act for more than one account.                                           |
| `GenericParameter1`            | string  | No       | Optional client reference. Echoed on verify and status as `client_reference`.                               |
| `CostCenter`                   | integer | No       | Client-side cost centre reference recorded against this transaction                                         |

### Example request

```json
{
  "IdentityNo": "8601015800086",
  "FirstName": "John",
  "LastName": "Smith",
  "CellNo": "0721234567",
  "ConsentObtainedByDataSubject": true
}
```

`CellNo` is normalised to `+27721234567` before the SMS is sent.

### Response fields

| Field            | Type     | Description                                   |
| ---------------- | -------- | --------------------------------------------- |
| `request_id`     | string   | UUID used for verify and status               |
| `transaction_id` | string   | Unique identifier for this send               |
| `cell_no`        | string   | Number after normalisation (E.164 with `+`)   |
| `expires_at`     | datetime | When the OTP expires (UTC)                    |
| `sent`           | boolean  | `true` when the SMS was accepted for delivery |

### Example response

```json
{
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "transaction_id": "TXN20261102120000001",
  "cell_no": "+27721234567",
  "expires_at": "2026-11-02T10:10:00",
  "sent": true
}
```

***

## Verify OTP

Checks the code for a prior send. The request must use a token for the same account that performed the send.

### Headers

| Header          | Required | Value              |
| --------------- | -------- | ------------------ |
| `Authorization` | Yes      | `Bearer {token}`   |
| `Content-Type`  | Yes      | `application/json` |

### Body parameters

| Field        | Type   | Required | Description                                   |
| ------------ | ------ | -------- | --------------------------------------------- |
| `request_id` | string | Yes      | UUID from the send response (`36` characters) |
| `code`       | string | Yes      | OTP the user entered (`4`–`10` characters)    |

### Example request

```json
{
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "code": "123456"
}
```

### Response fields

| Field              | Type             | Description                                      |
| ------------------ | ---------------- | ------------------------------------------------ |
| `verified`         | boolean          | `true` when the code matched                     |
| `phone_number`     | string           | Normalised E.164 number                          |
| `verified_at`      | datetime \| null | When verification succeeded                      |
| `client_reference` | string \| null   | Value from send `GenericParameter1`, if provided |

Calling verify again after a successful verification returns `verified: true` with the original `verified_at` (idempotent success).

### Example response

```json
{
  "verified": true,
  "phone_number": "+27721234567",
  "verified_at": "2026-11-02T10:05:12",
  "client_reference": null
}
```

***

## Status

Poll verification and SMS delivery state. Never returns the OTP code.

### Headers

| Header          | Required | Value            |
| --------------- | -------- | ---------------- |
| `Authorization` | Yes      | `Bearer {token}` |

### Path parameters

| Field        | Type   | Required | Description                 |
| ------------ | ------ | -------- | --------------------------- |
| `request_id` | string | Yes      | UUID from the send response |

### Response fields

| Field                 | Type             | Description                                        |
| --------------------- | ---------------- | -------------------------------------------------- |
| `request_id`          | string           | OTP request id                                     |
| `status`              | string           | `pending`, `verified`, `expired`, or `failed`      |
| `phone_number`        | string           | Normalised E.164 number                            |
| `expires_at`          | datetime         | OTP expiry (UTC)                                   |
| `verified_at`         | datetime \| null | Set when `status` is `verified`                    |
| `attempts`            | integer          | Failed verification attempts so far                |
| `client_reference`    | string \| null   | Value from send `GenericParameter1`, if provided   |
| `sms_delivery_status` | string \| null   | Delivery outcome when available (e.g. `delivered`) |
| `sms_delivered_at`    | datetime \| null | When delivery was confirmed, if available          |

### Example response

```json
{
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "phone_number": "+27721234567",
  "expires_at": "2026-11-02T10:10:00",
  "verified_at": null,
  "attempts": 0,
  "client_reference": null,
  "sms_delivery_status": "delivered",
  "sms_delivered_at": "2026-11-02T10:00:08"
}
```

***

## Phone numbers

Only South African mobile numbers are documented for this product. Numbers are normalised before the SMS is sent.

| Input style            | Example        | Result         |
| ---------------------- | -------------- | -------------- |
| Local                  | `0721234567`   | `+27721234567` |
| International with `+` | `+27721234567` | `+27721234567` |
| International digits   | `27721234567`  | `+27721234567` |

Spaces and hyphens are stripped. A leading trunk `0` after `+27` (for example `+270721234567`) is corrected to `+27721234567`.

***

## Errors

Error bodies commonly include a structured `detail` with `message` and `code`.

| HTTP | Code                            | Where           | Meaning                                      | Action                                     |
| ---- | ------------------------------- | --------------- | -------------------------------------------- | ------------------------------------------ |
| 400  | `invalid_phone`                 | Send            | Number could not be normalised               | Provide a valid SA mobile number           |
| 400  | `invalid_code`                  | Verify          | Code does not match                          | Ask the user to re-enter; check attempts   |
| 400  | `expired`                       | Verify          | OTP past `expires_at`                        | Call send again                            |
| 400  | `failed` / `expired`            | Verify          | Request already failed or expired            | Call send again                            |
| 401  | `unauthorized`                  | All             | Token missing or expired                     | Refresh your access token                  |
| 403  | `forbidden`                     | Send            | Account lacks access to Phone OTP            | Contact your account manager               |
| 404  | `not_found`                     | Verify / Status | Unknown `request_id` for this account        | Use the `request_id` from send             |
| 422  | `invalid_input`                 | Send            | Required field missing or consent not `true` | See validation details                     |
| 429  | `resend_cooldown`               | Send            | Resend within cooldown window                | Wait \~60 seconds and retry                |
| 429  | `max_attempts` / `invalid_code` | Verify          | Too many wrong codes                         | Call send again for a new OTP              |
| 502  | `sms_send_failed`               | Send            | SMS could not be delivered                   | Retry later; contact support if persistent |
| 500  | `server_error`                  | Send            | Unexpected failure                           | Contact support with `transaction_id`      |

***

## Recommended decisioning

| Situation                                     | Recommended action                                     |
| --------------------------------------------- | ------------------------------------------------------ |
| Send returns `sent: true`                     | Prompt user for the code; store `request_id`           |
| Verify returns `verified: true`               | Continue the trusted step for that `phone_number`      |
| Verify `invalid_code` with attempts remaining | Allow retry; show remaining attempts if you track them |
| Verify `expired` or `max_attempts`            | Start a new send                                       |
| Status `sms_delivery_status` not delivered    | Warn the user; offer resend after cooldown             |
| High SIM swap risk on the number              | Prefer another channel or step-up before send          |

***

## Code examples

{% tabs %}
{% tab title="cURL" %}

```bash
BASE_URL="https://consumer-service-api.fraudcheckonline.co.za/consumer-service"

# 1. Send
curl -X POST "$BASE_URL/phone-otp/send" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "IdentityNo": "8601015800086",
    "FirstName": "John",
    "LastName": "Smith",
    "CellNo": "0721234567",
    "ConsentObtainedByDataSubject": true
  }'

# 2. Verify (use request_id from send)
curl -X POST "$BASE_URL/phone-otp/verify" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "request_id": "550e8400-e29b-41d4-a716-446655440000",
    "code": "123456"
  }'

# 3. Status
curl -X GET "$BASE_URL/phone-otp/status/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

{% endtab %}

{% tab title="Python" %}

```python
import requests

BASE_URL = "https://consumer-service-api.fraudcheckonline.co.za/consumer-service"
headers = {
    "Authorization": f"Bearer {access_token}",
    "Content-Type": "application/json",
}

send = requests.post(
    f"{BASE_URL}/phone-otp/send",
    headers=headers,
    json={
        "IdentityNo": "8601015800086",
        "FirstName": "John",
        "LastName": "Smith",
        "CellNo": "0721234567",
        "ConsentObtainedByDataSubject": True,
    },
)
send.raise_for_status()
request_id = send.json()["request_id"]

verify = requests.post(
    f"{BASE_URL}/phone-otp/verify",
    headers=headers,
    json={"request_id": request_id, "code": user_entered_code},
)
verify.raise_for_status()
print(verify.json())

status = requests.get(
    f"{BASE_URL}/phone-otp/status/{request_id}",
    headers={"Authorization": f"Bearer {access_token}"},
)
print(status.json()["status"], status.json().get("sms_delivery_status"))
```

{% endtab %}

{% tab title="JavaScript (Node.js)" %}

```javascript
const BASE_URL = "https://consumer-service-api.fraudcheckonline.co.za/consumer-service";

const sendRes = await fetch(`${BASE_URL}/phone-otp/send`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    IdentityNo: "8601015800086",
    FirstName: "John",
    LastName: "Smith",
    CellNo: "0721234567",
    ConsentObtainedByDataSubject: true,
  }),
});
const { request_id: requestId } = await sendRes.json();

const verifyRes = await fetch(`${BASE_URL}/phone-otp/verify`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ request_id: requestId, code: userEnteredCode }),
});
console.log(await verifyRes.json());

const statusRes = await fetch(`${BASE_URL}/phone-otp/status/${requestId}`, {
  headers: { Authorization: `Bearer ${accessToken}` },
});
console.log(await statusRes.json());
```

{% endtab %}
{% endtabs %}

***

## Compliance & consent

* Obtain explicit consent from the data subject before calling send.
* Set `ConsentObtainedByDataSubject` to `true` on send.
* Retain proof of consent with the `transaction_id` from send and the `request_id` used for verification.
* Do not log or store the plaintext OTP from the user beyond the verification attempt itself.

***

## FAQ

<details>

<summary>What format should `CellNo` be in?</summary>

Either local (`0721234567`) or international (`+27721234567` / `27721234567`) South African format is accepted.

</details>

<details>

<summary>How long is the code valid?</summary>

10 minutes from send. After expiry, call send again (subject to the resend cooldown).

</details>

<details>

<summary>What happens if the user enters the wrong code too many times?</summary>

After 5 failed attempts the request moves to `failed`. You must send a new OTP.

</details>

<details>

<summary>Why do I get a resend cooldown?</summary>

The same account cannot send another OTP to the same normalised number within 60 seconds. Wait and retry.

</details>

<details>

<summary>Does status return the OTP?</summary>

No. Status never includes the code. Only the user receives it by SMS.

</details>

***

## Changelog

| Date       | Version | Change           |
| ---------- | ------- | ---------------- |
| 2026-11-02 | v1.0    | Initial release. |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.fraudcheck.co.za/consumer-service/identity/phone-otp.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
