K-OTP

Server SDKs by language

Preview server clients for C#, Java, Kotlin/JVM, PHP, Go, Python, Ruby, and Dart, plus the Kiota client in @k-otp/sdk for TypeScript. Install, issue and verify, idempotency, and error handling.

Besides the JavaScript and TypeScript SDK, K-OTP has server clients for other languages. Each one is a small, hand-written client around code generated from the public OpenAPI spec with Kiota. They are open source in the sdks/ directory of github.com/k-otp/sdk.

The language SDKs are a preview at version 0.1.0, versioned separately from the npm package. They are not published to PyPI, NuGet, Maven Central, Packagist, RubyGems, or pub.dev yet: install them from the server-sdk-v0.1.0 GitHub Release, and Go from its module path. Pin the exact version; method names and options may still change before 1.0.

These clients take an sk_ secret key and belong on your server only. For browsers and pk_ keys, use @k-otp/sdk and its framework and UI imports.

Packages

LanguagePackageVersionInstall fromTested with
C#KOtp.Sdk0.1.0GitHub Release: KOtp.Sdk.0.1.0.nupkg.NET 8
Javadev.kotp:kotp-sdk0.1.0GitHub Release: JAR + POM (Java 17 bytecode)JDK 21
Kotlin/JVMdev.kotp:kotp-sdk (Java JAR)0.1.0Same JAR as JavaKotlin 2.1.20, JDK 21
PHPk-otp/sdk0.1.0GitHub Release: kotp-sdk-php-0.1.0.zipPHP 8.4 (requires 8.2 or later)
Gogithub.com/k-otp/sdk/sdks/gov0.1.0Go module proxyGo 1.26
Pythonkotp-sdk (import kotp_sdk)0.1.0GitHub Release: wheel or sdistPython 3.13
Rubykotp_sdk0.1.0GitHub Release: kotp_sdk-0.1.0.gemRuby 3.3
Dartkotp_sdk0.1.0GitHub Release: kotp-sdk-dart-0.1.0.tar.gzDart 3.9, server (dart:io)
TypeScript@k-otp/sdk/kiotanpm 1.3.0+npm, part of @k-otp/sdkNode.js 20.19, 22, 24

Release assets download from https://github.com/k-otp/sdk/releases/download/server-sdk-v0.1.0/<file>. Check them against the release's SHA256SUMS:

shasum -a 256 -c SHA256SUMS --ignore-missing

For TypeScript on a server, @k-otp/sdk/server remains the typed, idiomatic client. @k-otp/sdk/kiota is the same JSON-in, JSON-out client as the other languages, for teams that want one API shape across services.

What every client does

  • Nine operations. Issue, verify, status, issue list, issue detail, credit ledger, balance, template list, and template detail: the operations in the API reference. Method names follow each language's conventions (issue, IssueAsync, issue_detail, …).
  • JSON in, JSON out. Inputs use the API's JSON field names (phoneNumber, purpose, idempotencyKey, issueId, code), and responses are the language's native JSON values. Keep phone numbers and codes as strings so leading zeros survive.
  • API 1.8.0. The 0.1.0 clients are generated from OpenAPI spec 1.8.0, so issue accepts every current field, including smsFallback (Channels and SMS fallback).
  • Secret keys only. The constructor rejects any key that does not start with sk_.
  • Locked to the API origin. The base URL defaults to https://api.k-otp.dev/v1 and must be HTTPS (loopback HTTP is allowed for local tests). Redirects and requests to any other origin are rejected, so the key is only ever sent to the configured API. The default timeout is 10 seconds.

Configuration mistakes (a missing or non-sk_ key, an invalid base URL, a missing or invalid idempotency key) fail before any request is sent, with a configuration error instead of an API error: for example ArgumentException in C#, IllegalArgumentException in Java, TypeError in TypeScript and Python, or kotp.ConfigurationError in Go.

Idempotency and retries

issue requires an idempotencyKey in its input: 1 to 128 printable ASCII characters without spaces (surrounding whitespace is trimmed), for example a UUID. The client validates it before sending and sends the same key as the Idempotency-Key header and in the body. Create one key per send attempt, store it, and reuse it when you retry that attempt (Idempotency).

The clients never retry automatically. Two cases need care:

  • A timeout or network failure raises a transport error whose outcome is "unknown": the code may already have been sent. Retry issue with the same key; never start over with a new key (Unknown outcomes).
  • HTTP 503 raises an API error. You can opt in to exactly one automatic retry of issue after a 503, with the same body and key. verify is never retried.
