> ## 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/verify — Verify a User-Submitted OTP Code

> POST /v3/verify checks a user-entered OTP against a transaction. Returns verified:true on success, verified:false on mismatch (HTTP 200, not an error).

Use `POST /v3/verify` to check the code your user entered against an existing OTP transaction. Submit the `otp_id` from the original `POST /v3/request` or `POST /v3/send` response alongside the code the user provided. OTP.ID records the attempt and returns whether the code matched. **A wrong code is not an HTTP error** — read `data.verified` in the response body to determine the outcome.

## Endpoint

```text theme={null}
POST https://api.otp.id/v3/verify
```

## Request Headers

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

## Request Body

<ParamField body="otp_id" type="string" required>
  The transaction ID returned by `POST /v3/request` or `POST /v3/send`. Format: `OTP` + `YYYYMMDD` + 4 letters + 6 digits (e.g. `"OTP20260807ABCD000001"`).
</ParamField>

<ParamField body="otp" type="string" required>
  The code submitted by the user. Pass it exactly as entered — OTP.ID compares it against the transaction's stored code.
</ParamField>

## Response Fields

<ResponseField name="otp_id" type="string">
  The transaction ID, echoed from your request.
</ResponseField>

<ResponseField name="verified" type="boolean">
  * `true` — the code matched; the transaction is now permanently marked as used
  * `false` — the code did not match; the attempt has been recorded against the transaction's attempt counter
</ResponseField>

<ResponseField name="reason" type="string">
  Human-readable reason string.

  * `""` (empty string) — verification was successful
  * `"mismatch"` — the code the user entered did not match
</ResponseField>

## Example

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.otp.id/v3/verify \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"otp_id": "OTP20260807ABCD000001", "otp": "482913"}'
  ```
</CodeGroup>

## Response Scenarios

<Warning>
  **A code mismatch returns HTTP 200, not HTTP 4xx.** Always check `data.verified` in your code — do not use the HTTP status code to detect a wrong code. A `verified: false` response still counts as an attempt.
</Warning>

### Success — correct code

```json theme={null}
{
  "success": true,
  "data": {
    "otp_id": "OTP20260807ABCD000001",
    "verified": true,
    "reason": ""
  },
  "error": null
}
```

### Mismatch — wrong code (HTTP 200)

```json theme={null}
{
  "success": true,
  "data": {
    "otp_id": "OTP20260807ABCD000001",
    "verified": false,
    "reason": "mismatch"
  },
  "error": null
}
```

### Expired — OTP past its TTL (HTTP 422)

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

### Too many attempts (HTTP 422)

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "code": "TOO_MANY_ATTEMPTS",
    "message": "Percobaan verifikasi melebihi batas"
  }
}
```

### Already used (HTTP 422)

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

## Error Codes

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

| HTTP Status | Error Code          | Description                                                                              |
| ----------- | ------------------- | ---------------------------------------------------------------------------------------- |
| 400         | `VALIDATION_ERROR`  | A required field is missing or malformed (e.g. `otp_id` or `otp` not provided).          |
| 401         | `UNAUTHORIZED`      | The `Authorization` header is missing or the API key is invalid.                         |
| 404         | `OTP_NOT_FOUND`     | No transaction with the given `otp_id` exists, or it belongs to a different merchant.    |
| 422         | `OTP_EXPIRED`       | The OTP's TTL has elapsed. Issue a new OTP if the user needs to retry.                   |
| 422         | `TOO_MANY_ATTEMPTS` | The transaction has reached the maximum number of failed attempts (5). It is now locked. |
| 422         | `ALREADY_USED`      | The OTP was already verified successfully and cannot be reused.                          |
| 429         | `RATE_LIMITED`      | You have exceeded the API-level request rate limit.                                      |
| 500         | `INTERNAL_ERROR`    | An unexpected server-side error occurred.                                                |

## Webhooks

A successful verification (where `verified` returns `true`) also triggers the [`otp.verified`](/api-reference/webhook-otp-verified) webhook asynchronously. Your server receives the event after OTP.ID records the verification — implement the webhook to react to verifications server-to-server without polling.

<Note>
  After a transaction reaches `TOO_MANY_ATTEMPTS`, subsequent verify calls for that `otp_id` will continue to return `TOO_MANY_ATTEMPTS` — the lock is permanent. You must start a new OTP transaction.
</Note>
