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 needs a live, verified site and the user's "Continue" on the sheet — "Continue without sharing" drops it from the grant, so the token comes back without it |
state |
yes | Opaque CSRF value; echoed back |
nonce |
recommended | Echoed in the ID token. Values starting pwtest_ are reserved for the dashboard's test sign-in, whose codes are never exchangeable |
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 |
max_age |
no | Seconds. A sign-in older than this gets no one-tap, only a fresh ceremony (the passkey first, when the person has one); 0 always asks again. Anything but a whole number is invalid_request, back on your callback |
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 HTTP Basic (preferred) or client_secret in the body; public clients are PKCE-only.
A public client may call it from the browser, from one of its site's allowed origins: the answer is readable there, and never carries a refresh_token. CORS preflights are answered, so the page may add headers of its own (a tracing traceparent, say); whether the response is readable is still decided per client, after authentication.
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 — HTTP Basic (preferred) or this field |
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. Sites validating JWTs locally may accept already-issued access tokens for up to 15 minutes after a global logout.
This is also OIDC RP-initiated logout (end_session_endpoint in discovery):
| Param | Notes |
|---|---|
post_logout_redirect_uri |
Where to send the user afterwards. Honored only if it exactly matches a registered redirect URI; otherwise ignored, never echoed |
client_id |
Names your site, so the URI is checked against your registered redirect URIs — even when the user had no session left to end |
id_token_hint |
An ID token Okia issued to your site, as an alternative to client_id. Its signature is checked — so the key that signed it must still be in the JWKS — but its expiry is not. If both are sent they must name the same client |
state |
Appended to the redirect, as with /authorize |
With neither client_id nor id_token_hint, the URI must be registered by a site the signed-in user actually holds a grant with, and a request without a session is not redirected at all.
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, their branding and domain verification |
users:read / users:write |
List, look up and read a site's end users / block, unblock and delete them |
events:read |
The audit log |
usage:read |
Metered usage |
webhooks:read / webhooks:write |
List endpoints and their deliveries / create, change, delete, test and replay them |
A request with no credential at all answers 401 unauthorized.
An API key is refused with 403 step_up_required on every route marked owner or step-up below, whatever its scopes: org settings, API keys, the team, deleting a site, rotating a client or webhook secret, and the actions that reach beyond your site (signing someone out everywhere, deleting a passkey, suspending). Those need a signed-in administrator, and step-up ones a passkey confirmed in the last five minutes — a credential that can mint credentials would make that requirement meaningless. GET /members is session-only too (403 forbidden to a key): the roster is read by a person, and a key has no person behind it.
Conventions
- Idempotency —
Idempotency-Key: <unique string>is honored on the writes below and ignored on every other route. The same key with a different request is a409 idempotency_key_reuse; a request that failed does not consume the key.- Replayed for 24 hours — a retry gets the stored
2xxback:PATCH /org,PATCH /sites/{id},PATCH /members/{id},POST /sites/{id}/users/{sub}/access,DELETE /sites/{id}/users/{sub},POST /webhooks/{id}/deliveries/{deliveryId}/replay. - Once only — a repeat is a
409, never a replay, because most of these answer with a secret or an invite link shown exactly once, which must not sit in a cache for a day:POST /sites,POST /sites/{id}/secret/rotate,POST /api-keys,POST /members,POST /members/{id}/invite,POST /members/{id}/reset-passkeys,POST /webhooks,POST /webhooks/{id}/secret/rotate.
- Replayed for 24 hours — a retry gets the stored
- 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. PassnextCursorback verbatim until it isnull. - Rate limits —
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset(seconds) on every response; over the limit is429. The protocol endpoints have their own:/oauth/tokenand/oauth/revokelock out a (client_id, IP) pair after 20 failed client authentications per minute — a wrong secret counts, a right one never does — and/oauth/tokencaps an authenticated client at 3000 requests per minute; both answer503temporarily_unavailablewith aRetry-Afterheader./authorizeaccepts 600 requests per site per minute and redirects any more to yourredirect_uriwitherror=temporarily_unavailable. - Request tracing —
X-Request-Idon every response. Include it in support requests; it is the key the server logs the failure under. - User lookup — by
sub,/v1/sites/{siteId}/users/{sub}, or by phone number,POST /v1/sites/{siteId}/users/lookup; always within one site. 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/v1only, 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 |
| POST | /sites/{id}/users/lookup |
users:read — find a user by phone number |
| GET | /sites/{id}/users/{sub} |
users:read |
| DELETE | /sites/{id}/users/{sub} |
users:write + admin — delete them from this site |
| DELETE · POST | /sites/{id}/users/{sub}/access |
users:write + admin — block / unblock on this site |
| DELETE | /sites/{id}/users/{sub}/sessions |
admin + step-up — ends their session on every site |
| 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 + step-up |
| POST | /members/{id}/invite |
owner + step-up; a new link for a pending invite |
| POST | /members/{id}/reset-passkeys |
owner + step-up; a teammate who lost every device |
| 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.
Request bodies
JSON, and strict: a field the endpoint does not know is a 400 invalid_body that names it, never silently ignored. A PATCH takes any subset of its fields.
| Endpoint | Field | Notes |
|---|---|---|
POST /sites |
name |
Required, 1–120 characters |
redirectUris |
Required, 1–20. https (plain http only for localhost), no fragment. Matched exactly at /authorize, so stored exactly as sent |
|
allowedOrigins |
Up to 20 http(s) origins, for popup and in-browser flows. Stored as origins — a path is dropped |
|
clientType |
confidential (default) or public. A public client is PKCE-only and gets no secret |
|
PATCH /sites/{id} |
name · redirectUris · allowedOrigins |
As above. clientType cannot change |
PUT /sites/{id}/branding |
logoDataUrl |
A data: URL of a PNG, JPEG or WebP, at most 200 KB. No SVG |
accent |
#rrggbb, refused when neither light nor dark text on it reaches 4.5:1 contrast. The body replaces the whole branding; {} resets it |
|
POST /sites/{id}/verification/start |
domain |
The hostname to verify. A pasted URL keeps its host; an IP address is refused |
PATCH /org |
name |
1–120 characters |
contactEmail |
Where operational alerts go; "" clears it. Never an identity |
|
POST /api-keys |
name · scopes |
1–120 characters · at least one scope from the table above |
POST /members |
role |
Required: owner, admin or viewer |
phone |
Required when role is owner (400 invalid_body without it), optional otherwise. E.164, or national format read in the region of the request's Accept-Language; a number that does not parse is 400 invalid_phone. Given, it binds the invite — any role — so only the person who signs in with that number can claim it. Only a hash of it is stored |
|
PATCH /members/{id} |
role |
As above. A pending invite cannot be changed (409 invite_pending) — revoke it and invite again — and nobody can change their own role (409 self, checked after last_owner) |
POST /webhooks |
url |
Required, https, no credentials in it |
siteId |
One of your sites, or null / absent for all of them |
|
description |
Up to 200 characters | |
eventTypes |
Up to 50 published types; empty or absent means every type, including ones published later | |
PATCH /webhooks/{id} |
url · description · eventTypes |
As above |
status |
active or disabled. Re-enabling clears the failure window |
|
POST /sites/{id}/users/lookup |
phone |
Required, one full number — see End users below |
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 three 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.
DELETE /sites/{id}/users/{sub} deletes someone from that site: the grant goes, the refresh tokens they hold for it are killed, and a user.revoked webhook names their sub. It answers { "deleted": true }. It is not a block — if they sign in again they come back as a new user of the site, with the same sub, because a site's subjects never change. A blocked user answers 409 user_blocked rather than be deleted, since deleting the grant would lift the block; unblock them first if that is what you mean.
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. Only the organization that suspended someone can lift it (409 suspended_elsewhere otherwise), and a person who administers an organization cannot be suspended, signed out or stripped of a passkey by another organization at all (409 org_member). 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.
POST /sites/{id}/users/lookup with { "phone": "…" } answers { "sub": "pw_…" } for the user of that site who signed in with the number, or 404 not_found. It matches a complete number and nothing else — there is no partial or prefix search, which would turn it into a way to discover whether a number is registered. The number may be written in E.164 (+972501234567) or as it is dialled at home (050-123-4567): a number without a country code is read in the region your Accept-Language header names (en-US → US), and as Israeli when it names none. Anything that is not a valid number is simply not found.
GET /sites/{id}/users?phone=… does the same match and answers a one-row (or empty) page. It is deprecated: a query string ends up in browser history, proxy logs and Referer headers, and a phone number belongs in none of them. It keeps working for code already built on it.
Phone numbers are returned masked (+9725•••••42) and never in full.
Events and usage
GET /events takes these filters, alone or together: type (one event type, e.g. user.login), siteId (one of your sites), and from / to (ISO 8601 timestamps, inclusive). ?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, over the last days days — a whole number from 1 to 90, default 30. 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. An owner invite must name the invitee's phone number, and only the person who signs in with that number can accept it — a forwarded link is useless to anyone else. 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, and 409 invite_needs_phone for an owner invite that names no number (revoke it and invite again with one). A re-issued link keeps its number.
POST /members/{id}/reset-passkeys deletes every passkey of a teammate who lost their devices and ends their sessions, so they sign in next with a WhatsApp code and enrol again. It answers { "reset": true, "credentialsRevoked": n }; your own passkeys are managed from your account page instead (409).
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 7807application/problem+json—type,title,status, plusrequestIdin the body andX-Request-Idon the response. The last path segment oftypeis the stable machine-readable code (step_up_required,idempotency_key_reuse,not_found, …) — branch on that, not ontitle. Aninvalid_bodytitle names the offending field and why:domain: Required,redirectUris.2: Invalid url. - OAuth endpoints (
/oauth/token,/oauth/revoke,/authorizeredirects): the formats RFC 6749 mandates.