Skip to content

Start typing to search the documentation.

Event catalog

On this page

Self delivers a single webhook event: verification.completed. Every registered endpoint receives it; there’s no per-event subscription.

verification.completed

Fires when a verification finishes, either successfully or with a definitive failure, in both verification modes. This is the event integrations care about.

Payload

{
  "type": "verification.completed",
  "verification_id": "7f3b2a1e-9c4d-4b2a-8e1f-2c6d5a4b3c2d",
  "external_uuid": "a1b2c3d4-5678-4e9a-b012-3456789abcde",
  "flow_id": "9c0b4f1c-1d6c-4f1b-a8c4-9f0fa0a8d9e2",
  "flow_version_id": "b1e2c3d4-5678-4abc-9def-0123456789ab",
  "environment": "live",
  "status": "valid",
  "product": "pre_kyc",
  "proof_attributes": {
    "minimumAge": 18,
    "excludedCountries": ["PRK", "IRN"],
    "ofac": true,
    "nationality": "USA",
    "name": "JANE DOE"
  },
  "proof": {
    /* raw proof envelope for re-verification (see below) */
  },
  "nullifier": "0x9b1c…",
  "verified_at": "2026-05-29T17:33:21.412Z",
  "storage_state": "pending",
  "storage_uri": null
}

Fields

FieldTypeNotes
verification_idstringStable, unique. Use as your dedup key.
external_uuidstringThe identifier you passed when creating the session (any string up to 256 chars, echoed verbatim).
flow_id / flow_version_idstringWhich flow + frozen version this verification ran against.
environment'test' | 'live'Matches the API key environment that created the session.
status'valid' | 'invalid' | 'error' | 'expired'See statuses below.
reasonstring (optional)Failure cause, present only on non-valid statuses. Human-readable, not an enum: log it, don’t branch on it.
productstring (optional)Which workspace the flow belongs to (pre_kyc, age_verification, proof_of_human, sovereign, custom_config). Lets a handler on a shared org endpoint route per workspace. Omitted on events from before the field existed.
proof_attributesobjectThe flow’s resolved predicate config that was enforced, echoed back: the same keys you set in the dashboard: minimumAge, excludedCountries, includedCountries, ofac (only the ones your flow uses). These are not disclosed booleans; a valid status is what tells you every predicate passed. On a valid event this object also carries the disclosed reveal values for any Additional-data or Custom Config fields the flow requested (keyed name, idNumber, dateOfBirth, gender, nationality, expiryDate, issuingState), included only when the holder returned a non-empty value (predicate and reveal keys never collide). Non-valid statuses carry the predicate config only. Empty object if the flow had no predicates and requested no reveals.
proofobject | nullThe raw submitted proof envelope (attestationId, proof, publicSignals, userContextData). Currently populated on every status, including invalid/error/expired (it’s whatever the holder submitted). Most integrations ignore it. Self already verified the proof.
nullifierstring | nullUniqueness identifier, stable per person within your organization. Present when status === 'valid', null otherwise. See nullifiers.
verified_atISO-8601When verification completed.
storage_state'pending' | 'committed' | 'failed' | 'skipped'pending on most events: the completed event fires before async storage runs; for the final state, read the session with sessions.get(...). skipped means no storage will ever happen: Custom Config verifications are under org custody, so this delivery is the sole permanent record: persist what you need now. (On-chain sessions also report 'skipped' via sessions.get(...): the chain is the record.)
storage_uristring | nullAlways null on this event. The committed URI, once available, is returned by sessions.get(...), not the webhook.

Org custody (Custom Config)

Events from a Custom Config flow carry product: 'custom_config' and storage_state: 'skipped', and they change the handling contract in one important way: the delivery is the only copy. Self stores neither the proof nor the disclosed fields, no storage ever runs, and sessions.get(...) has nothing extra to return later. Write the disclosed proof_attributes values (and proof, if you keep it) to your own storage before acking with 2xx. Deliveries retry for days if your endpoint is down, but a 2xx you didn’t persist is data gone for good.

On-chain fields

Verifications from an on-chain flow carry up to three extra fields. They are omitted entirely (not null) on backend-mode events, so existing handlers keep working unchanged:

FieldTypeNotes
verification_mode'backend' | 'onchain'Which mode the flow verifies in. Omitted on events from before the field existed; treat absence as backend.
tx_hashstringThe on-chain transaction, always present on a valid on-chain event (the mint transaction).
contract_addressstringThe flow’s SBT contract on Celo for that environment.

On on-chain events, proof_attributes is an empty object: the rules are enforced by the contract, not evaluated by Self’s servers, so there’s nothing to echo back. Branch on status as usual.

Statuses

  • valid: the proof verified and all flow predicates passed. This is the green-light case.
  • invalid: the proof verified but at least one predicate failed (e.g. user is 16, flow requires 18).
  • error: verification failed for a technical reason (unsupported document, malformed proof, signature mismatch). Not the user’s fault necessarily.
  • expired: session expired before completion.

Non-valid events may carry a human-readable reason string, but there is no machine-readable failure code: an invalid or error event tells you that it failed, not which predicate in enumerable form. Log reason for diagnosis; if you need to branch on cases (e.g. “underage” vs. “excluded country”), branch on the proof_attributes your flow enforced, or read the session with sessions.get(...).

Event ID (for deduplication)

The deduplication key is <verification_id>-completed (the same for every status, so a retry of this event reuses it).

Handling in TypeScript

The payload is typed as WebhookEvent; branch on event.type to narrow it. verification.completed is the event delivered:

import type { WebhookEvent } from '@selfxyz/enterprise-sdk';

function handle(event: WebhookEvent) {
  if (event.type === 'verification.completed') {
    // narrowed to VerificationCompletedPayload
    // event.verification_id, event.external_uuid, event.proof_attributes, event.status
  }
}

Forward compatibility

We may add fields to existing events without bumping major versions. The SDK’s webhookEvent schema uses .passthrough(), so unknown fields are preserved (and ignored by your existing code). We may also add new event types over time, so branch on event.type and let your handler ignore any type it doesn’t recognize.

Was this page helpful?