LanguageAllow one issue retry after 503
C#new RequestOptions(Retry503: true)
Java/Kotlinnew KotpClient.RequestOptions(Map.of(), null, true)
PHP['retry503' => true]
Gokotp.RequestOptions{Retry503: true}
Pythonretry503=True
Rubyretry503: true
Dartoptions: RequestOptions(retry503: true)
TypeScript{ retry503: true }

Errors

A non-2xx response raises an API error that keeps the whole error envelope: the HTTP status, envelope (defined, code, status, message, data), the X-Request-Id value (include it when you contact support), the wait before retrying in milliseconds (from data.retryAfterMs, else Retry-After; set on 429 and on 503 responses that send one), and the response headers with lowercase names. Branch on the envelope's code.

A wrong or expired code is not an error: verify returns verified: false with a reasonCode (Verification results).

LanguageAPI errorTransport error (outcome "unknown")
C#KotpApiException: Status, Envelope, RequestId, RetryAfterMsKotpTransportException
Java/KotlinKotpClient.KotpApiException: status, envelope, requestId, retryAfterMsKotpClient.KotpTransportException
PHPKOtp\KotpApiError: status, envelope, requestId, retryAfterMsKOtp\KotpTransportError
Go*kotp.APIError: Status, Envelope, RequestID, RetryAfterMs*kotp.TransportError
PythonKotpApiError: status, envelope, request_id, retry_after_msKotpTransportError
RubyKOtp::ApiError: status, envelope, request_id, retry_after_msKOtp::TransportError
DartKotpApiError: status, envelope, requestId, retryAfterMsKotpTransportError
TypeScriptKotpApiError: status, envelope, requestId, retryAfterMsKotpTransportError

C# (.NET)

Download KOtp.Sdk.0.1.0.nupkg into a local ./packages directory, add it as a NuGet source, and reference the package. Keep nuget.org enabled: the package's runtime dependencies come from there.

dotnet nuget add source ./packages --name kotp-local
dotnet add package KOtp.Sdk --version 0.1.0
using System.Text.Json.Nodes;
using KOtp;

using var client = new KotpClient(Environment.GetEnvironmentVariable("KOTP_SECRET_KEY")!);

try {
    var issued = await client.IssueAsync(new JsonObject {
        ["phoneNumber"] = "01012345678",
        ["purpose"] = "login",
        ["idempotencyKey"] = idempotencyKey, // e.g. Guid.NewGuid().ToString(), stored for retries
    });
    var issueId = issued!["issueId"]!.GetValue<string>();

    // Later, with the code the user entered:
    var result = await client.VerifyAsync(new JsonObject { ["issueId"] = issueId, ["code"] = "123456" });
    var verified = result!["verified"]!.GetValue<bool>();
} catch (KotpApiException e) {
    var code = e.Envelope?["code"]?.GetValue<string>();
    Console.Error.WriteLine($"{e.Status} {code} {e.RequestId}");
} catch (KotpTransportException) {
    // Outcome unknown: retry IssueAsync with the same idempotencyKey.
}

Every method also takes a CancellationToken. The constructor accepts baseUrl and timeoutMs. Dispose the client when you are done.

Java

Download kotp-sdk-0.1.0.jar, kotp-sdk-0.1.0.pom, kotp-sdk-0.1.0-sources.jar, and kotp-sdk-0.1.0-javadoc.jar, install them into your local Maven repository, and add the dependency.

mvn install:install-file -Dfile=kotp-sdk-0.1.0.jar -DpomFile=kotp-sdk-0.1.0.pom \
  -Dsources=kotp-sdk-0.1.0-sources.jar -Djavadoc=kotp-sdk-0.1.0-javadoc.jar
<dependency>
  <groupId>dev.kotp</groupId>
  <artifactId>kotp-sdk</artifactId>
  <version>0.1.0</version>
</dependency>
import com.google.gson.JsonObject;
import dev.kotp.sdk.KotpClient;

try (KotpClient client = new KotpClient(System.getenv("KOTP_SECRET_KEY"))) {
    JsonObject issue = new JsonObject();
    issue.addProperty("phoneNumber", "01012345678");
    issue.addProperty("purpose", "login");
    issue.addProperty("idempotencyKey", idempotencyKey); // e.g. UUID.randomUUID().toString()
    String issueId = client.issue(issue).getAsJsonObject().get("issueId").getAsString();

    // Later, with the code the user entered:
    JsonObject verify = new JsonObject();
    verify.addProperty("issueId", issueId);
    verify.addProperty("code", "123456");
    boolean verified = client.verify(verify).getAsJsonObject().get("verified").getAsBoolean();
} catch (KotpClient.KotpApiException e) {
    System.err.println(e.status + " " + e.envelope.get("code") + " " + e.requestId);
} catch (KotpClient.KotpTransportException e) {
    // Outcome unknown: retry issue with the same idempotencyKey.
}

