Webhooks

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 Account quarantined (recycled-number fork, ≥180d dormant)
user.blocked An administrator revoked this user's access to your site
user.unblocked That access was restored
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.
  • Dashboard administration — invitations, API keys, site settings. Those are in your Activity log.
Webhooks · Okia docs