K-OTP

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

PackPriceCreditsEffective price per credit
Starter$19.901,500 (1,000 + 500 bonus)≈ $0.0133
Pro$199.017,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

  1. At POST /v1/issue, 1 credit is reserved. If the balance is insufficient, the request is rejected here.
  2. 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.
  3. 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}.

billingStatusMeaning
reservedCredit reserved at issue
debit_pending → debitedDebit in progress → debited
release_pending → releasedRelease 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.codeMeaning
INSUFFICIENT_CREDITNot enough credit.
OVERDRAFT_LIMIT_EXCEEDEDThe 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.

entryTypeMeaningamountDelta
creditTop-uppositive
debitIssue chargenegative
refundRefundpositive
  • balanceAfter is the balance after the entry. It can be negative for apps allowed to overdraft.
  • issueId is present only on issue-linked entries (debit, refund).
  • Pagination: limit (1–100, default 50); pass the response's nextCursor unchanged as the next cursor. No nextCursor means 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.

On this page