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

# POST /v3/request — Create OTP with Server-Generated Code

> POST /v3/request creates an OTP transaction and delivers a server-generated code via your chosen channel. Returns otp_id, status, price, and balance.

Use `POST /v3/request` when you want OTP.ID to generate and deliver the one-time code on your behalf. You choose the channel, destination, and optional parameters — OTP.ID handles code generation and delivery. The code is **never returned in the API response**; it travels only to the end user via the chosen channel. On success you receive a transaction ID (`otp_id`) you'll use later to verify what the user enters.

## Endpoint

```
POST https://api.otp.id/v3/request
```

## Request Headers

| Header          | Value              | Required |
| --------------- | ------------------ | -------- |
| `Authorization` | `Bearer <api_key>` | ✅ Yes    |
| `Content-Type`  | `application/json` | ✅ Yes    |

## Request Body

<ParamField body="channel" type="string" required>
  The delivery channel for the OTP. Accepted values:

  * `whatsapp` — WhatsApp message
  * `sms` — SMS text message
  * `email` — Email message
  * `misscall` — Missed call (the OTP code is derived from the caller's number)
  * `whatsapp_inbound` — User sends a WhatsApp message to OTP.ID to verify (see [WhatsApp Inbound](#whatsapp-inbound))
</ParamField>

<ParamField body="number" type="string" required>
  The destination phone number or email address. Required for all channels except `whatsapp_inbound`.

  * **Phone numbers**: digits only, minimum 10 characters (e.g. `"6281234567890"`)
  * **Email**: a valid email address (e.g. `"user@example.com"`)
</ParamField>

<ParamField body="email" type="string">
  Alternative field for the destination email address. Used as a fallback when `number` is empty and `channel` is `email`. If both `number` and `email` are provided, `number` takes precedence.
</ParamField>

<ParamField body="brand" type="string">
  The brand name included in the OTP message (e.g. `"MyApp"`). Defaults to the `brand_name` configured on your merchant account.
</ParamField>

<ParamField body="otp_length" type="integer">
  Number of digits in the generated OTP. Accepted range: `4`–`8`. Defaults to `6`. Values outside this range are **clamped** to the nearest bound — not rejected.
</ParamField>

<ParamField body="ttl" type="integer">
  Time-to-live in seconds before the OTP expires. Accepted range: `60`–`900`. Defaults to `300` (5 minutes). Values outside this range are **clamped** to the nearest bound — not rejected.
</ParamField>

<ParamField body="external_id" type="string">
  Your own idempotency key for this transaction. If you submit a request with an `external_id` that already exists, OTP.ID returns the original `otp_id` without sending a new message and without changing your balance. See [Idempotency](#idempotency) below.
</ParamField>

## Response Fields

<ResponseField name="otp_id" type="string">
  The unique transaction ID assigned by OTP.ID. Use this value in subsequent calls to `POST /v3/verify` and `GET /v3/otp/{otp_id}`. Format: `OTP` + `YYYYMMDD` + 4 letters + 6 digits (e.g. `OTP20260807ABCD000001`).
</ResponseField>

<ResponseField name="status" type="string">
  Current delivery status of the transaction:

  * `sent` / `success` — code was delivered to the destination
  * `pending` — used for `whatsapp_inbound` while awaiting the user's reply
  * `failed` — vendor delivery failed (see [Delivery Failures](#delivery-failures))
</ResponseField>

<ResponseField name="channel" type="string">
  The channel used to deliver the OTP, echoed from your request.
</ResponseField>

<ResponseField name="number" type="string">
  The destination address used, echoed from your request.
</ResponseField>

<ResponseField name="price" type="integer">
  Transaction cost in IDR. This field is present even when delivery fails. No charge is applied when delivery fails — see [Delivery Failures](#delivery-failures).
</ResponseField>

<ResponseField name="last_balance" type="integer">
  Your account balance in IDR after this transaction. Unchanged if the delivery failed or if this was an idempotency replay.
</ResponseField>

<ResponseField name="expires_at" type="string">
  The datetime at which this OTP expires, in `YYYY-MM-DD HH:MM:SS` format (WIB, UTC+7).
</ResponseField>

<ResponseField name="verification" type="object">
  Present **only** for `whatsapp_inbound` transactions. Contains the instructions your user needs to verify themselves.

  <Expandable title="verification fields">
    <ResponseField name="verification.wa_number" type="string">
      The OTP.ID WhatsApp number the user must message.
    </ResponseField>

    <ResponseField name="verification.message" type="string">
      The exact message body the user must send (contains the matching token).
    </ResponseField>

    <ResponseField name="verification.wa_link" type="string">
      A `https://wa.me/` deep-link that pre-fills both the number and message body for the user.
    </ResponseField>

    <ResponseField name="verification.expires_at" type="string">
      Expiry of the inbound token in `YYYY-MM-DD HH:MM:SS` WIB format.
    </ResponseField>
  </Expandable>
</ResponseField>

## Examples

### WhatsApp

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.otp.id/v3/request \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"channel": "whatsapp", "number": "6281234567890", "brand": "MyApp", "ttl": 300}'
  ```
</CodeGroup>

**Success response:**

```json theme={null}
{
  "success": true,
  "data": {
    "otp_id": "OTP20260807ABCD000001",
    "status": "sent",
    "channel": "whatsapp",
    "number": "6281234567890",
    "price": 350,
    "last_balance": 99650,
    "expires_at": "2026-08-07 10:05:00"
  },
  "error": null
}
```

### Email

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.otp.id/v3/request \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"channel": "email", "number": "user@example.com", "brand": "MyApp"}'
  ```
</CodeGroup>

### WhatsApp Inbound

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.otp.id/v3/request \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"channel": "whatsapp_inbound", "brand": "MyApp", "ttl": 300}'
  ```
</CodeGroup>

**Success response (whatsapp\_inbound):**

```json theme={null}
{
  "success": true,
  "data": {
    "otp_id": "OTP20260807ABCD000002",
    "status": "pending",
    "channel": "whatsapp_inbound",
    "number": null,
    "price": 350,
    "last_balance": 99300,
    "expires_at": "2026-08-07 10:05:00",
    "verification": {
      "wa_number": "6285212345678",
      "message": "OTPID V-8FK2QN9P — verifikasi MyApp. Kirim pesan ini tanpa mengubah isinya.",
      "wa_link": "https://wa.me/6285212345678?text=OTPID%20V-8FK2QN9P%20%E2%80%94%20verifikasi%20MyApp.%20Kirim%20pesan%20ini%20tanpa%20mengubah%20isinya.",
      "expires_at": "2026-08-07 10:05:00"
    }
  },
  "error": null
}
```

<Note>
  For `whatsapp_inbound`, do not pass a `number`. Show the user the `verification.wa_link` (or the `wa_number` + `message` pair) and have them tap/send it from their own WhatsApp. OTP.ID matches the inbound message to this transaction automatically — no call to `POST /v3/verify` is needed.
</Note>

## Special Behaviors

### Delivery Failures

When the OTP.ID vendor accepts your request but ultimately fails to deliver the message (e.g. the number is unreachable), the API still returns **HTTP 200** with `"status": "failed"`. Your balance is **not** deducted. Check `data.status` in your application logic — do not rely solely on the HTTP status code.

**Example — delivery failure (HTTP 200):**

```json theme={null}
{
  "success": true,
  "data": {
    "otp_id": "OTP20260807ABCD000005",
    "status": "failed",
    "channel": "sms",
    "number": "6281234567890",
    "price": 0,
    "last_balance": 99650,
    "expires_at": "2026-08-07 10:05:00"
  },
  "error": null
}
```

### Idempotency

If you provide an `external_id` and a request with that ID was already processed, OTP.ID returns the **original `otp_id`** in the response without sending a new message and without changing `last_balance`. This lets you safely retry requests after network timeouts. The response body is otherwise identical to the original.

### WhatsApp Inbound

When `channel` is `whatsapp_inbound`, the `number` field is optional. Omitting it means OTP.ID will accept the verification reply from **any** WhatsApp number that sends the matching token — useful when you do not yet know the user's number. The transaction transitions to `verified` automatically when the matching message arrives.

## Error Responses

All errors follow the shape `{"success": false, "data": null, "error": {"code": "...", "message": "..."}}`.

| HTTP Status | Error Code                 | Description                                                                                                                                                    |
| ----------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400         | `INVALID_CHANNEL`          | The `channel` value is not one of the accepted options.                                                                                                        |
| 400         | `INVALID_NUMBER`           | The `number` format is invalid or missing for a channel that requires it.                                                                                      |
| 400         | `VALIDATION_ERROR`         | Another request body field failed validation.                                                                                                                  |
| 401         | `UNAUTHORIZED`             | The `Authorization` header is missing or the API key is invalid.                                                                                               |
| 402         | `INSUFFICIENT_BALANCE`     | Your account balance is too low to cover this transaction.                                                                                                     |
| 409         | `DUPLICATE_EXTERNAL_ID`    | An `external_id` collision with a *different* request payload was detected. The `error.details.existing_otp_id` field contains the conflicting transaction ID. |
| 429         | `RATE_LIMITED`             | You have exceeded the API-level request rate limit.                                                                                                            |
| 429         | `DESTINATION_RATE_LIMITED` | Too many OTPs have been sent to this destination in a short window.                                                                                            |
| 503         | `CHANNEL_UNAVAILABLE`      | The requested channel is temporarily unavailable.                                                                                                              |
| 500         | `INTERNAL_ERROR`           | An unexpected server-side error occurred.                                                                                                                      |

**Example — insufficient balance:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Account balance is too low to process this request"
  }
}
```

**Example — duplicate external\_id collision:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "DUPLICATE_EXTERNAL_ID",
    "message": "The external_id has already been used for a different request",
    "details": {
      "existing_otp_id": "OTP20260807ABCD000001"
    }
  }
}
```
