Credits & billing
Prepaid credits, 402 when credit runs out, balance and ledger endpoints
K-OTP uses prepaid credits. You top up the app's wallet in the console, and each send spends one credit. Credits are a quantity, not money; currency in API responses is always CREDIT.
- Wallets are per app. All keys of an app share one wallet.
- Top-ups happen in the console. The public API cannot add credits.
One credit per send
Every OTP send costs exactly 1 credit, whichever channel delivers it: SMS, AlimTalk, or AlimTalk with automatic SMS failover. A failover never costs a second credit. See Channels and failover.
Credit packs
| Pack | Price | Credits | Effective price per credit |
|---|---|---|---|
| Starter | $19.90 | 1,500 (1,000 + 500 bonus) | ≈ $0.0133 |
| Pro | $199.0 | 17,000 (10,000 + 7,000 bonus) | ≈ $0.0117 |
The list price is $0.0199 per credit; bonus credits lower the effective price. There is no subscription. See k-otp.dev/pricing for current packs.
How debits work
- At
POST /v1/issue, 1 credit is reserved. If the balance is insufficient, the request is rejected here. - After send processing, billing settles asynchronously:
- on a successful send the reservation is debited;
- on a failed send the reservation is released and nothing is debited.
- The issue response contains no balance or debit amount. Use the endpoints below when you need them.
Per-issue billing state (billingStatus) is available from GET /v1/issues/{issueId}.
billingStatus | Meaning |
|---|---|
reserved | Credit reserved at issue |
debit_pending → debited | Debit in progress → debited |
release_pending → released | Release in progress → released (no debit) |
Out of credit: 402 PAYMENT_REQUIRED
If the balance is insufficient, the issue is rejected with 402 and nothing is sent. data.code tells you why.
{
"defined": true,
"code": "PAYMENT_REQUIRED",
"status": 402,
"message": "PAYMENT_REQUIRED",
"data": { "code": "INSUFFICIENT_CREDIT" }
}data.code | Meaning |
|---|---|
INSUFFICIENT_CREDIT | Not enough credit. |
OVERDRAFT_LIMIT_EXCEEDED | The app's allowed overdraft limit was exceeded. |
Top up, then retry. Show end users a generic "please try again later" message and alert your operators about the low balance.
Balance
GET /v1/balance (sk_ key, otp:balance)
curl "https://api.k-otp.dev/v1/balance" \
-H "Authorization: Bearer $KOTP_SECRET_KEY"{
"appId": "app_...",
"balance": 1200,
"currency": "CREDIT",
"updatedAt": "2026-09-28T03:00:05.000Z"
}Ledger
GET /v1/credit-ledger (sk_ key, otp:ledger:read) returns wallet changes, newest first.
entryType | Meaning | amountDelta |
|---|---|---|
credit | Top-up | positive |
debit | Issue charge | negative |
refund | Refund | positive |
balanceAfteris the balance after the entry. It can be negative for apps allowed to overdraft.issueIdis present only on issue-linked entries (debit,refund).- Pagination:
limit(1–100, default 50); pass the response'snextCursorunchanged as the nextcursor. NonextCursormeans the last page. Keep the same filters while paging. - Filters:
entryType,createdFrom(inclusive),createdTo(exclusive), both RFC 3339.
const API = "https://api.k-otp.dev/v1";
export async function* listLedger(createdFrom: string) {
let cursor: string | undefined;
do {
const params = new URLSearchParams({ limit: "100", createdFrom });
if (cursor) params.set("cursor", cursor);
const res = await fetch(`${API}/credit-ledger?${params}`, {
headers: { Authorization: `Bearer ${process.env.KOTP_SECRET_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.message}`);
yield* body.items;
cursor = body.nextCursor;
} while (cursor);
}Refunds
Unused credits can be withdrawn within 7 days of purchase. For partly used packs, used credits are charged at the list price. See the refund policy.