Skip to content

KYC

Identity verification checks, the issuance gate, and console attestation.

The KYC gate

Creating a cardholder does not itself clear them to hold a card. Under a programme with KYC enabled, card issuance requires the cardholder's kyc_complete field to be true — and it is once the cardholder has either passed verification (kyc_state: "passed") or been attested by a console admin.

Three cardholder fields tell the story. kyc_state records what the verification engine concluded (unverified, pending, in_review, passed or failed); attested records a console admin's override; kyc_complete derives from the two. When deciding whether issuance can proceed, read kyc_complete — an attested cardholder keeps kyc_state: "unverified" because attestation is an exemption from verification, not a verification verdict.

Verification checks

A verification case is opened with Start a KYC check, which pins the check to the programme's active policy version and begins collecting applicant data. Attach that data with Attach applicant data — it is stored under purpose-bound custody and referenced by token, and API clients are the intended intake path: your backend collects from your user and submits on their behalf.

The case advances to evaluation once the policy has the inputs it requires, then completes on its own. A check's public state moves through pending while collecting and evaluating, and ends passed, failed, or in_review when the policy refers it to a human. A case left without its required inputs eventually ends expired (ageing is visible via created_at and expires_at on the check).

That's the full state enum — five values, and it does not carry a sixth. There is no rejected state: when a referred check is decided against the applicant, its state moves to failed — the same value an automated policy failure carries. rejected is deliberately absent from the enum, not an omission; to tell a human rejection apart from an automated failure, read the decision itself with List decisions rather than state.

Follow a single check with Retrieve a KYC check, which includes reason_codes drawn from the evidence recorded against it, or list the tenant's checks with List KYC checks — oldest first, filterable by state and programme.

Document capture

Where a check needs a captured identity document, mint a capture session with Mint a capture-widget session token. It returns a 15-minute bearer token scoped to that one check. The token is re-usable until it expires — the widget may scan several times against one session (up to 10 scans, at most one every 3 seconds) so an applicant can retake a blurry photo without re-minting — hand it to the hosted capture widget, never your own OAuth client credentials, so the widget can post the captured image on your behalf. A session is only mintable while the check is still collecting.

Embedding the capture widget

capture_url from Mint a capture-widget session token is a ready-made page carrying the document-capture UI. By default the page follows the end user's device appearance (prefers-color-scheme); if your app has a light/dark setting of its own, pass theme: "light" or theme: "dark" when minting and the page is pinned to it — the choice rides capture_url, so nothing else changes in how you embed. Host it one of two ways.

iframe embed. Set capture_url as an iframe's src on a page your own customer controls. The widget notifies the host of lifecycle events by posting a message to each origin your programme has registered on its capture-origins allow-list — never to *, and never to an origin that isn't registered.

Native WebView. Load capture_url as the WebView's top-level page rather than inside a frame. In this shape window.parent === window, so the iframe delivery path never fires; instead the widget posts every event through window.ReactNativeWebView.postMessage, which only accepts a string, so each event is JSON-stringified before it's sent.

The event envelope

Both delivery paths carry the same JSON shape:

{ "source": "rigid-capture", "version": 1, "event": "...", "...": "event-specific fields" }

source is always "rigid-capture". version is always 1 today, and only changes on a breaking change to this envelope. event and whatever fields follow it depend on which event fired — see the table below.

Event vocabulary

EventPayloadMeaning
ready—The widget has loaded and is ready to begin capture.
submittedmrz_extracted: boolean, document_type, sideThe applicant submitted a document image; mrz_extracted reports whether the machine-readable zone was read from it, and document_type/side name which document class and which side (front/back) the image belongs to.
completedcase_idThe capture flow finished; the check is ready to be evaluated.
errorcodeThe widget hit an error it can't recover from.
expired—The capture session's token expired mid-flow.

completed's case_id can be null — the scan-budget-exhaustion path lands on this same terminal even when no scan ever succeeded, so a caller must handle a null case_id rather than assume completed always carries one. For a two-sided document class (a card, not a passport) submitted fires once per side — each side consumes its own scan-budget attempt — while completed still fires exactly once, after the last side the case needs.

These five names are the entire contract. While an applicant works through a scan the widget moves through several internal screen phases — framing guidance, retake prompts, and so on — but those are not part of this message contract and can change without notice. Match only on the event names above.

Content-Security-Policy and embedding scope

The capture page's Content-Security-Policy sets frame-ancestors per session, scoped to the programme's registered capture-origins allow-list — the same origins events are posted to in the iframe shape. Only an origin on that list can embed the page in an iframe. If the programme has registered no capture origins, frame-ancestors is 'none': the page can then only ever load top-level, either as a full-page navigation or inside a native WebView — never inside any iframe, registered or not.

WebView integration notes

