> ## 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.

# OTP.ID API Error Codes: All 14 Codes and How to Handle Them

> Complete reference for all 14 OTP.ID API error codes with HTTP statuses, causes, example responses, and resolution steps for each code.

Every OTP.ID API response uses a consistent JSON envelope. When a request fails, the `success` field is `false`, the `data` field is `null`, and the `error` object contains a machine-readable `code` and a human-readable `message`. Always branch your error-handling logic on `error.code` — the code string is stable across API versions, while the message text may change without notice.

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message"
  }
}
```

<Warning>
  A wrong OTP code submitted to `POST /v3/verify` is **not** an error response. The API returns HTTP 200 with `success: true` and `data.verified: false`. Only check for error codes when `success` is `false`.
</Warning>

## Quick reference

| Code                                                    | HTTP Status | Summary                                                |
| ------------------------------------------------------- | ----------- | ------------------------------------------------------ |
| [`UNAUTHORIZED`](#unauthorized)                         | 401         | Missing or invalid API key                             |
| [`VALIDATION_ERROR`](#validation_error)                 | 400         | Invalid payload or missing required fields             |
| [`INVALID_CHANNEL`](#invalid_channel)                   | 400         | Unrecognized or disallowed channel value               |
| [`INVALID_NUMBER`](#invalid_number)                     | 400         | Phone number or email address is invalid               |
| [`INSUFFICIENT_BALANCE`](#insufficient_balance)         | 402         | Merchant balance too low for this transaction          |
| [`OTP_NOT_FOUND`](#otp_not_found)                       | 404         | `otp_id` does not exist or belongs to another merchant |
| [`DUPLICATE_EXTERNAL_ID`](#duplicate_external_id)       | 409         | Same `external_id` reused with different parameters    |
| [`OTP_EXPIRED`](#otp_expired)                           | 422         | OTP TTL has elapsed                                    |
| [`TOO_MANY_ATTEMPTS`](#too_many_attempts)               | 422         | 5 failed verification attempts reached                 |
| [`ALREADY_USED`](#already_used)                         | 422         | OTP already successfully verified                      |
| [`RATE_LIMITED`](#rate_limited)                         | 429         | Global rate limit exceeded (20 req/s per API key)      |
| [`DESTINATION_RATE_LIMITED`](#destination_rate_limited) | 429         | Per-destination limit exceeded                         |
| [`CHANNEL_UNAVAILABLE`](#channel_unavailable)           | 503         | Channel or vendor is temporarily unavailable           |
| [`INTERNAL_ERROR`](#internal_error)                     | 500         | Unexpected server error                                |

***

## Authentication errors

<h3 id="unauthorized">
  `UNAUTHORIZED` — HTTP 401
</h3>

Your request did not include a valid API key.

**Causes:**

* The `Authorization` header is missing entirely
* The header is present but empty or malformed (e.g., not using `Bearer <api_key>`)
* The API key has been revoked or does not exist

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "API key tidak valid atau tidak ditemukan"
  }
}
```

**How to resolve:** Verify that every request includes the header `Authorization: Bearer <api_key>` using a valid key from the [OTP.ID dashboard](https://otp.id). If the key was recently rotated, update it in your application configuration.

***

## Request validation errors

<h3 id="validation_error">
  `VALIDATION_ERROR` — HTTP 400
</h3>

The request body is missing required fields or contains values that fail schema validation.

**Causes:**

* A required field (e.g., `channel`, `number`) is absent from the request body
* A field value is the wrong type (e.g., a number where a string is expected)
* A field value violates a constraint (e.g., a `pin_length` outside the allowed range)

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Field 'channel' wajib diisi"
  }
}
```

**How to resolve:** Check the `error.message` text for the specific field that failed validation, then correct your request body. Consult the [API Reference](/api-reference/request-otp) for each endpoint's required and optional fields.

***

<h3 id="invalid_channel">
  `INVALID_CHANNEL` — HTTP 400
</h3>

The `channel` value you provided is not recognised, or you used a channel that is not permitted on the specified endpoint.

**Causes:**

* The `channel` string does not match any supported value (`whatsapp`, `sms`, `email`, `missed_call`, `whatsapp_inbound`)
* You passed `whatsapp_inbound` to `POST /v3/send` — this channel is only valid with `POST /v3/request` because it requires the user to initiate the WhatsApp message

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "INVALID_CHANNEL",
    "message": "Channel tidak valid"
  }
}
```

