> ## 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/topups — Create a Credit Top-Up Payment Link

> Create a credit top-up invoice and receive a signed OTP.ID payment URL that can be opened without dashboard login.

Use `POST /v3/topups` to create a credit top-up invoice from your server. The response contains a signed `payment_url` hosted on OTP.ID, such as:

```text theme={null}
https://app.otp.id/topup/TC20990809Q7M4X2A8BC5D6EFG?hash=...
```

You can open this URL without logging in to the dashboard. The hash binds the link to the invoice and merchant, so another invoice ID cannot reuse it.

<Warning>
  Create top-ups from your server only. Your API key must never be exposed in browser JavaScript, mobile apps, or public repositories.
</Warning>

## Endpoint

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

## Request headers

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

## Request body

<ParamField body="amount" type="integer" required>
  The credit package amount in rupiah. Accepted values are `10000`, `100000`, `500000`, `1000000`, and `2000000`.
</ParamField>

<ParamField body="payment_method_id" type="integer">
  Optional payment method ID. If omitted, OTP.ID chooses the first active payment method that supports the selected `amount`.
</ParamField>

## Response fields

<ResponseField name="topup_id" type="string">
  The public invoice ID for this top-up. This is safe to show to users.
</ResponseField>

<ResponseField name="payment_url" type="string">
  A signed OTP.ID payment page URL. Open this URL to show QRIS or Virtual Account details on OTP.ID without requiring dashboard login.
</ResponseField>

<ResponseField name="amount" type="integer">
  The selected credit package amount.
</ResponseField>

<ResponseField name="payment_total" type="integer">
  The final amount the user must pay, including admin fee. Display this number directly; do not separately render fee percentages.
</ResponseField>

<ResponseField name="payment_method_id" type="integer">
  The payment method used for the invoice.
</ResponseField>

<ResponseField name="payment_method" type="string">
  Human-readable payment method name, such as `QRIS` or `BRI Virtual Account`.
</ResponseField>

<ResponseField name="payment_type" type="string">
  Payment instruction type returned by the payment gateway. Typical values are `qr` and `va`.
</ResponseField>

<ResponseField name="payment_expired_at" type="string">
  The payment expiry time in `YYYY-MM-DD HH:MM:SS` format (WIB, UTC+7). Top-up payment links expire after **3 hours**.
</ResponseField>

<ResponseField name="status" type="string">
  The top-up status. New invoices start as `Pending`.
</ResponseField>

## Example

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

  ```js JavaScript theme={null}
  const response = await fetch('https://api.otp.id/v3/topups', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OTPID_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ amount: 100000 })
  });

  const result = await response.json();
  console.log(result.data.payment_url);
  ```
</CodeGroup>

**Success response:**

```json theme={null}
{
  "success": true,
  "data": {
    "topup_id": "TC20990809Q7M4X2A8BC5D6EFG",
    "payment_url": "https://app.otp.id/topup/TC20990809Q7M4X2A8BC5D6EFG?hash=6bbf...",
    "payment_hash": "6bbf...",
    "amount": 100000,
    "payment_total": 103500,
    "payment_method_id": 7,
    "payment_method": "QRIS",
    "payment_type": "qr",
    "payment_expired_at": "2026-08-10 15:00:00",
    "status": "Pending"
  },
  "error": null
}
```

## Payment page behavior

The `payment_url` opens an OTP.ID-hosted page with the same payment details shown in the dashboard top-up flow.

* **QRIS:** the QR image is displayed directly on the page, with a **Download QR** button.
* **Virtual Account:** the VA number is displayed directly on the page, with a **Copy** button.
* The final amount to pay is shown in rupiah and already includes the admin fee.
* After the 3-hour expiry window, QR/VA instructions are no longer displayed.

## Errors

| HTTP Status | Error Code         | Description                                                                       |
| ----------- | ------------------ | --------------------------------------------------------------------------------- |
| 400         | `VALIDATION_ERROR` | `amount` is missing, unsupported, or no active payment method supports it.        |
| 401         | `UNAUTHORIZED`     | The `Authorization` header is missing or the API key is invalid.                  |
| 429         | `RATE_LIMITED`     | You exceeded the global API-key rate limit.                                       |
| 500         | `INTERNAL_ERROR`   | OTP.ID could not prepare the payment or an unexpected server-side error occurred. |
