K-OTP

Issue OTP and queue outbound delivery

POST /v1/issue

POST https://api.k-otp.dev/v1/issue

Overview

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 keysRequired scopeAlso accepted
pk_ / sk_otp:issueotp:send, *

Parameters

NameInTypeRequiredDescription
Idempotency-KeyheaderstringClient-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

FieldTypeRequiredDescription
phoneNumberstringyesRecipient mobile number (at most 32 characters after trimming). Stored only as a hash and never returned by any endpoint.
purposestringyesCaller-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.
templateIdstringWhitelisted template id from GET /v1/templates. The default template is used when omitted.
templateVariablesRecord<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.
fromstringOptional sender identity override (at most 64 characters after trimming).
messageType"LMS" | "SMS"SMS message type for the sms channel.
costnumberDeprecated 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.
metadataRecord<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.
expiresInSecnumberOTP lifetime in seconds: an integer from 30 to 600. Defaults to 180.
maxAttemptsnumberMaximum verification attempts: an integer from 1 to 10. Defaults to 5.
idempotencyKeystringIdempotency 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).

FieldTypeRequiredDescription
issueIdstringyesIdentifier of the issued OTP; pass it to POST /v1/verify.
expiresAtstringyesWhen the OTP stops being verifiable (RFC 3339).
attemptsRemainingnumberyesVerification attempts left for this issue.
queuedAtstringyesWhen delivery was queued (RFC 3339).

Error responses

Every error uses the { defined, code, status, message, data? } envelope. See Errors.

StatuscodeDescription
400BAD_REQUESTinvalid 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).
401UNAUTHORIZEDthe Authorization: Bearer credential is missing, malformed, inactive, or rejected by introspection.
402PAYMENT_REQUIREDquota admission rejected the issue. data.code is INSUFFICIENT_CREDIT or OVERDRAFT_LIMIT_EXCEEDED; top up the wallet before retrying.
403FORBIDDENthe 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.
409CONFLICTthe idempotency key was already used with a different payload, or the replayed issue has since been replaced by a newer issue.
500INTERNAL_SERVER_ERRORunexpected server failure. Undefined errors (defined: false) use the same envelope.
503SERVICE_UNAVAILABLEintrospection 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"}'

On this page