API reference

Base domain: https://auth.okia.io. Management API lives under /api/v1 (org-scoped pw_sk_ keys). All JSON is camelCase; timestamps are ISO 8601 UTC.

Protocol endpoints

Method Path Purpose
GET /authorize Start login (hosted page)
POST /oauth/token Exchange code / refresh tokens
POST /oauth/revoke Revoke a refresh-token family
GET /userinfo Claims for a valid access token
GET /logout End the Okia SSO session
GET /.well-known/jwks.json Signing keys (ES256, EC)
GET /.well-known/openid-configuration Discovery document

Only the authorization-code + PKCE flow is supported. PKCE is required for all clients; S256 is the only accepted method.

GET /authorize

Param Required Notes
response_type yes code only
client_id yes Your site's pw_c_… id
redirect_uri yes Must exactly match a registered redirect URI
scope yes e.g. openid profile; phone only with per-site consent
state yes Opaque CSRF value; echoed back
nonce recommended Echoed in the ID token
code_challenge yes Base64url SHA-256 of the verifier
code_challenge_method yes S256 only
origin popup mode Validated against the site's allowed origins before the transaction is created, not just before a code is issued. Its presence is what selects popup mode

Invalid client_id or redirect_uri renders an error page — Okia never redirects to an unvalidated URI. Suspended sites and disallowed origins are rejected the same way. Everything checked after the redirect URI has been matched — response_type, state, PKCE, scopes — is reported by redirecting to your callback with an RFC 6749 error and your state, because your application is the only party that can act on it.

scope must include openid. The phone scope additionally requires a verified domain; unverified sites run in test mode (25-user cap, banner, no phone scope).

The authorization code is opaque, single-use, and expires in 60 seconds. Exchange it immediately. Replaying a consumed code revokes every token minted from that grant and fires a token.reuse_detected event.

A code is consumed the moment it is presented, before PKCE and client checks run. A failed exchange therefore burns the code and cannot be retried (RFC 6819 §5.2.1.1) — retry from /authorize, not from the code.

POST /oauth/token

Content-Type: application/x-www-form-urlencoded. Client auth: confidential clients send client_secret; public clients are PKCE-only.

Grant: authorization_code

Param Notes
grant_type authorization_code
code From the redirect
redirect_uri Same value as in /authorize
code_verifier PKCE verifier
client_id Always
client_secret Confidential clients only

Grant: refresh_token

Param Notes
grant_type refresh_token
refresh_token Current token — it rotates on every use
client_id / client_secret As above

Response

{
  "token_type": "Bearer",
  "expires_in": 900,
  "access_token": "eyJ…",
  "id_token": "eyJ…",
  "refresh_token": "…",
  "scope": "openid profile"
}

These field names are fixed by RFC 6749 §5.1 and OIDC Core §3.1.3.3 — they are snake_case, not camelCase, so standard OAuth clients (openid-client, passport-oauth2, Laravel Socialite, golang.org/x/oauth2) work unmodified.

Refresh tokens rotate per use within a family: 30 days rolling idle, 90 days absolute. Reuse of an old refresh token revokes the whole family — with a 10-second grace window that returns the already-minted successor (multi-tab safety). Errors on this endpoint use the RFC 6749 format ({"error": "invalid_grant", …}).

POST /oauth/revoke

Form-encoded: token (a refresh token) + client auth. Revokes the token's entire family. Returns 200 regardless of whether the token was valid (per RFC 7009).

GET /userinfo

Authorization: Bearer <access token>. Returns claims filtered by the granted scopes — sub always, plus the OIDC-standard phone_number and phone_number_verified when the phone scope was consented. (phone is the scope name; the claims it unlocks carry their standard OIDC names.)

Claims are filtered by the intersection of the token's scopes and the grant's current scopes, so revoking a scope takes effect immediately rather than at token expiry.

GET /logout

Ends the Okia SSO session and revokes every refresh-token family for that user, adding each family's most recent access token to a denylist. Optional post_logout_redirect_uri — honored only if it exactly matches a registered redirect URI of a site the user actually holds a grant with; otherwise ignored, never echoed. Sites validating JWTs locally may accept already-issued access tokens for up to 15 minutes after a global logout.