Calls are synchronous and return Gson values. The constructor new KotpClient(apiKey, baseUrl, timeoutMs) overrides the defaults. Close the client to release its connection pool.

Kotlin/JVM

Kotlin/JVM uses the Java JAR and API: install it as in Java. Android and Kotlin Multiplatform are not covered by this release.

import com.google.gson.JsonObject
import dev.kotp.sdk.KotpClient

KotpClient(System.getenv("KOTP_SECRET_KEY")).use { client ->
    try {
        val issued = client.issue(JsonObject().apply {
            addProperty("phoneNumber", "01012345678")
            addProperty("purpose", "login")
            addProperty("idempotencyKey", idempotencyKey)
        })
        val issueId = issued.asJsonObject.get("issueId").asString

        val result = client.verify(JsonObject().apply {
            addProperty("issueId", issueId)
            addProperty("code", "123456")
        })
        val verified = result.asJsonObject.get("verified").asBoolean
    } catch (e: KotpClient.KotpApiException) {
        println("${e.status} ${e.envelope.get("code")} ${e.requestId}")
    } catch (e: KotpClient.KotpTransportException) {
        // Outcome unknown: retry issue with the same idempotencyKey.
    }
}

Calls block, so run them on a suitable dispatcher (for example Dispatchers.IO) from coroutines.

PHP

Download kotp-sdk-php-0.1.0.zip into a local ./packages directory, register it as a Composer artifact repository, and require the package.

composer config repositories.kotp artifact ./packages
composer require k-otp/sdk:0.1.0
use KOtp\KotpApiError;
use KOtp\KotpClient;
use KOtp\KotpTransportError;

$client = new KotpClient(getenv('KOTP_SECRET_KEY'));

try {
    $issued = $client->issue([
        'phoneNumber' => '01012345678',
        'purpose' => 'login',
        'idempotencyKey' => $idempotencyKey, // e.g. bin2hex(random_bytes(16)), stored for retries
    ]);

    // Later, with the code the user entered:
    $result = $client->verify(['issueId' => $issued->issueId, 'code' => '123456']);
    $verified = $result->verified;
} catch (KotpApiError $e) {
    error_log("{$e->status} {$e->envelope->code} {$e->requestId}");
} catch (KotpTransportError $e) {
    // Outcome unknown: retry issue with the same idempotencyKey.
}

Calls are synchronous. JSON objects come back as stdClass and lists as arrays. The constructor accepts $baseUrl and $timeoutMs.

Go

go get github.com/k-otp/sdk/sdks/go@v0.1.0
import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"

	kotp "github.com/k-otp/sdk/sdks/go"
)

func sendAndVerify(ctx context.Context, idempotencyKey string) (bool, error) {
	client, err := kotp.New(kotp.Options{APIKey: os.Getenv("KOTP_SECRET_KEY")})
	if err != nil {
		return false, err
	}

	input, _ := json.Marshal(map[string]any{
		"phoneNumber":    "01012345678",
		"purpose":        "login",
		"idempotencyKey": idempotencyKey,
	})
	issued, err := client.Issue(ctx, input, kotp.RequestOptions{})
	var apiErr *kotp.APIError
	var transportErr *kotp.TransportError
	switch {
	case errors.As(err, &apiErr):
		return false, fmt.Errorf("%d %v %s", apiErr.Status, apiErr.Envelope["code"], apiErr.RequestID)
	case errors.As(err, &transportErr):
		return false, err // Outcome unknown: retry Issue with the same idempotencyKey.
	case err != nil:
		return false, err
	}
	issueID := issued.(map[string]any)["issueId"].(string)

	// Later, with the code the user entered:
	verifyInput, _ := json.Marshal(map[string]any{"issueId": issueID, "code": "123456"})
	result, err := client.Verify(ctx, verifyInput, kotp.RequestOptions{})
	if err != nil {
		return false, err
	}
	return result.(map[string]any)["verified"].(bool), nil
}

Issue and Verify take the request body as json.RawMessage; responses are decoded JSON (map[string]any for objects). Every call takes a context. Options also has BaseURL and Timeout.

Python

Download the wheel and install it.

