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 key | sk_ secret key | |
|---|---|---|
| Where | Browser | Server only |
| Callable APIs | POST /v1/issue and POST /v1/verify only | Every public API |
Origin header | Required; must exactly match the allowlist | Not checked |
| Allowed scopes | otp:issue, otp:verify only | Any 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/issueandPOST /v1/verifyare allowed. Every other API returns403. - Every request needs an
Originheader that exactly matches one of the key's allowed origins. A missing or unlisted Origin returns403. - Wildcards are not supported.
https://example.comandhttps://www.example.com, orhttp://localhost:3000andhttp://localhost:5173, are different origins and must each be registered. - A
pk_key may only holdotp:issueandotp: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).
| API | Required scope | Also accepted | pk_ |
|---|---|---|---|
POST /v1/issue | otp:issue | otp:send, * | yes |
POST /v1/verify | otp:verify | otp:read, * | yes |
GET /v1/status | otp:status | otp:read, * | no |
GET /v1/issues, GET /v1/issues/{issueId} | otp:dashboard:read | otp:read, * | no |
GET /v1/credit-ledger | otp:ledger:read | otp:dashboard:read, otp:read, * | no |
GET /v1/balance | otp:balance | * | no |
GET /v1/templates, GET /v1/templates/{templateId} | otp:templates | otp: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:
- 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.
- Deploy your service with the new key (environment variables, etc.). For a
pk_key, register the same allowed origins. - 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.