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:
- 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 singleKycProviderinterface, verifies provider webhooks via HMAC-SHA256, and persists every outcome to PostgreSQL. - Verifiable Credential bridge (KYC-001.2) — on every APPROVED webhook, oracle-bridge builds a W3C VC Data Model v1.1 credential with an
Ed25519Signature2020proof, hashes the canonical JSON (withoutproof) with SHA-256, and writes the 32-byte hash to the on-chainmembershipcanister viaset_kyc_attestation. The full VC (including PII claims) stays off-chain in Postgres; only the hash + metadata lives on-chain. - Frontend widget (KYC-001.3) —
KycWidgetReact component in marketing-suite embeds the Didit/iDenfy inline verification flow as a sandboxed iframe, listens for providerpostMessagecompletion events, then polls oracle-bridge's status endpoint until the webhook lands a terminal status (APPROVED/DENIED/REVIEW).
Key Properties
- Provider-agnostic — the
KycProviderinterface 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_jsoncolumn. The on-chainKycAttestationrecord 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/webhookroute is rate-limited before signature verification (cheap DoS protection) and rejects any request lacking a matching HMAC-SHA256 digest. - Idempotent canister writes —
set_kyc_attestationreplaces in place and is keyed bysubjectprincipal. 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_attestationyet (e.g., an older build), oracle-bridge still persists the DB row and markscanister_synced=false. The admin retry endpoint re-drives these rows once the canister catches up. - Replay-safe admin retries —
/admin/retry-unsyncedand/admin/retry-attestationswalkcanister_synced=falserows 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 sessionGET /api/kyc/status/:userId— latest status for the caller or (admin) any userGET /api/kyc/attestation[?user_id=]— latest live attestation metadata for the caller or (admin) any userGET /api/kyc/attestation/by-principal/:principal— admin-only inspection by IC principalPOST /api/kyc/webhook— provider callback (HMAC-SHA256 signed, raw-body parser)POST /api/kyc/admin/retry-unsynced— replay approved verifications whose canister call failedPOST /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-verificationpage +KycWidgetcomponent with iframe embed,postMessagelistener, 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_nselapses — consumers today checkis_kyc_verifiedper call - Self-service user re-submission flow after
REVIEW— today aREVIEWoutcome requires admin action
Full Lifecycle Sequence
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 paymentAuthentication
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
/loginflow) +X-CSRF-Tokendouble-submit header. - Middleware order:
requireSessionAuthruns beforekycRateLimitso unauthenticated floods fail at401and do not consume an authenticated user's rate budget (AI-R211). - Authorization: users may only read their own status / attestation. An admin caller (session
userRolesincludes"admin") may read anyuser_idvia the optional query or path parameter; a non-admin attempting to access another user gets403 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 anadminrole. - Non-admin sessions get
403without 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 toX-Signaturefor legacy deliveries). - iDenfy header:
Idenfy-Signature. - Constant-time comparison — buffer lengths are checked before
crypto.timingSafeEqualso 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 beforeexpress.json()insrc/index.ts. Reordering breaks signature verification 100% of the time (same lesson as Stripe webhooks — seedocs/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:
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 Var | Default | Values |
|---|---|---|
KYC_PROVIDER | didit | didit · 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:
export type KycStatus = 'PENDING' | 'APPROVED' | 'DENIED' | 'REVIEW';Didit (src/kyc/providers/didit.ts, DIDIT_STATUS_MAP):
| Raw status | Normalized |
|---|---|
Not Started, In Progress, PENDING, not_started, in_progress | PENDING |
Approved, approved, APPROVED | APPROVED |
Declined, declined, DECLINED | DENIED |
In Review, Expired, in_review, expired | REVIEW |
| any other string | REVIEW (fail-closed — never silent-approve) |
iDenfy (src/kyc/providers/idenfy.ts, IDENFY_STATUS_MAP):
| Raw status | Normalized |
|---|---|
PENDING, ACTIVE | PENDING |
APPROVED | APPROVED |
DENIED | DENIED |
SUSPECTED, REVIEWING, EXPIRED, DELETED | REVIEW |
| any other string | REVIEW |
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):
{
"callback_url": "https://helloworlddao.com/kyc-verification"
// defaults to `${cfg.frontendUrl}/kyc/callback` when omitted
}Response (200):
{
"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:
| Status | Body | Cause |
|---|---|---|
401 | { error: "Unauthorized" } | No active session |
403 | CSRF middleware rejection | X-CSRF-Token absent or invalid |
429 | Rate limit exceeded | kycRateLimit 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):
{
"user_id": "9b3...",
"status": null,
"provider": null,
"updated_at": null
}Response — existing row (200):
{
"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):
{
"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:
kycRateLimit— IP-keyed; sheds floods before signature CPU spend.express.raw— produces the raw bytes the HMAC covers.provider.handleWebhook({ rawBody, headers })— verifies signature, parses the body, returns a normalizedWebhookResult.upsertVerificationStatus— insert or updatekyc_verificationskeyed on(session_id).raw_payloadcarries the denormalizedicPrincipalso the admin retry endpoint can replay without re-parsing provider-specific fields.- If
normalizedStatus === 'APPROVED' && icPrincipal: a.applyKycApprovalToMembership(icPrincipal)— best-effort legacy bridge that flips an olderkyc_verifiedflag on the membership canister. b.applyKycAttestationToMembership({ userId, subjectPrincipalText, provider, level: 'standard', sessionId })— builds and signs the VC, upsertskyc_attestations, callsset_kyc_attestationon the canister. - Respond
200regardless of canister-write outcome — the DB row is authoritative, and failures surface through admin retries.
Response — success (200):
{
"received": true,
"status": "APPROVED",
"provider": "didit",
"session_id": "abc123-provider-session",
"membership_updated": true,
"attestation_synced": true,
"attestation_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}Errors:
| Status | Body | Cause |
|---|---|---|
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 |
429 | Rate limit exceeded | kycRateLimit 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):
{ "limit": 100 }Response (200):
{
"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):
{
"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:
{
"@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 bytesThe 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:
- Strip the
prooffield from the stored VC. - Run
canonicalStringifyover the remainder (same algorithm oracle-bridge used). - Compute
SHA-256of the canonical bytes. - Compare against
attestation_hashon-chain. - Independently verify
proof.proofValueis a valid Ed25519 signature over the same bytes usingproof.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
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:
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.issuerMUST equalcaller(prevents spoofing even from the authorized principal).subjectMUST NOT be the anonymous principal.attestation_hash.len() == 32(SHA-256 size — any other length rejects with a descriptive error).provider,levelnon-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 message | Cause |
|---|---|
Unauthorized: attestation.issuer must equal the calling oracle-bridge principal | Caller is authorized but the issuer field was spoofed |
Invalid attestation: subject must not be anonymous | subject = Principal::anonymous() |
Invalid attestation_hash length: got N, expected 32 (SHA-256) | Hash is not exactly 32 bytes |
Invalid attestation: provider must not be empty | Empty provider string |
Invalid attestation: level must not be empty | Empty level string |
Invalid attestation: issued_at_ns must be non-zero | issued_at_ns == 0 |
Invalid attestation: expires_at_ns must be 0 (no expiry) or greater than issued_at_ns | Invalid expiry relation |
Any require_oracle_bridge rejection | Caller 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:
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:
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:
is_kyc_verified : (principal) -> (bool) query;Access: public.
// 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
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| State | Trigger | Next |
|---|---|---|
loading | Initial mount | ready on session success, error on failure |
ready | Session created; iframe mounted | polling on completion postMessage; rejected on failure postMessage |
polling | Provider emitted a completion event; backend webhook expected | approved / rejected / error (max-polls exhausted) |
approved | Backend returned APPROVED | Terminal — fires onSuccess exactly once |
rejected | Backend returned DENIED or REVIEW, or provider emitted a failure event | Terminal — fires onFailure exactly once; shows retry CTA |
error | Session creation or status poll threw | Shows 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_urlfrom thePOST /api/kyc/sessionresponse. It never trusts asession_urlfrom URL parameters orpostMessage. - 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, pluskyc.completed/kyc.finishedgeneric fallbacks. Unknowntypevalues 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 torejected.
Accessibility
- iframe has
title="Identity verification"and a descriptivearia-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/rejectedtransitions. ESCinside 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)
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).
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key, uuid_generate_v4() |
user_id | TEXT NOT NULL | oracle-bridge user UUID — indexed |
provider | TEXT NOT NULL | CHECK IN ('didit', 'idenfy') |
session_id | TEXT NOT NULL UNIQUE | Provider session id — the webhook dedup key |
status | TEXT NOT NULL | CHECK IN ('PENDING', 'APPROVED', 'DENIED', 'REVIEW') |
raw_payload | JSONB NOT NULL | Provider response + icPrincipal denormalized for replay |
canister_synced | BOOLEAN NOT NULL DEFAULT false | (migration 018) true once applyKycApprovalToMembership succeeds |
created_at / updated_at | TIMESTAMPTZ 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.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
user_id | TEXT | oracle-bridge UUID (nullable — some flows only have a principal) |
ic_principal | TEXT NOT NULL | Member IC principal |
provider | TEXT NOT NULL | CHECK IN ('didit', 'idenfy', 'blockpass') |
level | TEXT NOT NULL | CHECK IN ('standard', 'enhanced', 'accredited_investor') |
session_id | TEXT | Source provider session (nullable — admin-issued attestations can omit) |
vc_json | JSONB NOT NULL | Full signed W3C VC (including proof) |
attestation_hash | BYTEA NOT NULL | CHECK octet_length = 32 — matches the 32-byte canister field |
issued_at | TIMESTAMPTZ NOT NULL | Source-of-truth issuance timestamp |
expires_at | TIMESTAMPTZ | Nullable — matches expires_at_ns == 0 on-chain |
canister_synced | BOOLEAN NOT NULL DEFAULT false | true after set_kyc_attestation succeeds |
revoked_at | TIMESTAMPTZ | Non-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 Var | Required | Notes |
|---|---|---|
KYC_PROVIDER | No | didit (default) | idenfy. Unsupported values throw at first factory call. |
DIDIT_API_KEY | Yes when KYC_PROVIDER=didit | Sent as x-api-key on session creation |
DIDIT_API_SECRET | No | Reserved for future Didit v3 auth |
DIDIT_WEBHOOK_SECRET | Yes when KYC_PROVIDER=didit | HMAC-SHA256 secret for X-Signature-V2 verification |
DIDIT_API_BASE_URL | No | Default https://verification.didit.me |
DIDIT_WORKFLOW_ID | No | Optional workflow id for multi-workflow Didit accounts |
IDENFY_API_KEY | Yes when KYC_PROVIDER=idenfy | Basic-auth username |
IDENFY_API_SECRET | Yes when KYC_PROVIDER=idenfy | Basic-auth password |
IDENFY_WEBHOOK_SECRET | Yes when KYC_PROVIDER=idenfy | HMAC-SHA256 secret for Idenfy-Signature verification |
IDENFY_API_BASE_URL | No | Default https://ivs.idenfy.com |
PRIV_KEY_B64 | Yes | Base64 Ed25519 private key — shared with other oracle-bridge signers (payment-gateway, ICP/DOM). Derives the issuer principal on the VC. |
MEMBERSHIP_CANISTER_ID | Yes | Staging / production id for the membership canister |
IC_HOST | No | Default https://icp0.io. Local PocketIC: http://127.0.0.1:4943 |
marketing-suite
| Env Var | Notes |
|---|---|
VITE_ORACLE_BRIDGE_URL | Base URL for session + status + attestation calls. Local: http://localhost:3000. Staging: https://oracle.staging.helloworlddao.com. |
VITE_DAO_SUITE_URL | Where /kyc-verification redirects on APPROVED. Example: https://staging-portal.helloworlddao.com. |
VITE_THINK_TANK_URL | Sign-in redirect target when the session is missing. |
Error Reference
All oracle-bridge KYC responses use the shape { error: "<phrase>" [, user_friendly_message: "..."] }.
| HTTP | Endpoint(s) | error | Cause |
|---|---|---|---|
400 | /webhook | Webhook body is not valid JSON | Raw body did not parse as JSON |
400 | /webhook | Webhook missing session_id | Webhook missing status | Required field absent |
400 | /attestation/by-principal/:principal | principal required | Empty path parameter |
401 | /session, /status, /attestation, /admin/* | Unauthorized | Session cookie absent or expired |
401 | /webhook | Missing X-Signature-V2 header | Invalid webhook signature | HMAC verification failed |
403 | /status, /attestation | Forbidden | Non-admin tried to read another user; or CSRF rejected |
403 | /admin/* | Forbidden | Non-admin session |
404 | /attestation, /attestation/by-principal | No KYC attestation found | No row for the target |
429 | Every route | Rate limit exceeded | kycRateLimit tripped |
500 | /session | Failed to create KYC session | Provider HTTP error |
500 | /status | Failed to fetch KYC status | DB error |
500 | /attestation | Failed to fetch KYC attestation | DB error |
500 | /webhook | KYC provider not configured | DB persistence failed | Webhook processing failed | Config or DB error; provider SHOULD retry |
500 | /admin/* | Failed to retry unsynced KYC approvals | Failed to retry unsynced attestations | Uncaught 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
/healthendpoint returns200 okfor 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.
Related Documentation
- 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_verifiedfor voting + ratification gates) - Payment Gateway API — payment-gateway.md (
kyc-refundpayment 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_verificationstable - KYC-001.2 — W3C VC Data Model v1.1 with
Ed25519Signature2020proof, canonical-JSON + SHA-256 hash, on-chainKycAttestationrecord + 4 membership canister methods,kyc_attestationstable with one-live-per-principal invariant - KYC-001.3 —
KycWidgetReact 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_nselapses - 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