SDKs
Official SDKs for K-OTP. The JavaScript and TypeScript SDK is one package with imports for browsers, servers, React, Vue, Svelte, and UI components (`npm i @k-otp/sdk`); preview server SDKs cover C#, Java, Kotlin, PHP, Go, Python, Ruby, and Dart.
The official SDKs are open source (github.com/k-otp/sdk). You can still call the REST API (https://api.k-otp.dev/v1) directly from any language, as in the curl and fetch examples throughout these docs.
Languages
| Language | Package | Status | Runs in |
|---|---|---|---|
| JavaScript, TypeScript | @k-otp/sdk on npm | Stable, 2.x | Browsers (pk_) and servers (sk_) |
| C#, Java, Kotlin/JVM, PHP, Go, Python, Ruby, Dart | See Server SDKs by language | Preview, 0.2.0 | Servers only (sk_) |
Browser, pk_ key, framework, and UI integrations are only in @k-otp/sdk. The rest of this page covers @k-otp/sdk.
Install
npm i @k-otp/sdk
# or: pnpm add @k-otp/sdk, bun add @k-otp/sdk, yarn add @k-otp/sdkReact (>= 18), Vue (>= 3.3), and Svelte (>= 4) are optional peer dependencies: your app already has the one it uses, so install nothing extra and import only that framework's subpath. The optional UI theme is a stylesheet import: import "@k-otp/sdk/ui/theme.css". The package needs a runtime with fetch (all modern browsers, Node.js >= 20.19, Bun, Deno, Cloudflare Workers).
One package, many imports
Everything ships as one npm package, @k-otp/sdk, with a subpath per use. React, Vue, and Svelte are optional peer dependencies: install the one your app already uses and import only its subpath.
| Import | Use it for | Key |
|---|---|---|
@k-otp/sdk (same as @k-otp/sdk/core) | issue and verify from browsers, SSR, and edge runtimes; also a <script> bundle (window.KOtp) | pk_ |
@k-otp/sdk/headless | The framework-agnostic issue → verify flow the framework bindings are built on | pk_ |
@k-otp/sdk/server | The full public API: issue, verify, status, history, ledger, balance, templates (Node.js >= 20.19, Bun, Deno, Workers) | sk_ |
@k-otp/sdk/react | OtpProvider, useOtpIssue, useOtpVerify, useOtpFlow (React 18 and 19) | pk_ |
@k-otp/sdk/vue | createOtpPlugin, useOtp, useOtpFlow (Vue 3.3+) | pk_ |
@k-otp/sdk/svelte | createOtpStores, a flow store, the use:otpForm action (Svelte 4 and 5) | pk_ |
@k-otp/sdk/ui, /ui/react, /ui/vue, /ui/svelte | Ready-made verification forms: see UI components | pk_ |
@k-otp/sdk/ui/theme.css | The optional default theme for the UI components | none |
@k-otp/sdk/kiota | A JSON-in, JSON-out server client shaped like the other language SDKs (Node.js >= 20.19, 1.3.0+) | sk_ |
@k-otp/sdk/contract | The oRPC contract and generated OpenAPI types, for building your own client | any |
The framework imports are thin layers over the core with identical behavior: loading and error state, a resend cooldown, and one idempotency key per send attempt. Each import only loads what it needs; for example, @k-otp/sdk/react never loads Vue, Svelte, the UI components, or the server client. All imports share one package version.
TypeScript resolves the subpaths through the package exports, which needs "moduleResolution" set to "bundler", "node16", or "nodenext".
Quick start
import { createIdempotencyKey, createOtpClient } from "@k-otp/sdk";
// baseUrl defaults to https://api.k-otp.dev/v1
const otp = createOtpClient({ apiKey: "pk_..." });
// Create one key per "send code" action, persist it, and reuse it when you retry that action.
// The SDK requires it; a blank key fails with BAD_REQUEST before any request is made.
const idempotencyKey = createIdempotencyKey("login");
const { issueId } = await otp.issue({
phoneNumber: "01012345678",
purpose: "login",
idempotencyKey,
});
// A wrong code is not an error: it resolves with verified: false and a reasonCode.
const result = await otp.verify({ issueId, code: "123456" });import { createOtpServerClient } from "@k-otp/sdk/server";
// sk_ keys only. The client refuses to run in a browser.
const otp = createOtpServerClient({ apiKey: process.env.KOTP_SECRET_KEY! });
const status = await otp.getStatus({ issueId });
const balance = await otp.getBalance();import { OtpProvider, useOtpFlow } from "@k-otp/sdk/react";
// <OtpProvider options={{ apiKey: "pk_..." }}> ... </OtpProvider>
function VerifyPhone() {
const otp = useOtpFlow({ resendCooldownMs: 30_000 });
// otp.send({ phoneNumber, purpose: "signup" }), otp.verify(code),
// otp.canSend, otp.cooldownRemainingMs, otp.verified, otp.reasonCode, otp.error
}The Vue and Svelte imports mirror this API. For a complete form instead of hooks, see UI components.
Server code never reaches the browser
@k-otp/sdk/server needs an sk_ secret key, so it must not end up in a browser bundle. Bundlers that build for browsers (Vite, webpack, esbuild, Rollup, Metro) resolve it through the browser export condition to a stub with the same exports: the constants are the real values, and every function, including the OtpApiError constructor, throws an error that says @k-otp/sdk/server was resolved for a browser. The bundle still builds, the server client is not in it, and the first call fails loudly. Server runtimes get the real client: Node.js, Bun, Deno, Cloudflare Workers (workerd), and Vercel Edge (edge-light).
If that error appears in code that should run on a server, the build used browser conditions, for example Vite SSR with ssr.target: "webworker" or a jsdom test environment. Resolve with a server condition (workerd, worker, or node) or run the test in a node environment.
Client options
| Option | Default | Description |
|---|---|---|
apiKey | required | pk_ key in browsers, sk_ key on servers. May be a (possibly async) function resolved before every request. |
baseUrl | https://api.k-otp.dev/v1 | Must include /v1. Without it, requests go to /issue instead of /v1/issue and fail. A trailing slash is fine. |
timeoutMs | 10000 | Per request; 0 disables the timeout. Override per call with { timeoutMs }. |
fetch | globalThis.fetch | Custom fetch (tests, proxies). |
headers | Extra request headers. authorization and idempotency-key cannot be overridden. | |
hooks | onRequestStart and onRequestEnd telemetry callbacks. |
pk_ keys only work from origins listed exactly (scheme + host + port) in the key's allowed origins. See Authentication.
Errors
Every rejected SDK call is an OtpApiError. Configuration mistakes (a missing key, an sk_ key in a browser, a non-sk_ key in @k-otp/sdk/server) throw a TypeError instead.
| Field | Description |
|---|---|
code | BAD_REQUEST, UNAUTHORIZED, PAYMENT_REQUIRED, FORBIDDEN, NOT_FOUND, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE, TIMEOUT, NETWORK_ERROR, ABORTED, or UNKNOWN. The HTTP codes follow Errors; the last four are client-side outcomes. |
status | HTTP status, or 0 when no response was received (timeout, network failure, abort). |
message | The server's message, or a descriptive client message. |
requestId | The X-Request-Id response header when readable. Include it when you contact support. |
data | The error payload, for example { code: "INSUFFICIENT_CREDIT" } on 402, or { limit, policy, retryAfterMs } on 429. |
retryAfterMs | How long to wait before retrying, in milliseconds: the body's data.retryAfterMs, else the Retry-After header. Set on 429 and on 503 responses that send a wait. |
retryable | true for TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE, TIMEOUT, and NETWORK_ERROR. |
cause | The underlying error, for example the fetch failure. |
Retry a retryable issue with the same idempotency key: a timeout or 503 can leave the send outcome ambiguous. See Idempotency.
import { isOtpApiError } from "@k-otp/sdk";
try {
await otp.issue({ phoneNumber, purpose: "login", idempotencyKey });
} catch (error) {
if (isOtpApiError(error) && error.retryable) {
// wait error.retryAfterMs (when set), then retry with the same idempotencyKey
}
}The headless flow and the React, Vue, and Svelte packages handle this for you. They reuse the key for the same input after a retryable failure, start a resend cooldown after each successful send (resendCooldownMs, 30 seconds by default) or for retryAfterMs after a 429/503, and keep a separate verify cooldown after a rate-limited verify. Their actions never reject for API errors: they resolve with { data }, { error }, or { skipped }.
SDK 2.1.0 changes
SDK 2.1.0 supports API 2.x, where codes are delivered by KakaoTalk AlimTalk only and SMS is only the automatic fallback of a failed AlimTalk delivery. Nothing is removed: code written for 2.0.0 keeps compiling.
- Issue request errors.
getIssueRequestErrorCode(error)readsCHANNEL_NOT_SUPPORTED,TEMPLATE_NOT_AVAILABLEandWEB_OTP_NOT_SUPPORTEDfrom a 400 (error.codestaysBAD_REQUEST). - Deprecated.
channel: "sms", thewebOtpoption and result, the WebOTP helpers and props of@k-otp/sdk/ui, andmessageType. They are still sent as given, so the API answers with a clear 400; they will be removed in the next major version. The form'swebOtpsetting now defaults tofalse. - Delivered channel.
listIssues()/getIssue()can still reportchannel: "sms"after an SMS fallback (OtpDeliveredChannel). - Payment errors. The 402
datatype keepsresetAtofTRIAL_DAILY_LIMIT_EXCEEDED.
SDK 2.0.0 changes
- HTTPS
baseUrl. Clients refuse a plainhttp://baseUrlexcept loopback hosts (localhost,*.localhost,127.0.0.0/8,[::1]). For local or test setups on other hosts, passallowInsecureHttp: true(development and tests only). - Ambiguous
issueoutcomes keep the idempotency key. A timeout, a network error, a redirect or a success response the SDK cannot read no longer drops the pending idempotency key: retrying the same request reuses it, so the API never sends a second code. CheckisAmbiguousIssueError(error)to tell these cases apart from definite failures. - Timeouts cover key resolution.
timeoutMsand abort signals now also bound an asyncapiKeyresolver.
Credit wallet fields (API 1.4.0)
The organization credit wallet release of the API (spec 1.4.0) adds fields that @k-otp/sdk/server exposes:
getBalance()also returnswalletId,walletScope("organization", or"app"for a legacy per-app wallet), andorganizationIdwhen the wallet is shared.appIdstays the calling app.- Entries from
listCreditLedger()anditerateCreditLedger()gain an optionalappId.
An API that has not been updated does not send these fields, so the SDK types walletId and walletScope as optional. Until that release the balance is the app's own wallet, as described in Credits & billing.