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 delivery by KakaoTalk AlimTalk with an automatic SMS fallback when AlimTalk delivery fails. The plaintext code is never returned; only issueId, expiry, and attempt metadata are.
SMS fallback. The SMS fallback is on by default (omitted or smsFallback: true): when AlimTalk delivery fails, the same OTP text is sent by SMS at no extra charge. Set smsFallback: false for AlimTalk only: a failed AlimTalk delivery is then not re-sent by SMS, and the price stays the AlimTalk price. An explicit false is part of the idempotency fingerprint (omitted and true are the same request).
Delivery channel. An issue is always delivered by KakaoTalk AlimTalk; channel accepts only alimtalk (also the default). Direct SMS was removed: channel: "sms" is 400 CHANNEL_NOT_SUPPORTED. A template without an approved AlimTalk code cannot be issued (400 TEMPLATE_NOT_AVAILABLE; channelSupport of GET /v1/templates is empty for it). SMS remains only as the automatic fallback of a failed AlimTalk delivery, and the channel of GET /v1/issues items (and delivery records) still reports sms as the delivered channel after a fallback; GET /v1/status has no channel field. Service name. If your app has an approved service name, the message reads [K-OTP] <service name> 인증번호는 ... instead of [K-OTP] 인증번호는 .... You keep passing the same templateId; the named text is used once its AlimTalk template is approved and otherwise the plain text is sent. The name is a property of your app (set in the console, never a request field) and a change applies within about a minute.
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. Phone numbers are compared in canonical form: a Korean mobile number is the same number in any spelling (010-1234-5678, 01012345678, +82 10 1234 5678); other numbers are compared by their digits only (+1 415-555-2671 = 14155552671).
Removed options. webOtp (WebOTP needed a direct SMS) is no longer supported: a request that sets it to anything but false is 400 WEB_OTP_NOT_SUPPORTED; the response no longer carries webOtp.
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 delivery channel (the SMS fallback is included in the AlimTalk price); 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.
Test mode. A pk_test_/sk_test_ key never sends a message and never uses credits: the issue is simulated and kept for 7 days, separately from live data (idempotency keys, issue ids and rate limits of test and live keys never mix; a live key cannot read a test issue and vice versa). Test keys only accept test phone numbers (anything else is 400 TEST_NUMBER_REQUIRED), and live keys reject them (400 TEST_NUMBER_IN_LIVE_MODE); both codes are the first token of message and are also in data.code. Test numbers: Korean 010-0000-00xx (+82 10-0000-00xx), UK +44 7700 9000xx and US +1 NXX 555-01xx; the last two digits xx pick the scenario. Every test issue that is created verifies with the fixed code 000000 (except 05), and responses carry mode: 'test'.
xx | Issue response and delivery (GET /status) | Verify |
|---|---|---|
00 | 200; sent after 1s, delivered after 2s | 000000 verifies |
01 | 200; sent after 1s, failed after 3s | 000000 verifies (delivery and verification are independent) |
02 | 200; queued/sent until 30s, then delivered (polling) | 000000 verifies |
03 | 200; stays sent with providerOutcomeAmbiguous: true (provider outcome unknown) | 000000 verifies |
04 | 200 with expiresAt = issue + 10s; delivered | EXPIRED after that |
05 | 200; delivered | every code is MISMATCH; the last attempt is MAX_ATTEMPTS |
06 | 429 TOO_MANY_REQUESTS (perPhone, Retry-After: 60) | - |
07 | 402 INSUFFICIENT_CREDIT | - |
08 | 402 TRIAL_DAILY_LIMIT_EXCEEDED | - |
09 | 503 SERVICE_UNAVAILABLE (Retry-After: 2) | - |
10 | 200; with AlimTalk delivery the AlimTalk send fails and the SMS fallback is delivered after 4s | 000000 verifies |
11-99 | reserved; behave like 00 | 000000 verifies |
Test keys use a fixed platform rate limit instead of the key policy: perKey 120 requests per minute and perKeyDaily 5,000 requests per day, counted per key and separately for issue, verify and reads (status, issues, balance, credit ledger). New test issues also share daily budgets per organization (perOrgDaily, 10,000 by default) and platform-wide (testModeDaily), reset at 00:00 UTC; an exhausted budget returns 429 with Retry-After.
Rate limits. The key's perKey, perIp and perPhone rules are enforced after validation. For pk_ keys the platform default covers perKey/perIp the key does not set and its perPhone default always applies; perKey/perIp never exceed the platform ceiling. An exceeded rule returns 429 with Retry-After and data.retryAfterMs.
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 message. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
phoneNumber | string | yes | Recipient mobile number (at most 32 characters after trimming, with at least one digit). Handled in canonical form: a Korean mobile number in any spelling (010-1234-5678, +82 10 1234 5678) is 01012345678; other numbers are their digits only (+1 415-555-2671 is 14155552671). 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" | Delivery channel: only alimtalk (KakaoTalk AlimTalk), which is also the default when omitted. If AlimTalk delivery fails the same OTP text is sent by SMS automatically (included in the AlimTalk price; turn it off with smsFallback: false). Direct SMS (sms) is no longer offered: an explicit sms is refused with 400 CHANNEL_NOT_SUPPORTED (in data.code). A template without an approved AlimTalk code cannot be issued (400 TEMPLATE_NOT_AVAILABLE). GET /v1/issues items and delivery records still report sms as the delivered channel after a fallback (GET /v1/status has no channel field). | |
smsFallback | boolean | Automatic SMS fallback of AlimTalk delivery. Omitted or true (the default): when AlimTalk delivery fails the same OTP text is sent by SMS. false: AlimTalk only; a failed AlimTalk delivery is not re-sent by SMS. The price is the AlimTalk price either way. Has no effect on the idempotency fingerprint unless false: an explicit false is part of it (reusing a key with a different value is a 409); omitted and true are the same request. | |
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" | Ignored: AlimTalk delivery and its SMS fallback do not use it, and direct SMS is no longer offered. Still accepted for compatibility. | |
cost | number | Deprecated and ignored for billing. The credit amount reserved and debited is always decided server-side from the delivery channel 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). |
mode | "live" | "test" | Mode of the API key that made the request: live, or test for a pk_test_/sk_test_ key (simulated, no message sent, no credit used). |
Error responses
Every error uses the { defined, code, status, message, data? } envelope. See Errors.
| Status | code | Description |
|---|---|---|
| 400 | BAD_REQUEST | TEST_NUMBER_REQUIRED (a test key with a real number) or TEST_NUMBER_IN_LIVE_MODE (a live key with a test number), as the first token of message and in data.code; invalid payload, missing/blank idempotency key, header/body key mismatch, a phoneNumber without digits, unknown template or template variables, expiresInSec/maxAttempts/cost out of range (cost is still range-checked though it no longer affects billing), CHANNEL_NOT_SUPPORTED (channel: "sms"), TEMPLATE_NOT_AVAILABLE (a template without an approved AlimTalk code) or WEB_OTP_NOT_SUPPORTED (webOtp), each as the first token of message and in data.code. |
| 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), or TRIAL_DAILY_LIMIT_EXCEEDED: a wallet that has never bought credits reached its daily free-trial send limit, which resets at data.resetAt (next 00:00 Asia/Seoul) or as soon as credits are purchased. |
| 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. |
| 429 | TOO_MANY_REQUESTS | the API key exceeded a rate limit. data.limit names the rule (perKey, perIp, perPhone, or for test keys perKeyDaily, perOrgDaily or testModeDaily) and data.policy whether it came from the key's own policy (key) or a platform limit (platform: the default for pk_ keys or the platform ceiling). Wait data.retryAfterMs milliseconds (the Retry-After header carries the same wait in seconds) before retrying; reuse the same idempotency key. Rejected requests do not consume the limit. |
| 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, the rate limiter is unavailable for a pk_ key (fail-closed, Retry-After set), or an earlier attempt with this idempotency key is still being resolved. Retry later with the same key. Test scenario 09 returns the same 503 with Retry-After. |
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"}'