Skip to content

Checking access...

KYC API Reference ​

Version: 1.0.0 Date: 2026-04-20 Services: oracle-bridge (adapter + VC bridge) · membership canister (on-chain attestation) · marketing-suite (KycWidget component) Repositories: Hello-World-Co-Op/oracle-bridge · Hello-World-Co-Op/membership · Hello-World-Co-Op/marketing-suiteEpic: KYC-001 (identity verification — completed 2026-04-15)


Overview ​

KYC identity verification lives on three cooperating layers:

  1. oracle-bridge adapter (KYC-001.1) — provider-agnostic REST surface at /api/kyc/*. Wraps the active KYC provider (Didit primary, iDenfy hot backup) behind a single KycProvider interface, verifies provider webhooks via HMAC-SHA256, and persists every outcome to PostgreSQL.
  2. Verifiable Credential bridge (KYC-001.2) — on every APPROVED webhook, oracle-bridge builds a W3C VC Data Model v1.1 credential with an Ed25519Signature2020 proof, hashes the canonical JSON (without proof) with SHA-256, and writes the 32-byte hash to the on-chain membership canister via set_kyc_attestation. The full VC (including PII claims) stays off-chain in Postgres; only the hash + metadata lives on-chain.
  3. Frontend widget (KYC-001.3) — KycWidget React component in marketing-suite embeds the Didit/iDenfy inline verification flow as a sandboxed iframe, listens for provider postMessage completion events, then polls oracle-bridge's status endpoint until the webhook lands a terminal status (APPROVED / DENIED / REVIEW).

Key Properties ​

  • Provider-agnostic — the KycProvider interface abstracts Didit and iDenfy (+ reserved slot for blockpass). Status normalization collapses every raw vendor string into one of four values: PENDING | APPROVED | DENIED | REVIEW.
  • PII stays off-chain — names, dates of birth, and document numbers remain with the provider and, optionally, in oracle-bridge's kyc_attestations.vc_json column. The on-chain KycAttestation record carries only a 32-byte SHA-256 hash, issuer / subject principals, provider short name, level, and timestamps.
  • Signed credentials — every VC is signed with oracle-bridge's existing Ed25519 identity (PRIV_KEY_B64). The verification method on the proof references that principal, so any third-party holding the off-chain VC can recompute the canonical hash and verify the signature independently.
  • Fail-closed webhooks — the POST /api/kyc/webhook route is rate-limited before signature verification (cheap DoS protection) and rejects any request lacking a matching HMAC-SHA256 digest.
  • Idempotent canister writes — set_kyc_attestation replaces in place and is keyed by subject principal. The same VC may be rewritten (same hash, new issuance timestamp) without side effects.
  • Graceful degradation — if the membership canister does not expose set_kyc_attestation yet (e.g., an older build), oracle-bridge still persists the DB row and marks canister_synced=false. The admin retry endpoint re-drives these rows once the canister catches up.
  • Replay-safe admin retries — /admin/retry-unsynced and /admin/retry-attestations walk canister_synced=false rows and replay the canister call. Already-verified membership returns (e.g., already_verified) are treated as idempotent success.

Current Status ​

Stable (KYC-001.1 through .3, completed 2026-04-15):

  • POST /api/kyc/session — start a Didit / iDenfy verification session
  • GET /api/kyc/status/:userId — latest status for the caller or (admin) any user
  • GET /api/kyc/attestation[?user_id=] — latest live attestation metadata for the caller or (admin) any user
  • GET /api/kyc/attestation/by-principal/:principal — admin-only inspection by IC principal
  • POST /api/kyc/webhook — provider callback (HMAC-SHA256 signed, raw-body parser)
  • POST /api/kyc/admin/retry-unsynced — replay approved verifications whose canister call failed
  • POST /api/kyc/admin/retry-attestations — replay live attestations whose canister sync failed
  • membership canister: set_kyc_attestation, get_kyc_attestation, revoke_kyc_attestation, is_kyc_verified
  • marketing-suite: /kyc-verification page + KycWidget component with iframe embed, postMessage listener, and status polling

Pending / out of scope for KYC-001:

  • blockpass provider (reserved in the provider-short-name CHECK constraint; no implementation)
  • Automatic re-verification after expires_at_ns elapses — consumers today check is_kyc_verified per call
  • Self-service user re-submission flow after REVIEW — today a REVIEW outcome requires admin action

Full Lifecycle Sequence ​

mermaid
sequenceDiagram
    autonumber
    participant Browser
    participant MS as marketing-suite (KycWidget)
    participant OB as oracle-bridge
    participant P as KYC provider (Didit)
    participant DB as OVH Postgres
    participant MEM as membership canister

    Browser->>MS: visit /kyc-verification (post-email-verify)
    MS->>OB: GET /api/auth/session (cookie)
    OB-->>MS: { authenticated: true, user_id }
    MS->>MS: mount KycWidget(userId, callbackUrl)

    MS->>OB: POST /api/kyc/session<br/>(session cookie + CSRF)<br/>{ callback_url }
    OB->>P: POST /v2/session/<br/>{ callback, vendor_data: {user_id, ic_principal} }
    P-->>OB: { session_id, session_url, expires_at }
    OB->>DB: INSERT kyc_verifications (status='PENDING', raw_payload)
    OB-->>MS: { session_id, session_url, provider: "didit", expires_at }
    MS->>MS: mount iframe src=session_url (sandboxed)

    Browser->>P: complete verification in iframe
    P->>MS: postMessage<br/>{ type: "didit.session.completed" }
    MS->>MS: verify event.origin matches iframe.origin
    MS->>MS: transition ready → polling

    P->>OB: POST /api/kyc/webhook<br/>(X-Signature-V2 HMAC-SHA256)<br/>raw body
    OB->>OB: rate limit → raw body parser →<br/>HMAC-SHA256 compare (constant-time)
    OB->>DB: UPSERT kyc_verifications (status='APPROVED')
    OB->>MEM: set_kyc_verified(principal)<br/>(legacy bridge — best-effort)
    OB->>OB: buildSignedVc → SHA-256 canonical JSON
    OB->>DB: UPSERT kyc_attestations (vc_json, attestation_hash, canister_synced=false)
    OB->>MEM: set_kyc_attestation(issuer, subject, hash, provider, level, issued_at_ns, expires_at_ns)
    MEM-->>OB: { Ok }
    OB->>DB: UPDATE kyc_attestations SET canister_synced=true
    OB-->>P: 200 { received: true, status: "APPROVED", attestation_synced: true }

    loop poll every 3s up to 30 polls
        MS->>OB: GET /api/kyc/status/:userId
        OB-->>MS: { status: "APPROVED" | "PENDING" | ... }
    end
    MS->>MS: transition polling → approved, fire onSuccess
    MS->>Browser: redirect to dao-suite /membership payment

Authentication ​

The KYC surface has three distinct authentication modes, one per route class.

Session + CSRF (browser-facing) ​

POST /api/kyc/session, GET /api/kyc/status/:userId, GET /api/kyc/attestation.

  • Auth: oracle-bridge session cookie (httpOnly, established by the /login flow) + X-CSRF-Token double-submit header.
  • Middleware order: requireSessionAuth runs before kycRateLimit so unauthenticated floods fail at 401 and do not consume an authenticated user's rate budget (AI-R211).
  • Authorization: users may only read their own status / attestation. An admin caller (session userRoles includes "admin") may read any user_id via the optional query or path parameter; a non-admin attempting to access another user gets 403 Forbidden.

Admin session (controller surface) ​

POST /api/kyc/admin/retry-unsynced, POST /api/kyc/admin/retry-attestations, GET /api/kyc/attestation/by-principal/:principal.

  • Auth: requireSessionAdminAuth — same session cookie, but the middleware additionally asserts the session carries an admin role.
  • Non-admin sessions get 403 without any DB query.

HMAC signature (provider webhook) ​

POST /api/kyc/webhook.

  • Auth: provider-signed HMAC-SHA256 over the raw request body. The signature IS the auth mechanism — no session cookie is required, and Didit / iDenfy servers cannot forward one.
  • Didit header: X-Signature-V2 (falls back to X-Signature for legacy deliveries).
  • iDenfy header: Idenfy-Signature.
  • Constant-time comparison — buffer lengths are checked before crypto.timingSafeEqual so the comparison never throws on length mismatch and never leaks secret length via timing.
  • Raw body parser — the webhook router is registered with express.raw({ type: '*/*', limit: '1mb' }) and mounted before express.json() in src/index.ts. Reordering breaks signature verification 100% of the time (same lesson as Stripe webhooks — see docs/api/payment-gateway.md).
  • Rate limit before signature check — kycRateLimit (IP-keyed) runs ahead of the raw body parser so adversaries cannot burn CPU or DB connections by flooding the public webhook URL.

