K-OTP

OTP 발급

POST /v1/issue

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

개요

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'.

xxIssue response and delivery (GET /status)Verify
00200; sent after 1s, delivered after 2s000000 verifies
01200; sent after 1s, failed after 3s000000 verifies (delivery and verification are independent)
02200; queued/sent until 30s, then delivered (polling)000000 verifies
03200; stays sent with providerOutcomeAmbiguous: true (provider outcome unknown)000000 verifies
04200 with expiresAt = issue + 10s; deliveredEXPIRED after that
05200; deliveredevery code is MISMATCH; the last attempt is MAX_ATTEMPTS
06429 TOO_MANY_REQUESTS (perPhone, Retry-After: 60)-
07402 INSUFFICIENT_CREDIT-
08402 TRIAL_DAILY_LIMIT_EXCEEDED-
09503 SERVICE_UNAVAILABLE (Retry-After: 2)-
10200; with AlimTalk delivery the AlimTalk send fails and the SMS fallback is delivered after 4s000000 verifies
11-99reserved; behave like 00000000 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.

인증

허용 키필요 scope대체 허용 scope
pk_ / sk_otp:issueotp:send, *

파라미터

이름위치타입필수설명
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 message.

요청 본문

필드타입필수설명
phoneNumberstring예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.
purposestring예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).
smsFallbackbooleanAutomatic 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.
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"Ignored: AlimTalk delivery and its SMS fallback do not use it, and direct SMS is no longer offered. Still accepted for compatibility.
costnumberDeprecated 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.
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.

응답 (200)

OTP issued (or an idempotent replay of an earlier issue with the same key).

필드타입필수설명
issueIdstring예Identifier of the issued OTP; pass it to POST /v1/verify.
expiresAtstring예When the OTP stops being verifiable (RFC 3339).
attemptsRemainingnumber예Verification attempts left for this issue.
queuedAtstring예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).

오류 응답

모든 오류는 { defined, code, status, message, data? } 형태입니다. 자세한 내용은 오류를 참고하세요.

상태code설명
400BAD_REQUESTTEST_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.
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), 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.
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.
429TOO_MANY_REQUESTSthe 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.
500INTERNAL_SERVER_ERRORunexpected server failure. Undefined errors (defined: false) use the same envelope.
503SERVICE_UNAVAILABLEintrospection 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.

요청 예시

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"}'

이 페이지의 내용