> ## 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 Billing: Prepaid IDR Balance and Deduction Rules

> OTP.ID uses a prepaid IDR balance. Learn when charges apply, how the price and last_balance fields work, and how to handle insufficient balance errors.

OTP.ID operates on a prepaid billing model: you maintain a balance denominated in Indonesian Rupiah (IDR), and each successful OTP delivery draws from that balance at a fixed per-channel rate. There are no monthly fees or minimum commitments — you pay only for OTPs that are actually delivered. This page explains exactly when charges apply, what the billing-related response fields mean, and how to handle an insufficient balance.

***

## How the Prepaid Model Works

Before sending OTPs, you top up your account balance through the [OTP.ID dashboard](https://otp.id). Your balance is stored in IDR as an integer (representing the exact Rupiah amount). Each delivery attempt has an associated `price` — the cost in IDR for that channel and transaction. When OTP.ID successfully delivers an OTP, the price is atomically deducted from your balance at the moment of delivery.

**To top up your balance**, log in to the OTP.ID dashboard and follow the top-up flow. There is no API endpoint for initiating a top-up.

***

## When Your Balance Is (and Is Not) Deducted

Not every API call results in a charge. OTP.ID only deducts your balance when value is delivered.

### Balance IS deducted when:

* The channel vendor **successfully delivers** the OTP — the transaction status is `sent` or `success`.
* The deduction happens **atomically at the moment of delivery**, not at request time.

### Balance is NOT deducted when:

* **Vendor delivery fails** — the transaction is created and you receive a `200 OK` response, but `status` is `failed`. No charge is applied.
* **Idempotent replay** — if you repeat a request with the same `external_id`, OTP.ID recognises it as a duplicate and does not create a new transaction or deduct a new charge.

<Note>
  Even when delivery fails, OTP.ID returns HTTP `200 OK` with `status: "failed"` in the response body. Always check the `status` field — a `200` response does not guarantee a charge was made or that the OTP was delivered.
</Note>

***

## Billing Fields in API Responses

Every transaction response includes two billing-related fields.

| Field          | Type          | Description                                                                                                                                                                     |
| -------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `price`        | integer (IDR) | The cost of this transaction. Present in every response, even on failed delivery. Represents what would be (or was) charged for a successful delivery.                          |
| `last_balance` | integer (IDR) | Your account balance **after** this transaction. If delivery failed or the request was a replay, this value equals your balance **before** the request — no deduction occurred. |

### Successful Delivery

When the OTP is delivered successfully, `last_balance` is lower than your pre-request balance by exactly `price`.

```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
}
```

In this example, the transaction cost 350 IDR. Your balance before the request was 100,000 IDR; your balance after is 99,650 IDR.

### Failed Delivery

When the vendor cannot deliver the OTP, `last_balance` is **unchanged** — your balance before and after the request is the same.

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

Here, `price` is still 350 IDR (showing what would have been charged), but `last_balance` remains 100,000 IDR because no deduction occurred.

<Tip>
  Use `last_balance` to keep a running display of your remaining credits in an internal dashboard or alerting system. If `last_balance` drops below a threshold you define, trigger an automated notification to top up before your balance is exhausted.
</Tip>

***

## Insufficient Balance

If your balance is too low to cover the cost of the requested transaction, OTP.ID returns an HTTP `402` error before attempting delivery.

<Warning>
  **`402 INSUFFICIENT_BALANCE`** — Your account balance is too low to send this OTP. OTP.ID will not attempt delivery, and no transaction is created.

  **To resolve this:**

  1. Log in to the [OTP.ID dashboard](https://otp.id).
  2. Navigate to the top-up section and add funds to your account.
  3. Retry the original API request once your balance has been updated.

  Do **not** retry the request automatically in a loop — the balance will not change until you manually top up.
</Warning>

***

## Billing Summary

| Situation                        | Charge applied?          | `status`                    | `last_balance`             |
| -------------------------------- | ------------------------ | --------------------------- | -------------------------- |
| OTP delivered successfully       | ✅ Yes — `price` deducted | `sent` / `success`          | Balance minus `price`      |
| Vendor delivery failed           | ❌ No                     | `failed`                    | Unchanged                  |
| Duplicate `external_id` (replay) | ❌ No                     | Original transaction status | Unchanged                  |
| Insufficient balance             | ❌ No (request rejected)  | —                           | Not returned (`402` error) |