Provider Abstraction ​

All providers implement the shared KycProvider interface:

typescript
export interface KycProvider {
  readonly name: KycProviderName;   // 'didit' | 'idenfy'

  createVerificationSession(req: CreateSessionRequest): Promise<VerificationSession>;
  handleWebhook(req: WebhookRequestLike): Promise<WebhookResult>;
  getVerificationStatus(sessionId: string): Promise<{
    sessionId: string;
    status: KycStatus;
    providerRawStatus: string;
  }>;
}

Provider selection is a single env var:

Env VarDefaultValues
KYC_PROVIDERdiditdidit · idenfy

The factory caches one instance per process. Tests call resetKycProviderCache() or __setKycProviderForTests(mock) to swap providers between cases.

Status Normalization ​

Every raw provider status collapses into one of four canonical values before crossing module boundaries:

typescript
export type KycStatus = 'PENDING' | 'APPROVED' | 'DENIED' | 'REVIEW';

Didit (src/kyc/providers/didit.ts, DIDIT_STATUS_MAP):

Raw statusNormalized
Not Started, In Progress, PENDING, not_started, in_progressPENDING
Approved, approved, APPROVEDAPPROVED
Declined, declined, DECLINEDDENIED
In Review, Expired, in_review, expiredREVIEW
any other stringREVIEW (fail-closed — never silent-approve)

