Okia POSTs signed JSON events to your HTTPS endpoint. Configure endpoints per site (or org-wide) in the dashboard; each has a pw_whsec_… secret shown once.
Event catalog
| Event | Fires when |
|---|---|
user.enrolled |
A user completes first-time enrollment on your site |
user.login |
Successful login |
user.otp_fallback |
A user with passkeys logged in via WhatsApp code instead |
user.passkey_added |
New passkey registered |
user.passkey_revoked |
Passkey removed or revoked — affects every site |
user.quarantined |
Someone new enrolled, on your site, a phone number that had been dormant for 180+ days: the previous owner's account is set aside, and sub names them. Sent only to the site where that happened, and only if the previous owner had signed in there — see below |
user.blocked |
An administrator revoked this user's access to your site |
user.unblocked |
That access was restored |
user.revoked |
An administrator deleted this user from your site. sub names them; if they sign in again they come back as a new user with the same sub |
token.reuse_detected |
Auth-code or refresh-token replay — tokens revoked |
ping |
Test delivery from the dashboard. Delivery-only: it is not recorded as an event |
site.secret_rotated |
Client secret rotated (24h overlap window) |
Envelope
{
"id": "evt_Zr6yQ1n8kKQ0m3VUYhTz2pXcWvBd7LsA9fN4eR1oJgM",
"type": "user.enrolled",
"createdAt": "2026-08-16T12:34:56Z",
"siteId": "…",
"data": { "sub": "pw_…", "amr": ["webauthn"] }
}
id is an opaque, unique evt_… identifier. It is not ordered — use createdAt if sequence matters to you.
Signature
Every delivery carries:
Okia-Signature: t={unix},v1={hmac}
Verify it with the helper we ship — both are tested against the exact header the platform sends, and both take the raw body, before any JSON parsing.
Next.js / Node
import { verifyOkiaWebhook } from "@okia/react/server";
export async function POST(req: Request) {
const raw = await req.text();
if (!(await verifyOkiaWebhook({ headers: req.headers, rawBody: raw }, process.env.OKIA_WEBHOOK_SECRET!))) {
return new Response(null, { status: 401 });
}
const event = JSON.parse(raw);
// ack first, do the work async
return new Response(null, { status: 204 });
}
verifyOkiaWebhook(req, secret) also accepts the Request itself (it reads the body; req.clone() first if you still need it), or Express's req.headers + the buffer from express.raw({ type: "application/json" }).
WordPress / PHP
The plugin ships Okia_Webhook, which has no WordPress dependency — copy includes/class-okia-webhook.php into any PHP app.
if ( ! Okia_Webhook::verify_request( $secret ) ) {
http_response_code( 401 );
exit;
}
$event = json_decode( file_get_contents( 'php://input' ), true );
Any other language
{hmac} = HMAC-SHA256(secret, "{t}.{body}"), hex-encoded, over the raw request body (Stripe-compatible). Reject if |now − t| > 300s in either direction, or if no v1= entry matches under a constant-time compare. During a secret rotation the header carries two v1= entries for 24h — accept if any matches.
Delivery and retries
Your endpoint must return a 2xx within 10 seconds; anything else counts as a failure. Do the real work async — ack first, process after.
| Attempt | Delay after previous failure |
|---|---|
| 1 | immediate |
| 2 | 1m |
| 3 | 5m |
| 4 | 30m |
| 5 | 2h |
| 6 | 8h |
| — | then marked dead |
An endpoint failing every delivery for 3 days is auto-disabled. The site's Webhooks tab shows it as failing well before that, with the time it started — you should not first hear about it from the thing already being switched off. Dead deliveries are visible and replayable there too, for 30 days.
Idempotent consumption
Retries mean you can receive the same event more than once. Store the evt_… ULID with a unique constraint and skip duplicates:
INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING;
-- 0 rows inserted → already processed, ack and stop
Ordering is not guaranteed across retries, and the evt_… id is opaque rather than sortable — use createdAt if sequence matters.
What is not a webhook
Some things happen inside Okia that deliberately never leave it:
- Message delivery mechanics (
wa.*) — which of our WhatsApp numbers sent a code is our operational concern, not your business logic. - A signing-key rotation — the JWKS already announces it, and your library already follows it.
- Sign-out — a single sign-out reaches every site the person is signed into, and delivering that to all of them at the same instant would let two sites line up one human behind two different subject IDs. That is precisely what pairwise subjects prevent, so the event stays internal.
- A recycled number, on your other sites —
user.quarantinedgoes only to the site where the number was enrolled again, for the same reason: one event about one person, sent to every site they used at the same instant, lines their subjects up. Your other sites see it the quiet way instead — the previous owner'ssubstops carryingphone_numberon its next token refresh and at/userinfo. - Dashboard administration — invitations, API keys, site settings. Those are in your Activity log.