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
| Event | Payload | Meaning |
|---|---|---|
ready | — | The widget has loaded and is ready to begin capture. |
submitted | mrz_extracted: boolean, document_type, side | The 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. |
completed | case_id | The capture flow finished; the check is ready to be evaluated. |
error | code | The 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
originWhitelistas 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" }
}
}inputistext,dropdownordate, 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 theoptionsyou list. A country field's option values are ISO 3166-1 alpha-3 codes.hintis a line of up to 200 characters the applicant reads under the field.optionsgo withdropdownand nothing else.labelis what the applicant sees; thevalueis 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.