**How to resolve:** Use one of the supported channel values listed in the [Channels guide](/concepts/channels). If you need WhatsApp Inbound, switch to the `POST /v3/request` endpoint. See the [WhatsApp Inbound guide](/guides/whatsapp-inbound) for the full flow.

***

<h3 id="invalid_number">
  `INVALID_NUMBER` — HTTP 400
</h3>

The destination phone number or email address could not be parsed or validated.

**Causes:**

* A phone number is not in E.164 format (e.g., `081234567890` instead of `6281234567890`)
* The phone number contains non-numeric characters
* The email address is syntactically invalid

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "INVALID_NUMBER",
    "message": "Nomor telepon tidak valid"
  }
}
```

**How to resolve:** Format phone numbers in E.164 without a leading `+` (e.g., `6281234567890` for an Indonesian number). For email, ensure the address conforms to standard format (`user@example.com`).

***

## Balance errors

<h3 id="insufficient_balance">
  `INSUFFICIENT_BALANCE` — HTTP 402
</h3>

Your merchant account does not have enough prepaid balance to cover the cost of this transaction.

**Causes:**

* Your balance has fallen below the per-transaction price for the requested channel
* A recent top-up has not yet been applied to your account

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Saldo tidak cukup"
  }
}
```

**How to resolve:** Top up your balance from the [OTP.ID dashboard](https://otp.id). Review the [Billing guide](/concepts/billing) for per-channel pricing details. Consider setting up a low-balance alert to avoid disruption in production.

***

## Not found errors

<h3 id="otp_not_found">
  `OTP_NOT_FOUND` — HTTP 404
</h3>

The `otp_id` you referenced does not exist or is not owned by the API key making the request.

**Causes:**

* The `otp_id` was typed or stored incorrectly
* The OTP was created with a different API key (cross-merchant access is not permitted)

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "OTP_NOT_FOUND",
    "message": "OTP tidak ditemukan"
  }
}
```

**How to resolve:** Confirm that you are storing and forwarding the `otp_id` value exactly as returned by `POST /v3/request` or `POST /v3/send`. Ensure you are using the same API key that created the OTP transaction.

***

## Conflict errors

<h3 id="duplicate_external_id">
  `DUPLICATE_EXTERNAL_ID` — HTTP 409
</h3>

You submitted an `external_id` that has already been used for a different OTP transaction with different parameters.

**Causes:**

* Your system retried a request with the same `external_id` but changed the `channel`, `number`, or other parameters between retries
* Two concurrent processes generated the same `external_id` with different payloads

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "DUPLICATE_EXTERNAL_ID",
    "message": "external_id sudah pernah dipakai dengan parameter berbeda",
    "details": {
      "existing_otp_id": "OTP20260807ABCD000001"
    }
  }
}
```

<Note>
  This is the only error code that includes an `error.details` object. The `existing_otp_id` field gives you the `otp_id` of the original transaction. Use `GET /v3/otp/{otp_id}` to retrieve its current status and continue the verification flow without creating a new OTP.
</Note>

**How to resolve:** Read `error.details.existing_otp_id` and look up the original transaction with `GET /v3/otp/{otp_id}`. If the parameters genuinely need to change (different channel, different number), generate a new unique `external_id`. See the [Idempotency guide](/guides/idempotency) for best practices on `external_id` generation.

***

## Terminal state errors (422)

These three errors indicate that the OTP transaction has reached a point from which it cannot recover. You must create a new OTP transaction — calling verify again on the same `otp_id` will continue to fail.

<h3 id="otp_expired">
  `OTP_EXPIRED` — HTTP 422
</h3>

The OTP's time-to-live (TTL) has elapsed and the code is no longer valid.

**Causes:**

