@k-otp/sdk
| Install | |
|---|---|
npm install @k-otp/sdk |
|
| Latest Version: | 2.1.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 20, 2026 |
| Links: | GitHub · npm |
@k-otp/sdk
The official JavaScript/TypeScript SDK for the K-OTP Korean OTP API: issue and verify one-time passwords (delivered by KakaoTalk AlimTalk, with automatic SMS fallback) from browsers, SSR frameworks, edge runtimes and servers. One package, with a subpath per use:
| Import | Use it for | Key | Reference |
|---|---|---|---|
@k-otp/sdk (= @k-otp/sdk/core) |
Framework-agnostic issue / verify for browsers, SSR and edge |
pk_ (browser) or sk_ |
below |
@k-otp/sdk/headless |
The issue -> verify flow state machine behind the adapters | below | |
@k-otp/sdk/server |
Every public /v1 operation (status, history, ledger, balance, templates) from Node.js, Bun, Deno or edge runtimes; never bundled for browsers |
sk_ only |
server |
@k-otp/sdk/react |
OtpProvider, useOtpIssue, useOtpVerify, useOtpFlow (React 18/19, "use client") |
pk_ |
react |
@k-otp/sdk/vue |
createOtpPlugin, useOtp, useOtpFlow (Vue >= 3.3) |
pk_ |
vue |
@k-otp/sdk/svelte |
createOtpStores, flow store, use:otpForm (Svelte 4/5, ESM-only) |
pk_ |
svelte |
@k-otp/sdk/ui/react, /ui/vue, /ui/svelte |
Headless UI components: <OtpForm /> preset and composable parts (React 18/19, Vue >= 3.3, Svelte 4/5) |
pk_ |
ui |
@k-otp/sdk/ui/theme.css |
Optional default theme for the UI components | theme | |
@k-otp/sdk/ui |
The framework-agnostic UI model (phone, code input, form state machine, messages) | ui | |
@k-otp/sdk/contract |
The oRPC contract and generated OpenAPI types, for custom oRPC clients | below | |
@k-otp/sdk/testing |
createMockTransport(): an in-memory test-mode API for unit tests (no network) |
pk_test_ / sk_test_ |
below |
@k-otp/sdk/k-otp.iife.min.js |
<script> bundle exposing window.KOtp (also k-otp.iife.js) |
pk_ |
below |
- Zero framework code in the core, side-effect free (only the optional theme
CSS is a side effect:
sideEffects: ["**/*.css"]), SSR-safe (nowindowaccess at import) - Each subpath only loads what it needs:
@k-otp/sdk/reactnever pulls Vue, Svelte, the UI components or the server client,@k-otp/sdk/ui/reactnever pulls Vue or Svelte, and the subpaths share one copy of the core per module format (OtpApiErrorfrom@k-otp/sdkand@k-otp/sdk/reactis the same class; across the ESM and CommonJS builds,instanceof OtpApiErrorstill matches) - Typed from the published OpenAPI spec; normalized
OtpApiError - ESM and CommonJS per subpath (Svelte: ESM only) with type declarations,
plus the
<script>bundle
Install
npm install @k-otp/sdk
# or: bun add / pnpm add / yarn add @k-otp/sdk
The only runtime dependencies are the @orpc/* client packages. React, Vue
and Svelte are optional peer dependencies: install the one your app uses
(it already does), and only import its subpath.
| Subpath | Peer dependency |
|---|---|
@k-otp/sdk/react, @k-otp/sdk/ui/react |
react >= 18 |
@k-otp/sdk/vue, @k-otp/sdk/ui/vue |
vue >= 3.3 |
@k-otp/sdk/svelte, @k-otp/sdk/ui/svelte |
svelte >= 4 |
| everything else | none |
Requires a runtime with fetch, AbortController and Web Streams (all modern
browsers, Node.js >= 20.19, Bun, Deno, Cloudflare Workers).
TypeScript resolves the subpaths through the exports map, which needs
"moduleResolution": "bundler", "node16" or "nodenext". The legacy
"node" (node10) setting only sees the root @k-otp/sdk. @k-otp/sdk/svelte
ships plain JavaScript stores (no .svelte files), so it has no svelte
export condition on purpose.
Quick start
import { createIdempotencyKey, createOtpClient, isOtpApiError } from "@k-otp/sdk";
const otp = createOtpClient({
apiKey: "pk_live_...", // public key; its allowedOrigins must include this page's exact origin
});
// Create ONE key per "send code" action and reuse it for retries of that action.
const idempotencyKey = createIdempotencyKey("signup");
const { issueId, expiresAt } = await otp.issue({
phoneNumber: "01012345678",
purpose: "signup",
idempotencyKey,
});
const result = await otp.verify({ issueId, code: "123456" });
if (!result.verified) {
// MISMATCH | MAX_ATTEMPTS | EXPIRED | ALREADY_VERIFIED | REPLACED | NOT_FOUND
console.log(result.reasonCode, result.attemptsRemaining);
}
CDN / static sites
<script
src="https://cdn.jsdelivr.net/npm/@k-otp/sdk@1.0.1/dist/k-otp.iife.min.js"
integrity="sha384-REPLACE_WITH_THE_FILE_HASH"
crossorigin="anonymous"
></script>
<script>
const otp = KOtp.createOtpClient({ apiKey: "pk_live_..." });
const key = KOtp.createIdempotencyKey();
otp.issue({ phoneNumber: "01012345678", purpose: "login", idempotencyKey: key });
</script>
Always pin an exact version and add Subresource Integrity. Get the hash from jsDelivr ("Copy HTML + SRI" on the package page) or compute it:
curl -s https://cdn.jsdelivr.net/npm/@k-otp/sdk@1.0.1/dist/k-otp.iife.min.js \
| openssl dgst -sha384 -binary | openssl base64 -A
unpkg works the same way:
https://unpkg.com/@k-otp/sdk@1.0.1/dist/k-otp.iife.min.js (the package's
unpkg / jsdelivr fields also point there, so
https://cdn.jsdelivr.net/npm/@k-otp/sdk@1.0.1 serves it too). CDN URLs use
the file path dist/...; bundlers and Node.js use the package export
@k-otp/sdk/k-otp.iife.min.js. dist/k-otp.iife.js is the unminified
variant.
API
createOtpClient(options): OtpClient
| Option | Type | Default | |
|---|---|---|---|
apiKey |
string | () => string | Promise<string> |
required | pk_ in browsers. A function is resolved before every request (key rotation, runtime injection). |
baseUrl |
string |
https://api.k-otp.dev/v1 |
Must be HTTPS (plain HTTP only for loopback hosts) and include /v1. Relative URLs resolve against location in browsers and inherit its scheme. |
fetch |
typeof fetch |
globalThis.fetch (looked up lazily) |
Custom fetch (tests, proxies, instrumentation). |
timeoutMs |
number |
10000 |
Per request; 0 disables. |
allowInsecureHttp |
boolean |
false |
Development/test only: accept plain http:// on a non-loopback host. HTTPS and loopback (localhost, *.localhost, 127.0.0.0/8, ::1) need no opt-in; a custom fetch does not lift the rule. |
headers |
Record<string, string> |
Extra headers; authorization cannot be overridden. Custom headers must be allowed by the API's CORS policy in browsers. |
|
hooks |
OtpTelemetryHooks |
See Telemetry. | |
dangerouslyAllowSecretKeyInBrowser |
boolean |
false |
The client throws if an sk_ key is used where window and document exist. |
Creating a client performs no I/O. Configuration mistakes (missing key, sk_
key in a browser, no fetch) throw a TypeError.
client.issue(input, options?) - POST /v1/issue
input: phoneNumber, purpose, idempotencyKey (required), and optional
templateId, templateVariables, from, metadata, expiresInSec,
maxAttempts, smsFallback. The code is always delivered by KakaoTalk
AlimTalk (K-OTP API 2.x), with automatic SMS fallback when AlimTalk cannot be delivered;
leave channel out (the API only accepts "alimtalk"; the deprecated "sms"
is refused with 400 CHANNEL_NOT_SUPPORTED). smsFallback: false sends by
AlimTalk only, at the same price. A template without an approved AlimTalk code
cannot be issued (400 TEMPLATE_NOT_AVAILABLE; check channelSupport of
listTemplates). messageType is deprecated and ignored, and the deprecated
webOtp option is refused (any value but false is 400
WEB_OTP_NOT_SUPPORTED): the SDK still sends what you pass, so these mistakes
surface as the API's 400. cost is still accepted but deprecated: the API decides the
charged credit from the delivery channel and message type and ignores it for
billing.
Resolves { issueId, expiresAt, attemptsRemaining, queuedAt }.
The key is trimmed and validated (1-128 visible ASCII characters) before any
network request; an invalid key rejects with OtpApiError BAD_REQUEST / 400.
It is sent as both the Idempotency-Key header and the body field.
client.verify(input, options?) - POST /v1/verify
input: { issueId, code }. Resolves { issueId, verified, reasonCode?, attemptsRemaining, expiresAt, verifiedAt? }. A wrong code is not an error:
it resolves with verified: false and a reasonCode.
Per-call options
{ signal?: AbortSignal; timeoutMs?: number } on every method.
createIdempotencyKey(prefix?)
Returns a random UUID v4 (crypto.randomUUID, falling back to
crypto.getRandomValues outside secure contexts), optionally prefixed.
Persist it with the pending action and reuse it when retrying; see
errors and retries.
Errors
Every rejection from issue/verify is an OtpApiError:
class OtpApiError extends Error {
code: OtpApiErrorCode; // see below
status: number; // HTTP status, 0 when no response was received
requestId?: string; // X-Request-Id (else request-id / cf-ray), when readable
data?: unknown; // e.g. { code: "INSUFFICIENT_CREDIT" } for 402,
// { limit, policy, retryAfterMs } for 429,
// { code: "TEST_NUMBER_REQUIRED" } for some 400s
// (also CHANNEL_NOT_SUPPORTED, TEMPLATE_NOT_AVAILABLE,
// WEB_OTP_NOT_SUPPORTED: getIssueRequestErrorCode)
retryAfterMs?: number; // data.retryAfterMs, else Retry-After (429, some 503s)
retryable: boolean; // TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE, TIMEOUT, NETWORK_ERROR
}
Codes: BAD_REQUEST, UNAUTHORIZED, PAYMENT_REQUIRED, FORBIDDEN,
NOT_FOUND, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR,
SERVICE_UNAVAILABLE, TIMEOUT, NETWORK_ERROR, ABORTED, UNKNOWN.
error instanceof OtpApiError also matches errors from another copy of the
SDK (the ESM and CommonJS builds loaded side by side, the CDN bundle and an
npm install): every OtpApiError carries a shared Symbol.for brand, which
instanceof and isOtpApiError(error) check.
A 400 from issue can carry data.code (also the first token of message)
for a request the API no longer accepts. getIssueRequestErrorCode(error)
returns it, and ISSUE_REQUEST_ERROR_CODES lists the values:
data.code |
Cause |
|---|---|
CHANNEL_NOT_SUPPORTED |
channel: "sms". Only "alimtalk" (the default) is accepted. |
TEMPLATE_NOT_AVAILABLE |
The template has no approved AlimTalk code (its channelSupport is empty). |
WEB_OTP_NOT_SUPPORTED |
webOtp was set to anything but false. |
Test mode
pk_test_/sk_test_ keys (API 1.9.0+) never send a message and never use
credits. They only accept test phone numbers (Korean 010-0000-00xx, UK
+44 7700 9000xx, US +1 NXX 555-01xx); the last two digits pick a
deterministic scenario, and every test issue verifies with 000000 (except
scenario 05). Results carry mode: "live" | "test" (OtpApiMode).
| Export | |
|---|---|
isTestKey(key) |
true for pk_test_... / sk_test_... |
TEST_PHONE_NUMBERS |
One Korean test number per scenario: success (010-0000-0000), delivery_failed, slow_delivery, ambiguous, expired, mismatch, rate_limited, insufficient_credit, trial_daily_limit, service_unavailable, alimtalk_failover (010-0000-0010) |
TEST_OTP_CODE |
"000000" |
TEST_SCENARIOS, OtpTestScenario |
The scenarios in suffix order (00-10) |
matchTestPhoneNumber(phoneNumber) |
{ region, canonical, suffix, scenario } for a test number in any spelling, else undefined |
getTestNumberErrorCode(error) |
"TEST_NUMBER_REQUIRED" (test key, real number) or "TEST_NUMBER_IN_LIVE_MODE" (live key, test number) from a 400's data.code, else undefined |
@k-otp/sdk/testing adds createMockTransport(), an in-memory simulator of
the same scenarios for unit tests (pass its fetch to any client; advance(ms)
walks the delivery timeline), plus TEST_MODE_TIMINGS and
TEST_MODE_SIMULATED_BALANCE:
import { createOtpClient, TEST_OTP_CODE, TEST_PHONE_NUMBERS } from "@k-otp/sdk";
import { createMockTransport } from "@k-otp/sdk/testing";
const mock = createMockTransport();
const otp = createOtpClient({ apiKey: "pk_test_unit", fetch: mock.fetch });
const { issueId } = await otp.issue({
phoneNumber: TEST_PHONE_NUMBERS.success,
purpose: "signup",
idempotencyKey: "unit-1",
});
await otp.verify({ issueId, code: TEST_OTP_CODE }); // verified: true
See the test mode guide.
Telemetry
createOtpClient({
apiKey,
hooks: {
onRequestStart: (op) => {}, // "issue" | "verify"
onRequestEnd: (op, ok, status, info) => {}, // info: { durationMs, requestId?, error? }
},
});
Hooks run synchronously; exceptions thrown by hooks are swallowed. They are not called for client-side validation failures (no request is made).
Headless flow: @k-otp/sdk/headless
The state machine behind the React, Vue and Svelte adapters, for plain
JavaScript or any other framework. Also available in the CDN bundle as
KOtp.createOtpFlow / KOtp.createOtpOperation.
import { createOtpClient } from "@k-otp/sdk";
import { createOtpFlow } from "@k-otp/sdk/headless";
const flow = createOtpFlow(createOtpClient({ apiKey: "pk_live_..." }), {
// resendCooldownMs: 30_000 (default; 0 disables), idempotencyKeyPrefix, createIdempotencyKey
});
const unsubscribe = flow.subscribe(() => render(flow.getState()));
await flow.send({ phoneNumber, purpose: "signup" }); // { data } | { error } | { skipped }
await flow.verify(code);
flow.getState(); // issueId, verified, reasonCode, error, canSend, canVerify,
// cooldownRemainingMs (ticks ~1/s while subscribed),
// verifyCooldownRemainingMs, idempotencyKey, ...
- One idempotency key per send attempt; the same key is reused when
sendis called again with the same input after an ambiguous failure (TIMEOUT,NETWORK_ERROR, 5xx, 429,ABORTED) and dropped after a success, a definitive error or changed input. - Cooldown:
resendCooldownMs(defaultDEFAULT_RESEND_COOLDOWN_MS, 30 s) after each successful send, and the server'sretryAfterMson 429/503. A 429/503 onverifystarts a separate verify cooldown (verifyCooldownRemainingMs;canVerifyis false andverifyis skipped with"cooldown"until it ends). - Actions never reject for API errors; blocked actions resolve with
{ skipped: "cooldown" | "busy" | "no-issue" | "no-previous-send" | "terminal" }. verified: falsewithEXPIRED,MAX_ATTEMPTS,REPLACED,NOT_FOUNDorALREADY_VERIFIEDis terminal for that code (canVerifybecomes false).reset()clears the flow but keeps the cooldowns;abort()cancels in-flight requests (e.g. when your view is torn down).
createOtpOperation(execute) is the single-operation building block
(getState, subscribe, run, reset, abort) with stale-response
protection. See the issue -> verify UX guide.
Advanced entry points
@k-otp/sdk/contract- the oRPC contract (otpPublicContract,otpServerContract),OPENAPI_VERSIONand every generated OpenAPI type, for building your own oRPCOpenAPILinkclient.
Anything not listed in the exports map (for example the shared transport
under src/core/internal.ts) is private and cannot be imported.
UI components
import { OtpForm } from "@k-otp/sdk/ui/react"; // or /ui/vue, /ui/svelte
import "@k-otp/sdk/ui/theme.css"; // optional
<OtpForm options={{ apiKey: "pk_live_..." }} purpose="signup" onVerified={(r) => console.log(r.issueId)} />;
A phone field (Korean mobiles canonicalized before sending), send/resend with
the cooldown, a segmented code input (paste, one-time-code autofill,
keyboard navigation), verify, an expiry countdown and a live status
message, in Korean or English. Headless parts (OtpForm.Root,
OtpForm.PhoneField, ...) expose data-* state and render props / slots.
Guide: UI components; API:
reference. The Svelte components are shipped as
.svelte sources (the svelte export condition) that your Svelte 4 or 5
compiles.
Server, React, Vue and Svelte
// Backend (Node.js, Bun, Deno, Workers) with an sk_ key:
import { createOtpServerClient } from "@k-otp/sdk/server";
const otp = createOtpServerClient({ apiKey: process.env.K_OTP_SECRET_KEY! });
await otp.getBalance();
// React (Vue and Svelte mirror this API):
import { OtpProvider, useOtpFlow } from "@k-otp/sdk/react";
@k-otp/sdk/server resolves to a stub under the browser export condition
(same exports; every function throws when called), so the sk_ client
cannot slip into a client bundle; server runtimes that also set browser (Cloudflare Workers, Vercel
Edge) are matched first and get the real client. Details, every method and
the framework APIs: server,
react, vue,
svelte; guides:
React, Vue,
Svelte.
Runtime notes
- SSR: importing and creating a client is safe on the server; nothing runs
until you call a method. Create browser clients with a
pk_key only. - Origins:
pk_keys require anOriginheader that exactly matches one of the key'sallowedOrigins(scheme + host + port, no wildcards). Calls from servers or tools without a matchingOrigingetFORBIDDEN. - Request ids in browsers: the API exposes
X-Request-IdandRetry-Afterto allowed origins, sorequestId/retryAfterMswork in browsers. They are undefined when no response was readable. NETWORK_ERRORin the browser but works with curl: the page origin is not in thepk_key'sallowedOrigins. For unlisted origins the API sends no CORS headers, so the browser blocks the response and the SDK can only reportNETWORK_ERROR(status 0, norequestId; the message carries a hint in browsers). Fix the allowlist entry: exact scheme + host + port.- CommonJS: the CJS build
require()s the ESM-only@orpc/*packages, which needs Node.js >= 20.19 (or 22.12+). ESM consumers are unaffected. - Timeouts: the default is 10 seconds per request (
DEFAULT_TIMEOUT_MS).
Versioning
@k-otp/sdk follows SemVer from 1.0.0. Every subpath listed above is part of
the public API and shares the package version. See the
changelog.
License
MIT
Kiota server client
@k-otp/sdk/kiota provides KotpClient using official Kiota request builders
and runtimes. It supports all nine server operations, requires an sk_ key,
and returns native JSON values. See the TypeScript guide
for installation, errors and retry options. The browser export rejects client
construction so server credentials remain on the server.
Related Packages
Verifyme SDK for JavaScript and TypeScript: send and verify one-time codes over...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 2.1.0 | >=3.3 | MIT | |
| 2.0.0 | >=3.3 | MIT | |
| 1.4.0 | >=3.3 | MIT | |
| 1.3.0 | >=3.3 | MIT | |
| 1.2.0 | >=3.3 | MIT | |
| 1.1.0 | >=3.3 | MIT | |
| 1.0.1 | >=3.3 | MIT | |
| 1.0.0 | >=3.3 | MIT |