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. Platform rate limits always apply to pk_ keys, but if you need your own bot protection or per-user 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.

Rate limits

POST /v1/issue and POST /v1/verify are rate limited per key. Other APIs are not. Each key can set its own rules in the console as <count>/<window> with s, m, h or d (for example 5/10m).

RuleCountspk_ platform defaultKey ruleCeiling
Per keyAll issue requests (verify separately)120/1mReplaces the default (looser or stricter)3000/1m
Per IPIssue requests per client IP30/10mReplaces the default (looser or stricter)300/10m
Per phone numberIssue requests per recipient number5/10mCan only be stricter; the default always applies—
  • sk_ keys have no platform defaults: only the rules set on the key apply, within the same ceilings.
  • Ceilings are compared per second, so 50/1s equals the 3000/1m ceiling. A rule over a longer window than its ceiling (for example 100000/1h) is also held to the ceiling so it cannot be spent in one burst. The console rejects rules above a ceiling.
  • Verify requests count toward the per key limit only.

A request over a limit returns 429 TOO_MANY_REQUESTS with a Retry-After header (seconds) and data: { limit, policy, retryAfterMs }: limit names the rule, policy is key for the key's own rule or platform for the default or a ceiling. Rejected requests do not count. Retry after Retry-After with the same Idempotency-Key.

Test keys

Every app can also have test keys, pk_test_ and sk_test_. They follow the rules above but only accept magic test numbers, never send a message and never spend credits; a pk_test_ key also works from localhost and 127.0.0.1 on any port. See Test mode.

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 issue history and, like every app of the organization, the credit wallet, 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