* The user took too long to enter the code
* Your application delayed the verify call beyond the OTP's expiry time (visible in `data.expires_at` on the send response)

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "OTP_EXPIRED",
    "message": "OTP sudah kadaluarsa"
  }
}
```

**How to resolve:** Create a new OTP transaction by calling `POST /v3/request` or `POST /v3/send` again. Display a "resend OTP" option in your UI before the expiry time so users can request a new code proactively.

***

<h3 id="too_many_attempts">
  `TOO_MANY_ATTEMPTS` — HTTP 422
</h3>

The user has submitted 5 incorrect codes for this OTP transaction. Further verification attempts are blocked.

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "TOO_MANY_ATTEMPTS",
    "message": "Terlalu banyak percobaan verifikasi"
  }
}
```

**How to resolve:** Create a new OTP transaction. Consider adding client-side attempt tracking to disable the submit button after several failures before reaching the server-side limit.

***

<h3 id="already_used">
  `ALREADY_USED` — HTTP 422
</h3>

The OTP has already been successfully verified and cannot be used again.

**Causes:**

* Your application called `POST /v3/verify` a second time after receiving `data.verified: true`
* A race condition caused two concurrent verify calls to be sent for the same `otp_id`

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "ALREADY_USED",
    "message": "OTP sudah digunakan"
  }
}
```

**How to resolve:** After a successful verification (`data.verified: true`), store the result and do not call verify again for the same `otp_id`. If your architecture has concurrent processes, add a local lock or idempotency check before calling the verify endpoint.

***

## Rate limit errors

<h3 id="rate_limited">
  `RATE_LIMITED` — HTTP 429
</h3>

Your API key has exceeded the global rate limit of 20 requests per second across all `/v3/*` endpoints.

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Terlalu banyak request, coba lagi sebentar"
  }
}
```

**How to resolve:** Implement exponential backoff — wait a short period before retrying, and increase the wait time on subsequent failures. See the [Rate Limits reference](/reference/rate-limits) for full details and backoff recommendations. If your use case requires a higher limit, contact OTP.ID support.

***

<h3 id="destination_rate_limited">
  `DESTINATION_RATE_LIMITED` — HTTP 429
</h3>

You have sent too many OTPs to the same destination phone number or email address in a short window.

**Limits:**

* Maximum **5 OTPs per 10 minutes** to the same destination
* Maximum **10 OTPs per hour** to the same destination

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "DESTINATION_RATE_LIMITED",
    "message": "Terlalu banyak OTP ke nomor ini, coba lagi nanti"
  }
}
```

**How to resolve:** Wait until the current window expires before sending another OTP to this destination. Track the number of OTPs you send per destination on your side to surface a friendly waiting message to users instead of showing them an error. See the [Rate Limits reference](/reference/rate-limits) for the full per-destination policy.

***

## Infrastructure errors

<h3 id="channel_unavailable">
  `CHANNEL_UNAVAILABLE` — HTTP 503
</h3>

The delivery channel or underlying vendor is temporarily unavailable.

**Causes:**

* The WhatsApp, SMS, email, or missed call provider is experiencing an outage or maintenance window
* A transient network issue between OTP.ID and the delivery vendor

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "CHANNEL_UNAVAILABLE",
    "message": "Channel sementara tidak tersedia"
  }
}
```

**How to resolve:** Retry the request after a short delay using exponential backoff. If the channel remains unavailable for an extended period, consider falling back to an alternative channel (e.g., SMS instead of WhatsApp) for time-sensitive use cases. Monitor the OTP.ID status page for ongoing incidents.

***

<h3 id="internal_error">
  `INTERNAL_ERROR` — HTTP 500
</h3>

An unexpected error occurred on the OTP.ID server.

**Example response:**

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "Terjadi kesalahan internal"
  }
}
```

**How to resolve:** Retry the request with exponential backoff. If the error persists, contact OTP.ID support with the timestamp and any `otp_id` or `external_id` values from the affected requests to help with investigation.

<Tip>
  Log all `INTERNAL_ERROR` responses in your application alongside request identifiers and timestamps. This trace information significantly speeds up diagnosis if you need to open a support ticket.
</Tip>
