K-OTP

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

LanguagePackageStatusRuns in
JavaScript, TypeScript@k-otp/sdk on npmStable, 2.xBrowsers (pk_) and servers (sk_)
C#, Java, Kotlin/JVM, PHP, Go, Python, Ruby, DartSee Server SDKs by languagePreview, 0.2.0Servers 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/sdk

React (>= 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.

ImportUse it forKey
@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/headlessThe framework-agnostic issue → verify flow the framework bindings are built onpk_
@k-otp/sdk/serverThe full public API: issue, verify, status, history, ledger, balance, templates (Node.js >= 20.19, Bun, Deno, Workers)sk_
@k-otp/sdk/reactOtpProvider, useOtpIssue, useOtpVerify, useOtpFlow (React 18 and 19)pk_
@k-otp/sdk/vuecreateOtpPlugin, useOtp, useOtpFlow (Vue 3.3+)pk_
@k-otp/sdk/sveltecreateOtpStores, a flow store, the use:otpForm action (Svelte 4 and 5)pk_
@k-otp/sdk/ui, /ui/react, /ui/vue, /ui/svelteReady-made verification forms: see UI componentspk_
@k-otp/sdk/ui/theme.cssThe optional default theme for the UI componentsnone
@k-otp/sdk/kiotaA JSON-in, JSON-out server client shaped like the other language SDKs (Node.js >= 20.19, 1.3.0+)sk_
@k-otp/sdk/contractThe oRPC contract and generated OpenAPI types, for building your own clientany

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

OptionDefaultDescription
apiKeyrequiredpk_ key in browsers, sk_ key on servers. May be a (possibly async) function resolved before every request.
baseUrlhttps://api.k-otp.dev/v1Must include /v1. Without it, requests go to /issue instead of /v1/issue and fail. A trailing slash is fine.
timeoutMs10000Per request; 0 disables the timeout. Override per call with { timeoutMs }.
fetchglobalThis.fetchCustom fetch (tests, proxies).
headersExtra request headers. authorization and idempotency-key cannot be overridden.
hooksonRequestStart 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.

FieldDescription
codeBAD_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.
statusHTTP status, or 0 when no response was received (timeout, network failure, abort).
messageThe server's message, or a descriptive client message.
requestIdThe X-Request-Id response header when readable. Include it when you contact support.
dataThe error payload, for example { code: "INSUFFICIENT_CREDIT" } on 402, or { limit, policy, retryAfterMs } on 429.
retryAfterMsHow 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.
retryabletrue for TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE, TIMEOUT, and NETWORK_ERROR.
causeThe 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) reads CHANNEL_NOT_SUPPORTED, TEMPLATE_NOT_AVAILABLE and WEB_OTP_NOT_SUPPORTED from a 400 (error.code stays BAD_REQUEST).
  • Deprecated. channel: "sms", the webOtp option and result, the WebOTP helpers and props of @k-otp/sdk/ui, and messageType. 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's webOtp setting now defaults to false.
  • Delivered channel. listIssues()/getIssue() can still report channel: "sms" after an SMS fallback (OtpDeliveredChannel).
  • Payment errors. The 402 data type keeps resetAt of TRIAL_DAILY_LIMIT_EXCEEDED.

SDK 2.0.0 changes

  • HTTPS baseUrl. Clients refuse a plain http:// baseUrl except loopback hosts (localhost, *.localhost, 127.0.0.0/8, [::1]). For local or test setups on other hosts, pass allowInsecureHttp: true (development and tests only).
  • Ambiguous issue outcomes 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. Check isAmbiguousIssueError(error) to tell these cases apart from definite failures.
  • Timeouts cover key resolution. timeoutMs and abort signals now also bound an async apiKey resolver.

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 returns walletId, walletScope ("organization", or "app" for a legacy per-app wallet), and organizationId when the wallet is shared. appId stays the calling app.
  • Entries from listCreditLedger() and iterateCreditLedger() gain an optional appId.

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.

On this page