Tokens and claims

Both JWTs are ES256-signed; verify against the JWKS. Key rotation is automatic — cache JWKS by kid and refetch on unknown kid.

Claim ID token Access token
iss https://auth.okia.io https://auth.okia.io
aud your client_id https://auth.okia.io
sub pairwise pw_… pairwise pw_…
exp 15 min 15 min
token_use "id" "access"
nonce from /authorize
amr ["webauthn"] or ["otp"]
auth_time yes
jti yes (revocation denylist)
azp your client_id (which client the token was minted for)
profile claims per scopes
phone_number, phone_number_verified with phone scope — (fetch from /userinfo)

You must check token_use. An access token has a valid signature and issuer but aud = https://auth.okia.io and token_use: "access" — accepting it where an ID token is expected is a session-forgery bug. Verify iss, aud, exp, and token_use on every token.

Management API (/api/v1)

Base URL https://auth.okia.io/api/v1. Every response carries X-Request-Id — quote it when you contact support.

Authentication

Authorization: Bearer pw_sk_…, an org-scoped API key created in the dashboard under Settings → API keys. The key is shown once; only its hash is stored. Each key carries explicit scopes:

Scope Grants
sites:read / sites:write List and read sites / create and update them
users:read / users:write Per-site end users (Wave 2 Phase 4)
events:read The audit log
usage:read Metered usage

Some actions are never available to an API key: rotating a client secret, deleting a site, and creating or revoking API keys. Those require a signed-in administrator who has confirmed with a passkey in the last five minutes, and answer 403 step_up_required to anything else. A credential that can mint credentials would make that requirement meaningless.

