K-OTP

Authentication

pk_ / sk_ keys, the Origin allowlist, scopes, key rotation and revocation

Every request authenticates with an Authorization: Bearer <key> header. Keys are issued per app in the console and come in two kinds.

pk_ public keysk_ secret key
WhereBrowserServer only
Callable APIsPOST /v1/issue and POST /v1/verify onlyEvery public API
Origin headerRequired; must exactly match the allowlistNot checked
Allowed scopesotp:issue, otp:verify onlyAny scope in the table below

A missing, malformed, or inactive (revoked or expired) key returns 401 UNAUTHORIZED; insufficient permissions (scope or Origin) return 403 FORBIDDEN. If an internal dependency needed to check the key is temporarily unavailable you may get 503 SERVICE_UNAVAILABLE.

sk_ secret keys (server)

Use sk_ keys on your server only. Anyone who extracts a key from a browser or mobile bundle can call the send and read APIs.

curl "https://api.k-otp.dev/v1/balance" \
  -H "Authorization: Bearer $KOTP_SECRET_KEY"

pk_ public keys (browser)

Use pk_ keys to call issue and verify directly from a web page. The key value is assumed to be public, so these restrictions always apply:

  • Only POST /v1/issue and POST /v1/verify are allowed. Every other API returns 403.
  • Every request needs an Origin header that exactly matches one of the key's allowed origins. A missing or unlisted Origin returns 403.
  • Wildcards are not supported. https://example.com and https://www.example.com, or http://localhost:3000 and http://localhost:5173, are different origins and must each be registered.
  • A pk_ key may only hold otp:issue and otp:verify. Keys with any other scope (including *) are rejected at authentication.

Browsers add the Origin header automatically; you do not set it yourself.

// Runs on https://app.example.com, which must be in the key's allowed origins.
const res = await fetch("https://api.k-otp.dev/v1/issue", {
	method: "POST",
	headers: {
		Authorization: "Bearer pk_...",
		"Content-Type": "application/json",
		"Idempotency-Key": crypto.randomUUID(),
	},
	body: JSON.stringify({ phoneNumber: "01012345678", purpose: "signup" }),
});

A pk_ key can still send messages and spend credits. If you need your own bot protection or per-user rate limits, call issue from your server with an sk_ key instead of from the browser.

Scopes

Each key carries scopes, and each API only accepts keys with the scope it requires. Keys holding an "also accepted" scope may call it too. * satisfies every scope (sk_ keys only).

APIRequired scopeAlso acceptedpk_
POST /v1/issueotp:issueotp:send, *yes
POST /v1/verifyotp:verifyotp:read, *yes
GET /v1/statusotp:statusotp:read, *no
GET /v1/issues, GET /v1/issues/{issueId}otp:dashboard:readotp:read, *no
GET /v1/credit-ledgerotp:ledger:readotp:dashboard:read, otp:read, *no
GET /v1/balanceotp:balance*no
GET /v1/templates, GET /v1/templates/{templateId}otp:templatesotp:read, *no

Grant the minimum scopes a key needs. A server that only issues and verifies needs just otp:issue and otp:verify.

Rotating and revoking keys

To rotate a key on a schedule or after a leak:

  1. Issue a new key in the same app in the console. Keys of the same app share the credit wallet and issue history, so nothing needs to be migrated.
  2. Deploy your service with the new key (environment variables, etc.). For a pk_ key, register the same allowed origins.
  3. Once traffic works with the new key, revoke the old key in the console.

Revocation can take up to about 60 seconds to reach every request. During that window, requests with the revoked key may still succeed. A newly issued key can also take a few seconds to become usable.

On this page