python -m pip install ./kotp_sdk-0.1.0-py3-none-any.whl
import asyncio
import os

from kotp_sdk import KotpApiError, KotpClient, KotpTransportError

async def main(idempotency_key: str) -> None:
    async with KotpClient(os.environ["KOTP_SECRET_KEY"]) as client:
        try:
            issued = await client.issue({
                "phoneNumber": "01012345678",
                "purpose": "login",
                "idempotencyKey": idempotency_key,  # e.g. str(uuid.uuid4()), stored for retries
            })

            # Later, with the code the user entered:
            result = await client.verify({"issueId": issued["issueId"], "code": "123456"})
            verified = result["verified"]
        except KotpApiError as e:
            print(e.status, e.envelope["code"], e.request_id)
        except KotpTransportError:
            pass  # Outcome unknown: retry issue with the same idempotency_key.


asyncio.run(main(idempotency_key))

The client is async. Use async with or call await client.aclose(). The constructor accepts base_url and timeout_ms.

Ruby

Download the gem and install it locally.

gem install --local kotp_sdk-0.1.0.gem
require 'kotp_sdk'

client = KOtp::Client.new(ENV.fetch('KOTP_SECRET_KEY'))

begin
  issued = client.issue({
    'phoneNumber' => '01012345678',
    'purpose' => 'login',
    'idempotencyKey' => idempotency_key # e.g. SecureRandom.uuid, stored for retries
  })

  # Later, with the code the user entered:
  result = client.verify({ 'issueId' => issued['issueId'], 'code' => '123456' })
  verified = result['verified']
rescue KOtp::ApiError => e
  warn "#{e.status} #{e.envelope['code']} #{e.request_id}"
rescue KOtp::TransportError
  # Outcome unknown: retry issue with the same idempotency_key.
end

Responses are Hash, Array, and scalar values with string keys. The constructor accepts base_url: and timeout_ms:. Loading the gem installs a guarded process-wide JSON compatibility hook, so keep its locked runtime dependency versions.

Dart

Extract kotp-sdk-dart-0.1.0.tar.gz into a directory and add it as a path dependency named kotp_sdk.

mkdir -p third_party/kotp_sdk
tar -xzf kotp-sdk-dart-0.1.0.tar.gz -C third_party/kotp_sdk
dependencies:
  kotp_sdk:
    path: third_party/kotp_sdk
import 'dart:io';

import 'package:kotp_sdk/kotp_sdk.dart';

final client = KotpClient(Platform.environment['KOTP_SECRET_KEY']!);
try {
  final issued = await client.issue({
    'phoneNumber': '01012345678',
    'purpose': 'login',
    'idempotencyKey': idempotencyKey, // stored for retries
  }) as Map<String, dynamic>;

  // Later, with the code the user entered:
  final result = await client.verify({
    'issueId': issued['issueId'],
    'code': '123456',
  }) as Map<String, dynamic>;
  final verified = result['verified'] as bool;
} on KotpApiError catch (e) {
  stderr.writeln('${e.status} ${e.envelope['code']} ${e.requestId}');
} on KotpTransportError {
  // Outcome unknown: retry issue with the same idempotencyKey.
} finally {
  client.close();
}

The supported runtime is Dart 3.9 on the server (dart:io); like your sk_ key, it does not belong in a Flutter or web app. The constructor accepts baseUrl: and timeoutMs:. Call close() when you are done.

TypeScript (@k-otp/sdk/kiota)

npm i @k-otp/sdk
import { KotpApiError, KotpClient, KotpTransportError } from "@k-otp/sdk/kiota";

const client = new KotpClient({ apiKey: process.env.KOTP_SECRET_KEY! });

try {
	const issued = (await client.issue({
		phoneNumber: "01012345678",
		purpose: "login",
		idempotencyKey, // e.g. crypto.randomUUID(), stored for retries
	})) as { issueId: string };

	// Later, with the code the user entered:
	const result = (await client.verify({ issueId: issued.issueId, code: "123456" })) as {
		verified: boolean;
		reasonCode?: string;
	};
} catch (error) {
	if (error instanceof KotpApiError) {
		console.error(error.status, error.envelope.code, error.requestId);
	} else if (error instanceof KotpTransportError) {
		// Outcome unknown: retry issue with the same idempotencyKey.
	} else {
		throw error;
	}
}

Responses are untyped JSON (unknown); for typed results use @k-otp/sdk/server. The constructor accepts baseUrl, timeoutMs, and a custom fetch. Under the browser export condition, constructing the client throws, so the server client cannot run in a browser bundle.

On this page