iDenfy (src/kyc/providers/idenfy.ts, IDENFY_STATUS_MAP):

Raw statusNormalized
PENDING, ACTIVEPENDING
APPROVEDAPPROVED
DENIEDDENIED
SUSPECTED, REVIEWING, EXPIRED, DELETEDREVIEW
any other stringREVIEW

Both maps are frozen (Object.freeze) so runtime tampering throws. The fall-through to REVIEW is intentional: a human reviewing the audit log is always safer than a silent approval for an unknown vendor string.


REST API Reference ​

Session ​

POST /api/kyc/session ​

Create a new verification session with the active provider.

Auth: session cookie + X-CSRF-Token. Rate-limited (kycRateLimit).

Request body (all fields optional):

jsonc
{
  "callback_url": "https://helloworlddao.com/kyc-verification"
  // defaults to `${cfg.frontendUrl}/kyc/callback` when omitted
}

Response (200):

json
{
  "session_id": "abc123-provider-session",
  "session_url": "https://verify.didit.me/s/abc123...",
  "provider": "didit",
  "expires_at": "2026-04-20T23:59:59Z"
}

The frontend passes session_url directly into an iframe src. oracle-bridge stashes the provider's session in kyc_verifications with status='PENDING' and raw_payload capturing the full provider response for audit.

Errors:

StatusBodyCause
401{ error: "Unauthorized" }No active session
403CSRF middleware rejectionX-CSRF-Token absent or invalid
429Rate limit exceededkycRateLimit tripped
500{ error: "Failed to create KYC session", user_friendly_message }Provider HTTP failure or misconfiguration

GET /api/kyc/status/:userId ​

Fetch the latest verification row for a user.

Auth: session cookie + X-CSRF-Token. Admins may query any user_id; non-admins only their own.

Response — no prior session (200):

json
{
  "user_id": "9b3...",
  "status": null,
  "provider": null,
  "updated_at": null
}

Response — existing row (200):

json
{
  "user_id": "9b3...",
  "status": "APPROVED",
  "provider": "didit",
  "session_id": "abc123-provider-session",
  "updated_at": "2026-04-20T18:45:12.345Z"
}