Conventions

  • Idempotency — send Idempotency-Key: <unique string> on any write. Ordinary writes replay their stored 2xx for 24 hours; the same key with a different body is a 409. Writes that return a secret (site create, secret rotate, key create) are never replayed — a repeat is a 409, because a secret shown once must not sit in a cache for a day. A request that failed does not consume the key.
  • Pagination — list endpoints take ?limit= (default 25, max 100) and ?cursor=, and answer { "data": [...], "nextCursor": "…" | null }. Cursors are keyset, not offsets: rows created while you page never shift what you see. Pass nextCursor back verbatim until it is null.
  • Rate limitsRateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds) on every response; over the limit is 429.
  • Request tracingX-Request-Id on every response. Include it in support requests; it is the key the server logs the failure under.
  • User lookup/v1/sites/{siteId}/users/{sub} only. Subs are per-site by design; there is no cross-site or org-wide resolution, and there will not be one.
  • JSON is camelCase on /api/v1 only, with ISO 8601 UTC timestamps. Protocol endpoints (/oauth/*, /userinfo, /.well-known/*) use the exact field names RFC 6749, RFC 7009, RFC 8414 and OIDC Core mandate.

Endpoints

Method Path Scope / role
GET · PATCH /org read: any · write: owner, session only
GET · POST /sites sites:read · sites:write + admin
GET · PATCH · DELETE /sites/{id} sites:read · sites:write + admin · delete also needs step-up
POST /sites/{id}/secret/rotate admin + step-up
PUT /sites/{id}/branding sites:write + admin
POST /sites/{id}/verification/start · /check sites:write + admin
GET · POST /api-keys owner, session only; create also needs step-up
DELETE /api-keys/{id} owner + step-up
GET /sites/{id}/users users:read
GET /sites/{id}/users/{sub} users:read
DELETE · POST /sites/{id}/users/{sub}/access users:write + admin — block / unblock on this site
DELETE /sites/{id}/users/{sub}/sessions users:write + admin
DELETE /sites/{id}/users/{sub}/credentials/{id} owner + step-up
POST · DELETE /sites/{id}/users/{sub}/suspend owner + step-up — platform-wide
GET /events events:read; add ?format=csv to stream an export
GET /usage usage:read
GET · POST /members session only; invite is owner + step-up
PATCH · DELETE /members/{id} owner, session only
POST /members/{id}/invite owner + step-up; a new link for a pending invite
GET · POST /webhooks webhooks:read · webhooks:write + admin
GET · PATCH · DELETE /webhooks/{id} webhooks:read · webhooks:write + admin
POST /webhooks/{id}/secret/rotate webhooks:write + admin + step-up
POST /webhooks/{id}/test webhooks:write + admin — sends a ping
GET /webhooks/{id}/deliveries webhooks:read
POST /webhooks/{id}/deliveries/{deliveryId}/replay webhooks:write + admin

POST /sites returns { "site": {…}, "clientSecret": "pw_cs_…" }. That is the only time the secret exists anywhere but your own storage — public clients get null, because PKCE is their whole credential.

POST /sites/{id}/secret/rotate returns a new secret and keeps the previous one working for 24 hours, so you can deploy on your own schedule rather than during the request.

DELETE /sites/{id} stops every sign-in through that site immediately and the client id is never reissued. Usage history and the audit log are kept.

Webhooks

POST /webhooks creates an endpoint and returns { "endpoint": {…}, "secret": "pw_whsec_…" } — the only time that secret exists outside your own storage. siteId scopes it to one site; omit it (or send null) for every site the organization owns. An empty eventTypes means every published type, including ones published later, so you do not have to come back and add them.

POST /webhooks/{id}/secret/rotate returns a new secret and keeps signing with the previous one for 24 hours, so deliveries carry two v1= signatures during the overlap and your endpoint can be updated on your own schedule. See webhooks.

Deliveries are kept for 30 days and can be replayed from the dashboard or through the API. A replay re-sends the exact bytes that were sent originally, so its signature covers the same body — it is a re-delivery, not a re-derivation.

End users, and the two ways to stop one

DELETE /sites/{id}/users/{sub}/access blocks someone on that site: their grant is revoked, the refresh tokens they hold for it are killed, and their next sign-in there is refused. Their access to your other sites, and to sites that are not yours, is untouched. POST to the same path restores it.

POST /sites/{id}/users/{sub}/suspend locks the account across the whole platform, including sites belonging to other organizations. It is owner-only, requires a passkey confirmed in the last five minutes, and every use is recorded with the administrator who did it. Use the per-site block unless you mean exactly this.

DELETE /sites/{id}/users/{sub}/credentials/{id} deletes a passkey. A passkey belongs to the person, not to your site, so this removes it everywhere and cannot be undone — the response says so with "affectsAllSites": true.

GET /sites/{id}/users?phone=+9725… matches a complete number and nothing else. There is no partial or prefix search: that would turn this endpoint into a way to discover whether a number is registered.

Phone numbers are returned masked (+9725•••••42) and never in full.

Events and usage

GET /events?format=csv streams the same rows the JSON form returns, honouring the same filters, capped at 10,000 rows — a capped export carries an X-Export-Truncated: true response header (the body stays plain CSV); narrow it with from and to rather than expecting a larger export.

GET /usage answers with metered rows by site and day plus a stats summary. wa.sent counts WhatsApp messages; login.passkey and login.otp count sign-ins by how the person actually authenticated. Note that enrolling — verifying a code and then creating a passkey — counts as login.otp, because the code is what authenticated them.

Team

POST /members returns a single-use invite link that expires in seven days. It is shown once, like a client secret; only its hash is stored. Whoever opens it enrols through the hosted sign-in page, so they arrive with a passkey already registered. POST /members/{id}/invite re-issues the link on the same pending row — the old one stops working — and answers 404 for a membership that was already accepted.

An organization must always have at least one owner: demoting or removing the last one answers 409 last_owner.

Error model

  • Management API (/api/v1/*): RFC 7807 application/problem+jsontype, title, status, plus requestId in the body and X-Request-Id on the response. The last path segment of type is the stable machine-readable code (step_up_required, idempotency_key_reuse, not_found, …) — branch on that, not on title. An invalid_body title names the offending field and why: domain: Required, redirectUris.2: Invalid url.
  • OAuth endpoints (/oauth/token, /oauth/revoke, /authorize redirects): the formats RFC 6749 mandates.
API reference · Okia docs