How we build K-OTP SDKs for nine languages from one OpenAPI spec (with Kiota)
You can call the K-OTP API (https://api.k-otp.dev/v1) from any language, but most teams want a client that gets idempotency keys, retries and error details right. This post explains how we build those clients for nine languages from one OpenAPI document, how we check them, and how we ship them. Everything described here is open source in github.com/k-otp/sdk (MIT).
Two tracks, one spec
We maintain two kinds of SDK, and they solve different problems.
@k-otp/sdk, hand-written, for TypeScript and JavaScript. One npm package with a subpath per use: the core issue/verify client for browsers with a pk_ key, @k-otp/sdk/server for every public operation with an sk_ key, hooks for React, Vue and Svelte, headless UI components (a one-line <OtpForm /> or composable parts), and @k-otp/sdk/testing, an in-memory test-mode API for unit tests. Its value is user experience (resend cooldowns, an idempotency key per send attempt, Korean mobile number handling, a paste-friendly code input), which a generator does not write.
Kiota-generated server clients for everything else. Backends need a correct client more than a UI, in whatever language they use. For C#, Java, Kotlin/JVM, PHP, Go, Python, Ruby and Dart, plus TypeScript through @k-otp/sdk/kiota, we generate the HTTP layer with Kiota and add a small hand-written client on top. Each one covers the nine public operations (issue, verify, status, issue list and detail, credit ledger, balance, template list and detail), with JSON in and JSON out.
That is nine languages, counted honestly: Kotlin/JVM uses the Java JAR and its API rather than a separate package, and TypeScript has both tracks. On a TypeScript server, @k-otp/sdk/server stays the typed, idiomatic choice; @k-otp/sdk/kiota is for teams that want the same API shape across services in several languages.
Both tracks start from the same file: the public OpenAPI document, vendored at spec/openapi.json. The hand-written SDK generates its TypeScript types from it, and a drift test fails CI when the types or the client's contract disagree with the spec.
What Kiota is, and why we picked it
Kiota is a command line tool from Microsoft that generates an API client for any API described with OpenAPI. It is open source under the MIT license (github.com/microsoft/kiota), and generated code builds on per-language runtime libraries that Microsoft and the community maintain (as of October 2026).
We evaluated it first and kept the record in the repository (poc/kiota/ADOPTION.md). What decided it:
- Official runtimes, no fork. Every SDK uses the published Kiota runtime for its language. We never patch generated files and never fork Kiota or a runtime.
- Reproducible output. With the CLI pinned (Kiota 1.35.0), two clean generations produce byte-identical code, so a diff in generated code always has a cause.
- It passed our contract. With the adjustments below, every target passed all 28 cases of our shared HTTP contract.
The evaluation was also clear about the limits. Code generated from the unmodified spec could not ship: Kiota warned that it could not create error types for our error responses, and the raw clients lost fields of the error envelope at runtime. The pinned CLI labels Ruby and Dart as experimental and TypeScript as a preview, has no Swift generator, and its .http request examples did not pass our checks. We record those findings rather than hide them.
The overlay and the wrapper
Two layers sit between the spec and what you install.
The overlay is a generation-only copy of the spec (poc/kiota/overlays/compat.ts). The vendored spec is never modified, and every change is saved with its JSON pointer and reason. It does three things:
- Turns
oneOflists of constants into plainenumvalues (the allowed values stay the same). - Adds the missing
typeto typelessconstvalues. - Points all error responses at one common error envelope (
defined,code,status,message,data), so every client can read API errors. The trade-off is documented: the per-branch typing of errordatais relaxed.
Ruby and Dart get one extra hint each, and Ruby, Dart and TypeScript need a small compatibility layer (a few dozen lines each) built on the official runtime.
The wrapper is the small hand-written client in sdks/<language>/. It is where the K-OTP rules live, identically in every language:
- Only
sk_secret keys are accepted. - The base URL defaults to
https://api.k-otp.dev/v1and must be HTTPS (loopback HTTP for local tests). Redirects and requests to any other origin are refused, so the key only ever goes to the configured API. issuevalidates the idempotency key before any request (1 to 128 ASCII characters, trimmed) and sends it as theIdempotency-Keyheader and in the body.- Automatic retries are off. You can opt in to exactly one
issueretry after an HTTP 503, with the same body and key.verifyis never retried. - Errors are mapped to one API error type with the HTTP status, the envelope, the request ID and the retry delay in milliseconds. A timeout or network failure is a transport error with the outcome
"unknown": the code may already have been sent, so you retry with the same key.
How correctness is enforced
The checks run in this order:
- The spec comes from the released API.
bun run sync:openapicopies the published OpenAPI document intospec/openapi.json. It is never fetched during generation. - The spec is pinned. Its SHA-256 is recorded next to the Kiota version, and generation stops if the file does not match.
- Generate twice. Each target is generated from the raw spec and from the overlay, twice from clean directories, and the code is compared byte for byte.
- Build everything. All generated files are compiled or loaded with the real toolchain and runtime of each language.
- Package, then install it somewhere else. The SDK becomes a real package (a NuGet package, a JAR, a wheel, a Composer ZIP, a Go module ZIP, a gem, a Dart archive, an npm tarball), and a separate consumer project installs that package, never the source tree. Kotlin compiles against the installed Java JAR.
- Run the shared contract. The 28 cases in one fixture file run through the real generated request builders and serializers against a local mock server. They cover all nine operations, leading zeros in phone numbers and codes, errors from 400 to 503,
Retry-After, cursor pages and the retry rules above. - Keep the known gaps exact. The raw (non-overlay) output still fails some cases. CI checks that it fails in exactly the reviewed way, so a Kiota or spec change that alters those failures, even by fixing one, stops the build until a person reviews it.
This is one GitHub Actions workflow, kiota-poc.yml, run before merging an SDK change and again on main at the release commit. Its final gate needs complete evidence for all nine targets from that run; missing or stale evidence never counts as a pass.
Delivery
npm. @k-otp/sdk is released with Sampo changesets and published from GitHub Actions with npm Trusted Publishing (OIDC). There is no npm token in the repository, and packages published this way carry an npm provenance attestation. @k-otp/sdk/kiota is part of that package and shares its version.
Native packages. A second workflow, release-sdks.yml, builds nothing. It takes the ID of a successful validation run, checks that it is that workflow on main at the same commit, and re-verifies every consumer result, the artifact list and each file's SHA-256, plus that the npm version with /kiota is already published. Only then does it create the server-sdk-vX.Y.Z GitHub Release with the packages, a SHA256SUMS file and the validation records. Go is installed from its module tag (sdks/go/vX.Y.Z), a separate snapshot commit that holds exactly the verified module contents. A published release is never overwritten.
Install commands for every language are in Server SDKs by language, and the npm package is in SDKs.
Versioning
@k-otp/sdkkeeps its major and minor version in step with the API contract: both are 2.1 today.- The native server SDKs are a separate
0.xpreview line.0.2.0requires API 2.0.0 or later. Pin the exact version: names and options may still change before 1.0. - We deprecate before we remove. In
@k-otp/sdk2.1.0, options that API 2.x no longer accepts are deprecated, still compile and still reach the API, which answers with a clear 400. They are removed only in the next major version.
A taste
The hand-written TypeScript server client is typed:
import { createIdempotencyKey, createOtpServerClient } from "@k-otp/sdk/server";
const otp = createOtpServerClient({ apiKey: process.env.K_OTP_SECRET_KEY! });
const idempotencyKey = createIdempotencyKey("login"); // persist and reuse on retries
const { issueId } = await otp.issue({
phoneNumber: "01012345678",
purpose: "login",
idempotencyKey,
});
const { verified, reasonCode } = await otp.verify({ issueId, code: "123456" });
The Kiota-based clients take and return plain JSON, with the API's field names. Python:
import os
from kotp_sdk import KotpClient
async with KotpClient(os.environ["K_OTP_SECRET_KEY"]) as client:
issued = await client.issue({
"phoneNumber": "01012345678",
"purpose": "login",
"idempotencyKey": idempotency_key, # stored, reused on retries
})
result = await client.verify({"issueId": issued["issueId"], "code": "123456"})
verified = result["verified"]
Go:
client, err := kotp.New(kotp.Options{APIKey: os.Getenv("K_OTP_SECRET_KEY")})
if err != nil {
return err
}
input, _ := json.Marshal(map[string]any{
"phoneNumber": "01012345678",
"purpose": "login",
"idempotencyKey": idempotencyKey,
})
issued, err := client.Issue(ctx, input, kotp.RequestOptions{})
if err != nil {
return err // *kotp.APIError, or *kotp.TransportError: outcome unknown, retry with the same key
}
issueID := issued.(map[string]any)["issueId"].(string)
verifyInput, _ := json.Marshal(map[string]any{"issueId": issueID, "code": "123456"})
result, err := client.Verify(ctx, verifyInput, kotp.RequestOptions{})
What is still missing
- The native SDKs are a preview. They are not on NuGet, Maven Central, PyPI, RubyGems, pub.dev or Packagist yet: each registry needs a publisher account and namespace set up first, and Packagist also needs a separate distribution repository. Until then, install from the GitHub Release and check
SHA256SUMS. - They are server clients only: no Android, Kotlin Multiplatform, Flutter or browser use. In the browser, use
@k-otp/sdk. - The tested runtimes are specific: .NET 8, JDK 21, PHP 8.4, Go 1.26, Python 3.13, Ruby 3.3, Dart 3.9 and Node.js 20.19, 22 and 24.
- There is no Swift SDK, because the pinned Kiota CLI has no Swift generator.
If you need a registry package, another runtime or another language, open an issue or contact us.