Errors: 401 Unauthorized, 403 Forbidden (user tried to read another user's status without admin role), 500 Failed to fetch KYC status.


Attestation ​

GET /api/kyc/attestation ​

Return the latest live attestation metadata (VC JSON + 32-byte hash + lifecycle flags) for the caller. Admins may pass ?user_id=<uuid> to query any user.

Auth: session cookie + X-CSRF-Token.

Response (200):

jsonc
{
  "id": "94f...",
  "user_id": "9b3...",
  "ic_principal": "aaaaa-aa...-cai",
  "provider": "didit",
  "level": "standard",
  "session_id": "abc123-provider-session",
  "vc": { /* full signed W3C VC JSON */ },
  "attestation_hash_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "issued_at": "2026-04-20T18:45:12.345Z",
  "expires_at": "2027-04-20T18:45:12.345Z",
  "canister_synced": true,
  "revoked_at": null
}

Errors: 401 Unauthorized, 403 Forbidden, 404 No KYC attestation found, 500 Failed to fetch KYC attestation.


GET /api/kyc/attestation/by-principal/:principal ​

Admin-only — fetch an attestation by IC principal (useful when an operator has the principal but not the user UUID).

Auth: admin session.

Response: same shape as GET /api/kyc/attestation.

Errors: 400 principal required, 401, 403 (non-admin), 404 No KYC attestation found, 500.


Webhook ​

POST /api/kyc/webhook ​

Receive provider callbacks and drive the membership + attestation bridges.

Auth: provider HMAC signature (see Authentication). Public to the provider — this route MUST be reachable from the Didit / iDenfy egress IP range.

Content-Type: application/json, parsed as raw Buffer (limit 1 MiB).

Flow:

  1. kycRateLimit — IP-keyed; sheds floods before signature CPU spend.
  2. express.raw — produces the raw bytes the HMAC covers.
  3. provider.handleWebhook({ rawBody, headers }) — verifies signature, parses the body, returns a normalized WebhookResult.
  4. upsertVerificationStatus — insert or update kyc_verifications keyed on (session_id). raw_payload carries the denormalized icPrincipal so the admin retry endpoint can replay without re-parsing provider-specific fields.
  5. If normalizedStatus === 'APPROVED' && icPrincipal: a. applyKycApprovalToMembership(icPrincipal) — best-effort legacy bridge that flips an older kyc_verified flag on the membership canister. b. applyKycAttestationToMembership({ userId, subjectPrincipalText, provider, level: 'standard', sessionId }) — builds and signs the VC, upserts kyc_attestations, calls set_kyc_attestation on the canister.
  6. Respond 200 regardless of canister-write outcome — the DB row is authoritative, and failures surface through admin retries.

Response — success (200):

json
{
  "received": true,
  "status": "APPROVED",
  "provider": "didit",
  "session_id": "abc123-provider-session",
  "membership_updated": true,
  "attestation_synced": true,
  "attestation_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}

Errors:

StatusBodyCause
400{ error: "Webhook body is not valid JSON" | "Webhook missing session_id" | "Webhook missing status" }MalformedWebhookPayloadError from the provider adapter
401{ error: "Missing X-Signature-V2 header" | "Invalid webhook signature" }InvalidWebhookSignatureError
429Rate limit exceededkycRateLimit tripped
500{ error: "KYC provider not configured" | "DB persistence failed" | "Webhook processing failed" }Config / Postgres / uncaught error. Provider SHOULD retry — duplicate webhook bodies are idempotent at the DB layer via UPSERT on session_id.

Admin Retries ​

POST /api/kyc/admin/retry-unsynced ​

Replay APPROVED verifications whose membership canister call failed (flag canister_synced=false). Idempotent.

Auth: admin session.

Request body (optional):

json
{ "limit": 100 }

Response (200):

json
{
  "scanned": 3,
  "succeeded": 2,
  "stillFailed": 0,
  "skippedNoPrincipal": 1,
  "entries": [
    { "sessionId": "sess1", "status": "synced" },
    { "sessionId": "sess2", "status": "synced", "alreadyVerified": true },
    { "sessionId": "sess3", "status": "skipped_no_principal",
      "error": "Row has no IC principal to replay; needs manual reconciliation" }
  ]
}

The alreadyVerified flag is set when the membership canister returns one of the idempotent-success errors (kyc_already_verified, already_verified, member_already_kyc_verified). These rows are flipped to canister_synced=true and never re-queued.

Errors: 401, 403, 500 Failed to retry unsynced KYC approvals.


POST /api/kyc/admin/retry-attestations ​

Replay attestation rows whose set_kyc_attestation call failed. Idempotent — the canister replaces in place, so a retry with the same hash is a no-op.

Auth: admin session.

Request body (optional): { "limit": 100 }.

Response (200):

json
{
  "scanned": 2,
  "succeeded": 2,
  "stillFailed": 0,
  "entries": [
    {
      "icPrincipal": "aaaaa-aa",
      "status": "synced",
      "hashHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
    }
  ]
}

Unlike the verification retry, this endpoint does NOT reconstruct the original VC bytes. It rebuilds a fresh VC (new issuanceDate, same claims) and re-calls set_kyc_attestation — the canister keys by subject principal, so the replay is idempotent from the on-chain perspective.


KYC-001.2 — Verifiable Credential Bridge ​

W3C VC Data Model v1.1 ​

Every APPROVED webhook produces a signed credential shaped as:

jsonc
{
  "@context": ["https://www.w3.org/2018/credentials/v1"],
  "type": ["VerifiableCredential", "KycCredential"],
  "issuanceDate": "2026-04-20T18:45:12.345Z",
  "expirationDate": "2027-04-20T18:45:12.345Z",
  "credentialSubject": {
    "id": "aaaaa-aa...-cai",
    "kycVerified": true,
    "provider": "didit",
    "level": "standard",
    "verificationDate": "2026-04-20T18:45:12.345Z",
    "kycSessionId": "abc123-provider-session"
  },
  "issuer": "<oracle-bridge principal>",
  "proof": {
    "type": "Ed25519Signature2020",
    "created": "2026-04-20T18:45:12.345Z",
    "verificationMethod": "<oracle-bridge principal>#oracle-bridge-ed25519",
    "proofPurpose": "assertionMethod",
    "proofValue": "<base64 Ed25519 signature>",
    "publicKeyBase64": "<oracle-bridge Ed25519 pubkey>"
  }
}

Canonical JSON + Hash ​

The signing input (and the on-chain hash input) is the canonical JSON of the credential without the proof field. src/kyc/canonical-json.ts produces deterministic output: keys are sorted recursively, no insignificant whitespace, no trailing commas. The same bytes are reproduced by any verifier that follows the same algorithm.

canonicalSigningInput = canonicalStringify(vc_without_proof)
signature             = ed25519_sign(PRIV_KEY_B64, canonicalSigningInput)
hash                  = SHA-256(canonicalSigningInput)   // exactly 32 bytes

The defensive check in buildSignedVc throws if the hash is any length other than 32 — SHA-256 always returns 32 bytes, but the on-chain validator rejects anything else, so we fail loudly rather than silently.

Default TTL ​

applyKycAttestationToMembership defaults to ttlMs = 365 * 24 * 60 * 60 * 1000 (one year) when the caller omits it. This matches the industry-standard KYC re-verification cadence. Pass ttlMs: null to issue a credential with no expiry (stored on-chain as expires_at_ns = 0).

Third-Party Verification ​

A verifier that holds the off-chain VC and the on-chain KycAttestation.attestation_hash can prove integrity without trusting oracle-bridge:

  1. Strip the proof field from the stored VC.
  2. Run canonicalStringify over the remainder (same algorithm oracle-bridge used).
  3. Compute SHA-256 of the canonical bytes.
  4. Compare against attestation_hash on-chain.
  5. Independently verify proof.proofValue is a valid Ed25519 signature over the same bytes using proof.publicKeyBase64.

recomputeHashFromStoredVc(vcJson) in src/kyc/vc-builder.ts performs steps 1–3 for TypeScript callers.


Membership Canister Interface ​

The membership canister stores KycAttestation records keyed by subject principal. No PII lives on-chain — only the 32-byte hash and metadata.

Candid Type ​

candid
type KycAttestation = record {
  issuer : principal;          // oracle-bridge principal (signer)
  subject : principal;         // member IC principal
  attestation_hash : blob;     // SHA-256(canonical_json(vc_without_proof)) — exactly 32 bytes
  provider : text;             // "didit" | "idenfy" | "blockpass"
  level : text;                // "standard" | "enhanced" | "accredited_investor"
  issued_at_ns : nat64;
  expires_at_ns : nat64;       // 0 = no expiry
  revoked : bool;
};

set_kyc_attestation ​

Insert or replace a KYC attestation for a member principal.

Signature:

candid
set_kyc_attestation : (KycAttestation) -> (variant { Ok; Err : text });

Access: oracle-bridge principal only. The canister gates the call with require_oracle_bridge(caller), which checks the caller against get_oracle_bridge() (set via set_oracle_bridge — see the Governance API for the shared admin-auth pattern).

Additional checks:

  • attestation.issuer MUST equal caller (prevents spoofing even from the authorized principal).
  • subject MUST NOT be the anonymous principal.
  • attestation_hash.len() == 32 (SHA-256 size — any other length rejects with a descriptive error).
  • provider, level non-empty strings (CHECK constraints in the off-chain DB use an enumerated set; the canister only requires non-empty).
  • issued_at_ns != 0.
  • expires_at_ns == 0 || expires_at_ns > issued_at_ns.

Semantics: last-write-wins. Replay of the same VC overwrites the row with a fresh issuance timestamp — safe and idempotent.

Errors:

Err messageCause
Unauthorized: attestation.issuer must equal the calling oracle-bridge principalCaller is authorized but the issuer field was spoofed
Invalid attestation: subject must not be anonymoussubject = Principal::anonymous()
Invalid attestation_hash length: got N, expected 32 (SHA-256)Hash is not exactly 32 bytes
Invalid attestation: provider must not be emptyEmpty provider string
Invalid attestation: level must not be emptyEmpty level string
Invalid attestation: issued_at_ns must be non-zeroissued_at_ns == 0
Invalid attestation: expires_at_ns must be 0 (no expiry) or greater than issued_at_nsInvalid expiry relation
Any require_oracle_bridge rejectionCaller is not the configured oracle-bridge principal

get_kyc_attestation ​

Public query returning the hash + metadata for a principal, or None if no attestation exists.

Signature:

candid
get_kyc_attestation : (principal) -> (opt KycAttestation) query;

Access: public. Returns no PII — the response is safe to log and expose through any caller.


revoke_kyc_attestation ​

Controller-only revocation — sets revoked = true in place without deleting the row. Used for emergency regulatory action (e.g., the provider notifies us that the underlying identity document was a forgery).

Signature:

candid
revoke_kyc_attestation : (principal) -> (variant { Ok; Err : text });

Access: canister controllers only (require_controller). oracle-bridge cannot revoke — that is an intentional separation of duties.

Error: No KYC attestation found for <principal> when the principal has never been attested.


is_kyc_verified ​

Public query helper — returns true iff the principal has an attestation AND the attestation is not revoked AND the attestation has not expired (with expires_at_ns == 0 meaning "no expiry"). Downstream canisters (governance, treasury, marketplace) use this helper to keep the expiry / revocation logic in one place.

Signature:

candid
is_kyc_verified : (principal) -> (bool) query;

Access: public.

rust
// Canonical predicate (src/lib.rs:674)
fn is_kyc_verified(principal: Principal) -> bool {
    let now = ic_cdk::api::time();
    match state.kyc_attestations().get(&principal) {
        None => false,
        Some(entry) => {
            if entry.revoked { return false; }
            if entry.expires_at_ns != 0 && now >= entry.expires_at_ns { return false; }
            true
        }
    }
}

Consumers should never duplicate this logic inline — inconsistent now >= vs now > or different revoked-first ordering is a real-world source of defects.


KYC-001.3 — Frontend Widget ​

Component Shape ​

typescript
import { KycWidget } from '@/components/KycWidget';

<KycWidget
  userId={session.userId}              // required — oracle-bridge UUID
  callbackUrl="https://.../kyc-verification"   // required — provider metadata
  onSuccess={(status) => { /* APPROVED */ }}
  onFailure={(reason, status) => { /* DENIED | REVIEW | null */ }}
  supportUrl="mailto:support@helloworlddao.com" // optional
  pollIntervalMs={3000}                 // optional — default 3s
  maxPolls={30}                         // optional — default 30 (≈90s)
  fetchImpl={fetch}                     // optional — test injection
/>

State Machine ​

loading ──────────► ready ──────► polling ──► approved ──► onSuccess
  │                   │              │            │
  │                   │              │            └──► (terminal)
  │                   │              │
  │                   │              └──────► rejected ──► onFailure
  │                   │                          │
  │                   │                          └──► retry → loading
  │                   │
  │                   └──────► rejected (provider failure postMessage)
  │
  └──────► error (session creation failure) ──► retry → loading
StateTriggerNext
loadingInitial mountready on session success, error on failure
readySession created; iframe mountedpolling on completion postMessage; rejected on failure postMessage
pollingProvider emitted a completion event; backend webhook expectedapproved / rejected / error (max-polls exhausted)
approvedBackend returned APPROVEDTerminal — fires onSuccess exactly once
rejectedBackend returned DENIED or REVIEW, or provider emitted a failure eventTerminal — fires onFailure exactly once; shows retry CTA
errorSession creation or status poll threwShows retry CTA that returns to loading

Duplicate fires are guarded by a fired ref so React Strict Mode double-renders (dev) and late poll responses do not double-invoke onSuccess / onFailure.

Security Model ​

  • iframe URL from backend only — the widget reads session_url from the POST /api/kyc/session response. It never trusts a session_url from URL parameters or postMessage.
  • iframe sandbox — sandbox="allow-scripts allow-same-origin allow-forms allow-popups". Matches Didit's embed requirements without granting top-navigation rights.
  • postMessage origin check — events are accepted only after event.origin === iframe.origin. Foreign origins (analytics, chat widgets on the same page) are silently dropped.
  • Completion event allowlist — didit.session.completed, didit.verification.completed, didit.session.submitted, idenfy.verification.completed, idenfy.session.finished, plus kyc.completed / kyc.finished generic fallbacks. Unknown type values are ignored — the backend webhook is the authoritative decision point.
  • Failure event allowlist — didit.session.failed, idenfy.verification.failed, kyc.failed. These skip the poll loop and go straight to rejected.

Accessibility ​

  • iframe has title="Identity verification" and a descriptive aria-label.
  • Status region uses role="status" (non-critical updates) / role="alert" (errors and rejections) for screen reader announcements.
  • Retry button receives focus automatically on error / rejected transitions.
  • ESC inside the iframe does not dismiss the flow — the user must click the retry / support CTAs to ensure verification is not accidentally abandoned.

Example Page (/kyc-verification) ​

tsx
import KycWidget from '@/components/KycWidget';

export default function KycVerification() {
  const [session, setSession] = useState<SessionSnapshot | null>(null);
  const oracleBaseUrl = import.meta.env.VITE_ORACLE_BRIDGE_URL;
  const daoSuiteUrl = import.meta.env.VITE_DAO_SUITE_URL;

  // Resolve the signed-in user first...
  useEffect(() => { /* GET /api/auth/session with credentials: include */ }, []);

  if (!session?.authenticated) return <SignInPrompt />;

  return (
    <KycWidget
      userId={session.userId}
      callbackUrl={`${window.location.origin}/kyc-verification`}
      onSuccess={() => { window.location.href = `${daoSuiteUrl}/membership`; }}
      onFailure={(reason) => { console.warn('[KycVerification] failure:', reason); }}
    />
  );
}

See marketing-suite/src/pages/KycVerification.tsx for the production version (adds privacy-policy footer + sign-in error states).


Database Schema ​

Two tables back the KYC surface. Both live in oracle-bridge's PostgreSQL database. Migrations: migrations/017_kyc_verifications.sql, 018_kyc_canister_sync.sql, 019_kyc_attestations.sql.

kyc_verifications (migration 017 + 018) ​

One row per verification session. Updated by POST /api/kyc/session (INSERT) and POST /api/kyc/webhook (UPSERT on session_id).

ColumnTypeNotes
idUUIDPrimary key, uuid_generate_v4()
user_idTEXT NOT NULLoracle-bridge user UUID — indexed
providerTEXT NOT NULLCHECK IN ('didit', 'idenfy')
session_idTEXT NOT NULL UNIQUEProvider session id — the webhook dedup key
statusTEXT NOT NULLCHECK IN ('PENDING', 'APPROVED', 'DENIED', 'REVIEW')
raw_payloadJSONB NOT NULLProvider response + icPrincipal denormalized for replay
canister_syncedBOOLEAN NOT NULL DEFAULT false(migration 018) true once applyKycApprovalToMembership succeeds
created_at / updated_atTIMESTAMPTZ NOT NULL DEFAULT NOW()

Indexes: user_id, session_id, status, created_at DESC.

kyc_attestations (migration 019) ​

One live row per IC principal, plus zero or more revoked rows retained for audit.

ColumnTypeNotes
idUUIDPrimary key
user_idTEXToracle-bridge UUID (nullable — some flows only have a principal)
ic_principalTEXT NOT NULLMember IC principal
providerTEXT NOT NULLCHECK IN ('didit', 'idenfy', 'blockpass')
levelTEXT NOT NULLCHECK IN ('standard', 'enhanced', 'accredited_investor')
session_idTEXTSource provider session (nullable — admin-issued attestations can omit)
vc_jsonJSONB NOT NULLFull signed W3C VC (including proof)
attestation_hashBYTEA NOT NULLCHECK octet_length = 32 — matches the 32-byte canister field
issued_atTIMESTAMPTZ NOT NULLSource-of-truth issuance timestamp
expires_atTIMESTAMPTZNullable — matches expires_at_ns == 0 on-chain
canister_syncedBOOLEAN NOT NULL DEFAULT falsetrue after set_kyc_attestation succeeds
revoked_atTIMESTAMPTZNon-null marks revocation; row retained for audit

Invariants enforced by indexes:

  • uq_kyc_attestations_live_principal — UNIQUE (ic_principal) WHERE revoked_at IS NULL — at most one live attestation per principal.
  • idx_kyc_attestations_unsynced — (canister_synced, created_at) WHERE canister_synced = false AND revoked_at IS NULL — hot-path index for the admin retry endpoint.

The UPSERT in upsertAttestation first soft-revokes any existing live row for the principal (sets revoked_at = NOW()), then inserts a fresh live row. This preserves history while enforcing the one-live-per-principal invariant without a transactional deadlock window.


Environment Variables ​

All secret values must come from the k8s Secret kyc-secrets in the oracle-bridge namespace (or the VPS .env.staging / .env.production files pre-PLATFORM-006). Env var names only below — never commit values.

oracle-bridge ​

Env VarRequiredNotes
KYC_PROVIDERNodidit (default) | idenfy. Unsupported values throw at first factory call.
DIDIT_API_KEYYes when KYC_PROVIDER=diditSent as x-api-key on session creation
DIDIT_API_SECRETNoReserved for future Didit v3 auth
DIDIT_WEBHOOK_SECRETYes when KYC_PROVIDER=diditHMAC-SHA256 secret for X-Signature-V2 verification
DIDIT_API_BASE_URLNoDefault https://verification.didit.me
DIDIT_WORKFLOW_IDNoOptional workflow id for multi-workflow Didit accounts
IDENFY_API_KEYYes when KYC_PROVIDER=idenfyBasic-auth username
IDENFY_API_SECRETYes when KYC_PROVIDER=idenfyBasic-auth password
IDENFY_WEBHOOK_SECRETYes when KYC_PROVIDER=idenfyHMAC-SHA256 secret for Idenfy-Signature verification
IDENFY_API_BASE_URLNoDefault https://ivs.idenfy.com
PRIV_KEY_B64YesBase64 Ed25519 private key — shared with other oracle-bridge signers (payment-gateway, ICP/DOM). Derives the issuer principal on the VC.
MEMBERSHIP_CANISTER_IDYesStaging / production id for the membership canister
IC_HOSTNoDefault https://icp0.io. Local PocketIC: http://127.0.0.1:4943

marketing-suite ​

Env VarNotes
VITE_ORACLE_BRIDGE_URLBase URL for session + status + attestation calls. Local: http://localhost:3000. Staging: https://oracle.staging.helloworlddao.com.
VITE_DAO_SUITE_URLWhere /kyc-verification redirects on APPROVED. Example: https://staging-portal.helloworlddao.com.
VITE_THINK_TANK_URLSign-in redirect target when the session is missing.

Error Reference ​

All oracle-bridge KYC responses use the shape { error: "<phrase>" [, user_friendly_message: "..."] }.

HTTPEndpoint(s)errorCause
400/webhookWebhook body is not valid JSONRaw body did not parse as JSON
400/webhookWebhook missing session_id | Webhook missing statusRequired field absent
400/attestation/by-principal/:principalprincipal requiredEmpty path parameter
401/session, /status, /attestation, /admin/*UnauthorizedSession cookie absent or expired
401/webhookMissing X-Signature-V2 header | Invalid webhook signatureHMAC verification failed
403/status, /attestationForbiddenNon-admin tried to read another user; or CSRF rejected
403/admin/*ForbiddenNon-admin session
404/attestation, /attestation/by-principalNo KYC attestation foundNo row for the target
429Every routeRate limit exceededkycRateLimit tripped
500/sessionFailed to create KYC sessionProvider HTTP error
500/statusFailed to fetch KYC statusDB error
500/attestationFailed to fetch KYC attestationDB error
500/webhookKYC provider not configured | DB persistence failed | Webhook processing failedConfig or DB error; provider SHOULD retry
500/admin/*Failed to retry unsynced KYC approvals | Failed to retry unsynced attestationsUncaught error

Membership Canister Errors ​

set_kyc_attestation rejects with structured Err strings — see set_kyc_attestation for the full list. revoke_kyc_attestation returns No KYC attestation found for <principal> when the target is unknown.


Observability ​

Structured Logs ​

oracle-bridge emits pino JSON to stdout. Every KYC log line anonymizes user identifiers via anonymizeUserId(userId) — only the first 8 hex characters of the SHA-256 of the user id are logged, never the raw UUID.

Key log events:

  • [KYC] create session failed for <anonymized>: <error>
  • [KYC] Failed to persist verification row for <anonymized>: <error> (non-fatal — session still returned to the user)
  • [KYC] webhook handler error: <error>
  • [KYC] failed to persist webhook row: <error> (returns 500 so provider retries)
  • [KYC] membership canister update failed: <error> (non-fatal — DB row is authoritative)
  • [KYC] attestation sync incomplete: <error>
  • [KYC] admin retry-unsynced failed: <error> / [KYC] admin retry-attestations failed: <error>

Raw provider payloads, VC JSON, and attestation_hash values never appear in logs. The full audit trail lives in kyc_verifications.raw_payload and kyc_attestations.vc_json.

Health Metrics ​

  • oracle-bridge /health endpoint returns 200 ok for k8s / Docker liveness probes.
  • Prometheus metrics are not yet wired for KYC endpoints — file a BL-* item if the pending OBS epic needs per-endpoint counters.

  • KYC policy docs — docs/kyc/gdpr-compliance-requirements.md, docs/kyc/provider-comparison.md (policy + procurement, no integration surface)
  • Governance API — governance.md (uses is_kyc_verified for voting + ratification gates)
  • Payment Gateway API — payment-gateway.md (kyc-refund payment type uses this surface for failed-verification refunds)
  • Cross-Domain Auth — cross-domain-auth.md (shares the session-cookie pattern used by /api/kyc/* browser-facing routes)
  • Secret hygiene — secret-hygiene.md (how the KYC env vars are rendered into k8s Secrets)
  • oracle-bridge README — oracle-bridge/README.md
  • membership canister README — membership/README.md

Changelog ​

Version 1.0.0 (2026-04-20) ​

Initial KYC-001 epic delivery documentation. All three sub-stories (.1 through .3) merged and deployed to staging.

Implemented:

  • KYC-001.1 — oracle-bridge adapter with Didit + iDenfy providers, status normalization, HMAC-signed webhook, admin retry, kyc_verifications table
  • KYC-001.2 — W3C VC Data Model v1.1 with Ed25519Signature2020 proof, canonical-JSON + SHA-256 hash, on-chain KycAttestation record + 4 membership canister methods, kyc_attestations table with one-live-per-principal invariant
  • KYC-001.3 — KycWidget React component with iframe embed, origin-checked postMessage handling, status polling, full state machine, accessibility support

Known follow-ups:

  • Blockpass provider implementation (reserved in the DB CHECK constraint)
  • Auto-re-verification when expires_at_ns elapses
  • Self-service user re-submission flow after REVIEW outcomes
  • Prometheus metrics for per-endpoint counters (folded into the pending OBS epic)

Maintainer: Platform / KYC Team Last Updated: 2026-04-20

Hello World DAO