Issue OTP and queue outbound delivery
POST /v1/issue
POST https://api.k-otp.dev/v1/issueOverview
Generates a 6-digit OTP, applies quota admission, records the issue, and queues SMS or AlimTalk delivery. The plaintext code is never returned; only issueId, expiry, and attempt metadata are.
Idempotency. An idempotency key is required, either in the Idempotency-Key header or the body idempotencyKey field (both must match when both are sent). A retry with the same key and payload returns the original response without re-sending or re-debiting. When the outcome of the first attempt is unknown (timeout, 503), retry only with the same key.
Replacement. Issuing again for the same phone number and purpose (with a new key) marks the previous issued OTP as replaced.
Billing. Credit is reserved at admission and finalized asynchronously after send processing; the response does not include balance or debit amounts. The charged amount is decided server-side per channel/message type (SMS, LMS, AlimTalk); the deprecated cost field is ignored for billing for every key type. Quota rejection returns 402 with data.code.
Accepts pk_ public keys (browser, Origin must exactly match an allowed origin) or sk_ secret keys.
Authentication
| Accepted keys | Required scope | Also accepted |
|---|---|---|
pk_ / sk_ | otp:issue | otp:send, * |
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key | header | string | Client-generated key that makes POST /issue safe to retry. Required for every issue request unless the body idempotencyKey is provided; if both are sent they must match. After trimming, the key must be 1-128 visible ASCII characters (no spaces). Retrying with the same key and payload replays the original result without a second send or debit; reusing a key with a different payload returns 409 CONFLICT. If the first attempt timed out or returned 503, retry with the same key — never mint a new key, because the provider outcome may be ambiguous and a new key can cause a duplicate SMS. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
phoneNumber | string | yes | Recipient mobile number (at most 32 characters after trimming). Stored only as a hash and never returned by any endpoint. |
purpose | string | yes | Caller-defined purpose label (1-64 characters after trimming). A new issue for the same phone number and purpose replaces the previous active issue. |
channel | "alimtalk" | "sms" | Delivery channel. Defaults to sms. | |
templateId | string | Whitelisted template id from GET /v1/templates. The default template is used when omitted. | |
templateVariables | Record<string, string> | Values for the template's declared variables (see GET /v1/templates/{templateId}). code is always supplied by the server and must not be included. | |
from | string | Optional sender identity override (at most 64 characters after trimming). | |
messageType | "LMS" | "SMS" | SMS message type for the sms channel. | |
cost | number | Deprecated and ignored for billing. The credit amount reserved and debited is always decided server-side from the channel and message type (SMS, LMS, AlimTalk) and cannot be overridden by any key type. Still accepted for compatibility: when sent it must be an integer from 1 to 100000, and it remains part of the idempotency fingerprint. | |
metadata | Record<string, string> | Free-form string key/value tags stored with the issue. Subject to a server-side size budget and never returned by dashboard endpoints. | |
expiresInSec | number | OTP lifetime in seconds: an integer from 30 to 600. Defaults to 180. | |
maxAttempts | number | Maximum verification attempts: an integer from 1 to 10. Defaults to 5. | |
idempotencyKey | string | Idempotency key; alternative to the Idempotency-Key header. One of the two is required, and they must match when both are sent. 1-128 visible ASCII characters without spaces after trimming. Reuse the same key when retrying. |
Response (200)
OTP issued (or an idempotent replay of an earlier issue with the same key).
| Field | Type | Required | Description |
|---|---|---|---|
issueId | string | yes | Identifier of the issued OTP; pass it to POST /v1/verify. |
expiresAt | string | yes | When the OTP stops being verifiable (RFC 3339). |
attemptsRemaining | number | yes | Verification attempts left for this issue. |
queuedAt | string | yes | When delivery was queued (RFC 3339). |
Error responses
Every error uses the { defined, code, status, message, data? } envelope. See Errors.
| Status | code | Description |
|---|---|---|
| 400 | BAD_REQUEST | invalid payload, missing/blank idempotency key, header/body key mismatch, unknown template or template variables, or expiresInSec/maxAttempts/cost out of range (cost is still range-checked though it no longer affects billing). |
| 401 | UNAUTHORIZED | the Authorization: Bearer credential is missing, malformed, inactive, or rejected by introspection. |
| 402 | PAYMENT_REQUIRED | quota admission rejected the issue. data.code is INSUFFICIENT_CREDIT or OVERDRAFT_LIMIT_EXCEEDED; top up the wallet before retrying. |
| 403 | FORBIDDEN | the credential lacks otp:issue (or an accepted alias), or a pk_ public key was sent without an Origin header / with an Origin that is not an exact match for one of the key's allowedOrigins. |
| 409 | CONFLICT | the idempotency key was already used with a different payload, or the replayed issue has since been replaced by a newer issue. |
| 500 | INTERNAL_SERVER_ERROR | unexpected server failure. Undefined errors (defined: false) use the same envelope. |
| 503 | SERVICE_UNAVAILABLE | introspection or a downstream dependency is unavailable, or an earlier attempt with this idempotency key is still being resolved. Retry later with the same key. |
Example request
curl -X POST "https://api.k-otp.dev/v1/issue" \
-H "Authorization: Bearer $KOTP_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"phoneNumber":"01012345678","purpose":"login"}'