A few details the field surfaced that aren't obvious from the API alone:

  • Register the capture page's origin in the WebView's originWhitelist as a bare origin (https://vision.example.com), not a path underneath it (https://vision.example.com/*) — on some platforms a path-suffixed entry opens the link in the system browser instead of loading it in the WebView.
  • Grant the WebView camera/media-capture permission before navigating to capture_url; capture cannot proceed without it.
  • Once the scan phase is live, let the widget own the screen. Don't render your own primary "Continue" affordance over it — the widget drives its own progression through capture, and a host-level control racing it produces a confusing double interface.

Face match

Where a check needs a selfie compared against its captured document, attach one directly — no widget, no operator supervision required — with Attach a selfie image:

curl -X POST https://api.rigid.fi/v1/kyc/checks/<check-id>/selfie-image \
  -H "Authorization: Bearer $RIGID_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "selfie_image_base64": "<base64-encoded JPEG or PNG>" }'

This is the API-population intake: your own backend collects the selfie from your user and submits it on their behalf, the same intake shape as Attach applicant data. A replacement selfie erases the one it supersedes, and if the case is already evaluating and the active policy requires face_match, attaching one enqueues a fresh evaluation. Idempotency-Key is required.

Read it back with Read a check's selfie image — same custody tier and 404/410 shape as the document image. Its response also carries provenance, stating how the selfie was captured: submitted for this intake path, or controlled for an operator-supervised capture.

Programme policy

Each programme carries a KYC policy — which signals a check must produce before it can pass. Get the active KYC policy returns the current version, seeding version 1 from the platform default on first read; policy is scoped per programme and per mode, so test and live carry independent histories.

Policy versions are immutable. Write a new policy version recomposes the policy from the active version plus your requested required_signals set — you toggle signals on or off, and the server owns how their evidence is weighted. Checks in flight stay pinned to the version they started under.

Details fields

The details step is the one step of the hosted flow that asks the applicant to type rather than to photograph, and the policy says what it asks. required_fields and optional_fields choose the fields and whether each must be answered, and field_order the order they appear in. Fields a check depends on stay required whatever you choose: the name and date of birth that data quality and sanctions screening compare, and the date of birth or nationality an eligibility rule reads.

field_config chooses how each field is asked, keyed by field:

{
  "field_config": {
    "nationality": {
      "input": "dropdown",
      "hint": "As shown on your passport",
      "options": [
        { "value": "GBR", "label": "United Kingdom" },
        { "value": "IRL", "label": "Ireland" }
      ]
    },
    "date_of_birth": { "input": "text" }
  }
}
  • input is text, dropdown or date, as far as the field allows. A date field is a date picker unless you ask for it as free text; it is still checked as a date (YYYY-MM-DD) either way. Any other field is free text unless you make it a dropdown of the options you list. A country field's option values are ISO 3166-1 alpha-3 codes.
  • hint is a line of up to 200 characters the applicant reads under the field.
  • options go with dropdown and nothing else. label is what the applicant sees; the value is what is stored.

The hosted capture page asks for each field the way it is configured, and refuses a dropdown value you did not offer. The policy keeps configuration only for fields the flow asks for, so removing a field removes its configuration too. Any configuration that cannot be honoured, for a field the flow asks for or not — a date offered as a dropdown, a country option that is not an alpha-3 code — is refused with invalid_field_config. So is publishing a nationality dropdown that offers no nationality your allowed_nationalities rule accepts; a draft holding one still saves, so you can fix it. A dropdown with no options never gets that far: request validation refuses it, a 400 with no code.

Referred checks

A check the policy cannot settle lands in_review, and someone must decide it. Record a decision resolves a referred case to approved or rejected, and requires a named human reviewer — the authenticated caller is recorded as the actor and may act on the reviewer's behalf. Deciding emits a second kyc.check.completed event with a newer checked_at.

A referred case isn't only approve-or-reject: where the document itself was the problem, Request a re-capture is the third option. It reopens the case for one more scan instead of forcing a decision on what it already has — the case moves back to collecting/evaluating, a fresh capture_url is minted for the applicant, and the attempt is recorded as a recapture_requested decision naming the reviewer. No kyc.check.completed fires (nothing has completed); a properly re-scanned document lets the case reach its own decision, automated or not, exactly as if it had read cleanly the first time.

For adjudication and audit, List evidence shows per-signal findings in production order (which signal produced which finding, under which engine version, with what score and reason codes), List decisions shows the decision history (the policy decision first, then any human decision), Read submitted applicant data returns the human-adjudication slice of the applicant's data, and Read a check's document image returns the captured document as a base64 image once one has been submitted (404 before that, 410 if it has since been crypto-erased under the retention policy) — every read of either is reported to the platform's PII audit pipeline.

Attestation

Where a programme has verified a cardholder out of band, a console admin can record that as an attestation (Attest a cardholder) from the console's cardholder list. Attestation is an admin-only override of the KYC gate: it is not reachable by any API client, org-wide or programme-pinned — an unattended integration can never self-attest the cardholder it represents. It flips kyc_complete to true while kyc_state continues to record what the verification engine concluded.

Webhook events

Three events cover the check lifecycle: kyc.check.started when a case opens, kyc.check.completed when it settles (and again if a referred case is later decided), and kyc.check.expired when an unfinished case ages out. Wait for kyc.check.completed rather than polling Retrieve a KYC check; delivery, verification and retries work as described in the webhooks guide.