Triggers
Complete reference for every Certivant Automation Workflow trigger — when each event fires, its data context, example payloads, subject/scope configuration, and DSL filter patterns.
A Trigger is the single entry node of every Automation Workflow. It declares which event starts the workflow, which objects that event must concern (the subject + scope), and an optional filter that further narrows which events qualify. When the platform emits a matching event, a KycWorkflowRun is created and the workflow executes node by node.
This page documents every trigger the engine actually emits, grounded in the platform's emission sites. Each entry lists when it fires, the exact context it carries, a real example payload, and how to scope and filter it.
The trigger catalogue is a fixed enum in the engine (
WorkflowTriggerEvent). The editor's picker, this page, the API validation schema, and the dispatcher all read from the same list, so every trigger you can select is a trigger the platform actually emits — none are decorative. A few capabilities that don't yet have an emission point are intentionally kept out of the picker and listed under Roadmap triggers with the working alternative, so you never build a workflow that silently never runs.
How triggers fire
Triggers are not polled. Each one corresponds to a real point in the platform where code calls the dispatcher:
emitter (API route / worker / cron)
└─ enqueueWorkflowDispatch({ organizationId, event, kycRequestId?, …, context })
└─ workflow.worker → dispatchWorkflowEvent(input)
├─ load active workflows whose trigger.event === input.event
├─ narrow by subject + scope (form / applicant / document / check)
├─ evaluate the optional DSL filter (against input.context)
└─ create + run a KycWorkflowRun per match
Three properties matter for integrators:
- Fire-and-forget. Dispatch is wrapped so a failure never breaks the originating action. If a document upload triggers a workflow and the dispatch throws, the upload still succeeds. Treat workflow side effects as eventually executed, not synchronous to the API call.
- One event, many runs. A single event can start every active workflow that matches it. Use subject + scope (and filters) to keep that fan-out intentional.
- Context is captured once. The event's
contextobject is stashed on the run's first log entry. Condition nodes, Action templates, and mail-merge tokens read from that snapshot — they do not re-query live state.
The dispatch envelope
Every emission produces a WorkflowEventInput. Fields beyond organizationId and event are optional and depend on the event:
| Field | Type | Present when |
|---|---|---|
organizationId | string | always |
event | WorkflowTriggerEvent | always |
kycRequestId | string | null | the event concerns a specific verification (most events) |
formId | string | null | form.submitted |
formKind | "kyc" | "kyb" | null | form.submitted |
applicantId | string | null | applicant-scoped events |
documentType | string | null | document.* |
checkKind | string | null | check.completed, liveness.*, sanctions.* |
context | object | event-specific facts (see each trigger below) |
workflowId | string | only on a trigger_workflow direct invocation |
chainDepth | number | workflow-to-workflow chain depth (max 5) |
subject, scope, filter, formIds, schedule, scoreMin, and scoreMax are properties of the trigger you configure — not of the runtime event. The dispatcher uses them to decide whether an incoming event matches your workflow.
The trigger definition
This is the shape persisted on a workflow's trigger (WorkflowTrigger):
interface WorkflowTrigger {
event: WorkflowTriggerEvent; // which event starts the workflow
subject?: "form" | "applicant" // the kind of object it binds to
| "document" | "check"; // (absent → legacy org-wide)
scope?: {
mode: "specific" | "category" | "all";
ids?: string[]; // specific-mode object ids
category?: string; // category-mode key (see below)
};
filter?: string; // optional DSL predicate over context
formIds?: string[]; // legacy form-binding (pre-subject)
schedule?: string; // schedule only — cron expression
scoreMin?: number; // score.threshold convenience bound
scoreMax?: number; // score.threshold convenience bound
}
Subject + scope
The subject is the kind of object the trigger watches; the scope narrows it to specific objects, a category, or everything. This is what stops a trigger from firing org-wide by accident.
| Subject | Valid events | scope.mode options | category meaning |
|---|---|---|---|
form | form.submitted | specific (form ids) · category · all | "kyc" or "kyb" |
applicant | request.*, score.threshold, edd.triggered, verification.expired | specific (applicant ids) · all | — |
document | document.uploaded, document.failed | specific · category · all | a document type, e.g. "passport" |
check | check.completed, liveness.*, sanctions.* | specific · category · all | a check kind, e.g. "sanctions" |
| (none) | schedule, inbound.webhook, monitoring.alert, kyb.*, reviewer.decision, conference.completed | — | org-wide |
Scope modes:
specific— match only the listedids(e.g. fire only for this onboarding form). If every id in aspecificscope points to a deleted object, the trigger is orphaned: the dispatcher refuses to fire it (it stays dormant rather than silently falling back to "all"), and the editor shows a "Needs re-binding" badge.category— match a class of objects (all KYC forms, all passports, all sanctions checks).all— match any object of the subject kind in the organization.
A trigger with no subject keeps the legacy behaviour: org-wide, optionally narrowed by formIds for form.submitted. New workflows should always set a subject + scope.
Filter (DSL)
filter is an optional boolean predicate in the same expression grammar as the decision policy DSL. It runs against the event context. An empty filter fires on every event of that type. See Filtering with the DSL.
Trigger categories
| Category | Triggers |
|---|---|
| Entry | form.submitted, request.created |
| Lifecycle / status | request.status_changed, request.approved, request.rejected, request.escalated, request.resubmitted, request.documents_ready |
| Documents | document.uploaded, document.failed |
| Verification signals | check.completed, liveness.passed, liveness.failed, face_match.passed, face_match.failed, sanctions.hit, sanctions.cleared, pep.match, kyb.match_found, kyb.match_not_found, kyb.ubo_completed |
| Human review | reviewer.decision, reviewer.assigned, reviewer.comment_added, edd.triggered |
| Risk / score | score.threshold, score.updated |
| Time / monitoring | schedule, verification.expired, monitoring.alert |
| External / integration | inbound.webhook, conference.completed |
Trigger reference
Each entry below uses the same structure: When it fires, Data context, Example event, and Example workflow usage. Every payload is the real dispatch shape produced by the emitting code.
Entry
form.submitted
"A form is submitted" · subject form
When it fires. A public, hosted verification form is submitted by an applicant. Emitted from the public forms route the moment a submission creates its KycRequest. The companion request.created event is emitted alongside it.
Data context.
| Field | Type | Notes |
|---|---|---|
type | string | Applicant type at submission, e.g. "INDIVIDUAL" |
Top-level: formId, formKind ("kyc"/"kyb"), kycRequestId, applicantId.
Example event.
{
"organizationId": "org_01H...",
"event": "form.submitted",
"kycRequestId": "req_01J...",
"formId": "form_01H...",
"formKind": "kyc",
"context": { "type": "INDIVIDUAL" }
}
Example workflow usage. Bind a welcome-email + document-request workflow to one specific onboarding form:
trigger:
event: form.submitted
subject: form
scope: { mode: specific, ids: ["form_01H..."] }
request.created
"A verification is created" · subject applicant
When it fires. A verification request is created — via POST /api/v1/kyc/requests (201) or as the companion to a hosted form.submitted. Fires before any document is uploaded or any check runs, so use it for intake routing, not for outcomes.
Data context.
| Field | Type | Notes |
|---|---|---|
applicant.country | string | null | ISO-3166 alpha-2, from applicant nationality |
applicant.type | string | "INDIVIDUAL" or "BUSINESS" |
type | string | Request type (INDIVIDUAL / BUSINESS) |
Example event.
{
"organizationId": "org_01H...",
"event": "request.created",
"kycRequestId": "req_01J...",
"applicantId": "app_01J...",
"context": {
"applicant": { "country": "CA", "type": "INDIVIDUAL" },
"type": "INDIVIDUAL"
}
}
Example workflow usage. Route business applicants to a KYB intake queue:
trigger:
event: request.created
subject: applicant
scope: { mode: all }
filter: 'applicant.type = "BUSINESS"'
Lifecycle / status
These fire after orchestration finalizes a verdict. They all carry the same decision context. The specific events (approved/rejected/escalated) fire in addition to the generic request.status_changed, so listen to one or the other — not both — to avoid double execution.
Shared data context (request.approved, request.rejected, request.escalated, request.status_changed):
| Field | Type | Notes |
|---|---|---|
status | string | New status, e.g. "APPROVED", "REJECTED", "ESCALATED", "PENDING_SECOND_REVIEW" |
decision | string | null | Scorer verdict: "approve" / "reject" / "escalate" |
overallScore | number | null | Trust score 0–100 (higher = lower risk) |
applicant.country | string | null | ISO-3166 alpha-2 |
applicant.type | string | INDIVIDUAL / BUSINESS |
request.approved
"A verification is approved" · subject applicant
When it fires. Orchestration finalizes the request as APPROVED.
{
"organizationId": "org_01H...",
"event": "request.approved",
"kycRequestId": "req_01J...",
"applicantId": "app_01J...",
"context": {
"status": "APPROVED",
"decision": "approve",
"overallScore": 88,
"applicant": { "country": "US", "type": "INDIVIDUAL" }
}
}
Example workflow usage. Generate and email a verification report for high-confidence approvals only:
trigger:
event: request.approved
subject: applicant
scope: { mode: all }
filter: 'overallScore >= 80'
request.rejected
"A verification is rejected" · subject applicant
When it fires. Orchestration finalizes the request as REJECTED. Context identical to request.approved with status: "REJECTED", decision: "reject".
Example workflow usage. Notify the assigned manager and tag the applicant on rejection:
trigger:
event: request.rejected
subject: applicant
scope: { mode: all }
request.escalated
"A verification is escalated" · subject applicant
When it fires. Orchestration (or a prior workflow's escalate action) moves the request to ESCALATED / human review. Context identical to the shared shape with status: "ESCALATED".
Example workflow usage. Schedule a live ID conference whenever a request is escalated:
trigger:
event: request.escalated
subject: applicant
scope: { mode: all }
request.status_changed
"A verification status changes" · subject applicant
When it fires. Always emitted on a finalized status change, alongside any specific decision event. Use it to react to statuses that have no dedicated event (e.g. PENDING_SECOND_REVIEW, CANCELLED).
Example workflow usage. React only to a two-reviewer hand-off:
trigger:
event: request.status_changed
subject: applicant
scope: { mode: all }
filter: 'status = "PENDING_SECOND_REVIEW"'
request.resubmitted
"A verification is resubmitted" · subject applicant
When it fires. A reviewer sends a request back for resubmission from the dashboard; the request returns to PENDING_UPLOAD.
Data context.
| Field | Type | Notes |
|---|---|---|
status | string | "PENDING_UPLOAD" |
kind | string | "resubmit" |
{
"organizationId": "org_01H...",
"event": "request.resubmitted",
"kycRequestId": "req_01J...",
"context": { "status": "PENDING_UPLOAD", "kind": "resubmit" }
}
Example workflow usage. Re-send the applicant their upload link and start a reminder timer.
request.documents_ready
"All required documents are uploaded" · subject applicant
When it fires. The applicant has just uploaded the last item on their required-document checklist, so the request moves to DOCUMENTS_READY. Emitted from both upload paths — the dashboard document API and the hosted verification session — only on the upload that empties the pending-document set. This is the concrete "the applicant completed every required step" signal.
Data context.
| Field | Type | Notes |
|---|---|---|
status | string | "DOCUMENTS_READY" |
{
"organizationId": "org_01H...",
"event": "request.documents_ready",
"kycRequestId": "req_01J...",
"context": { "status": "DOCUMENTS_READY" }
}
Example workflow usage. Notify the assigned reviewer the moment a case is ready to review:
trigger:
event: request.documents_ready
subject: applicant
scope: { mode: all }
Documents
document.uploaded
"A document is uploaded" · subject document
When it fires. Each time a document is uploaded — through the dashboard API or a hosted verification session. One event per document.
Data context.
| Field | Type | Notes |
|---|---|---|
document.type | string | e.g. "passport", "id_card", "drivers_license" |
document.side | string | null | "front" / "back" (null when not applicable) |
Top-level: documentType, kycRequestId.
{
"organizationId": "org_01H...",
"event": "document.uploaded",
"kycRequestId": "req_01J...",
"documentType": "passport",
"context": { "document": { "type": "passport", "side": "front" } }
}
Example workflow usage. Fire only on proof-of-address uploads:
trigger:
event: document.uploaded
subject: document
scope: { mode: category, category: "utility_bill" }
document.failed
"A document fails validation" · subject document
When it fires. Orchestration marks a document failed (OCR could not run, authenticity rejected it, etc.). One event per failed document. Same context shape as document.uploaded.
{
"organizationId": "org_01H...",
"event": "document.failed",
"kycRequestId": "req_01J...",
"documentType": "drivers_license",
"context": { "document": { "type": "drivers_license", "side": "back" } }
}
Example workflow usage. Automatically request a recapture of any failed document.
Verification signals
check.completed
"A verification check completes" · subject check
When it fires. Once per individual check that finishes during an orchestration run (OCR, face match, liveness, sanctions, document authenticity, NFC read…). This is the generic, high-frequency signal — for a single clear outcome prefer liveness.* / sanctions.*.
Guarded fan-out. Because several checks finish per request, the platform first asks whether the org has any active workflow listening for
check.completed. With no listener, no dispatch jobs are created. The per-run watermark prevents a re-verify from re-emitting a previous run's checks.
Data context.
| Field | Type | Notes |
|---|---|---|
check.type | string | Check kind, e.g. "sanctions", "face_match", "ocr" |
check.status | string | "passed" / "failed" / "error" |
check.provider | string | null | Provider that ran the check |
check.score | number | null | Provider score where applicable |
Top-level: checkKind, kycRequestId.
{
"organizationId": "org_01H...",
"event": "check.completed",
"kycRequestId": "req_01J...",
"checkKind": "face_match",
"context": {
"check": { "type": "face_match", "status": "passed",
"provider": "azure_face", "score": 0.93 }
}
}
Example workflow usage. React to any sanctions check completing, regardless of outcome (for a clear pass/fail prefer the dedicated sanctions.* / face_match.* events below):
trigger:
event: check.completed
subject: check
scope: { mode: category, category: "sanctions" }
liveness.passed / liveness.failed
"Liveness passed" / "Liveness failed" · subject check (
liveness)
When it fires. After orchestration, when the liveness check persisted an unambiguous isLive boolean. passed when isLive is true, failed when false. An errored/ambiguous liveness check emits neither.
Data context.
| Field | Type | Notes |
|---|---|---|
liveness.isLive | boolean | Whether the applicant passed liveness |
liveness.confidence | number | null | Confidence 0–1 |
{
"organizationId": "org_01H...",
"event": "liveness.failed",
"kycRequestId": "req_01J...",
"checkKind": "liveness",
"context": { "liveness": { "isLive": false, "confidence": 0.31 } }
}
Example workflow usage. Escalate on a low-confidence liveness failure:
trigger:
event: liveness.failed
subject: check
scope: { mode: all }
filter: 'liveness.confidence < 0.5'
face_match.passed / face_match.failed
"Face match passed" / "Face match failed" · subject check (
face_match)
When it fires. After orchestration, when the face-match check (selfie ↔ document portrait) persisted an unambiguous isMatch boolean. passed when isMatch is true, failed when false. An errored/ambiguous check emits neither.
Data context.
| Field | Type | Notes |
|---|---|---|
faceMatch.isMatch | boolean | Whether the selfie matched the document face |
faceMatch.confidence | number | null | Confidence 0–1 |
{
"organizationId": "org_01H...",
"event": "face_match.failed",
"kycRequestId": "req_01J...",
"checkKind": "face_match",
"context": { "faceMatch": { "isMatch": false, "confidence": 0.42 } }
}
Example workflow usage. Escalate any face-match failure for manual review:
trigger:
event: face_match.failed
subject: check
scope: { mode: all }
sanctions.hit / sanctions.cleared
"Sanctions hit" / "Sanctions cleared" · subject check (
sanctions)
When it fires. After orchestration, from the persisted sanctions/watchlist result. hit when a match meets threshold (or any match is present); cleared otherwise. A match that is specifically a politically-exposed person also fires the dedicated pep.match event, so listen to that when you only care about PEP exposure.
Data context.
| Field | Type | Notes |
|---|---|---|
sanctions.isMatch | boolean | True on a hit |
sanctions.matchCount | number | Number of watchlist matches |
sanctions.topScore | number | null | Strongest match score 0–1 |
sanctions.listsChecked | string[] | Lists screened (OFAC, OpenSanctions, …) |
{
"organizationId": "org_01H...",
"event": "sanctions.hit",
"kycRequestId": "req_01J...",
"checkKind": "sanctions",
"context": {
"sanctions": { "isMatch": true, "matchCount": 2, "topScore": 0.91,
"listsChecked": ["OFAC", "OpenSanctions"] }
}
}
Example workflow usage. Escalate and notify compliance on a strong sanctions match:
trigger:
event: sanctions.hit
subject: check
scope: { mode: all }
filter: 'sanctions.topScore >= 0.85'
pep.match
"PEP match found" · subject check (
sanctions)
When it fires. After orchestration, when the sanctions/watchlist result contains at least one match whose entity type is a politically-exposed person. It rides on the same screening as sanctions.* but only fires when a PEP entry is present — a plain watchlist hit with no PEP entries does not fire it.
Data context.
| Field | Type | Notes |
|---|---|---|
pep.isMatch | boolean | Always true (the event only fires on a PEP match) |
pep.matchCount | number | Number of PEP matches |
{
"organizationId": "org_01H...",
"event": "pep.match",
"kycRequestId": "req_01J...",
"checkKind": "sanctions",
"context": { "pep": { "isMatch": true, "matchCount": 1 } }
}
Example workflow usage. Auto-trigger Enhanced Due Diligence and route to compliance whenever a PEP is detected:
trigger:
event: pep.match
subject: check
scope: { mode: all }
kyb.match_found / kyb.match_not_found
"KYB business match found" / "KYB match not found" · org-wide
When it fires. For BUSINESS requests only, after orchestration writes the business-registry verification result. verified → kyb.match_found; failed → kyb.match_not_found. Any other status (pending/absent) is ambiguous and emits nothing.
Data context.
| Field | Type | Notes |
|---|---|---|
kyb.status | string | "verified" or "failed" |
{
"organizationId": "org_01H...",
"event": "kyb.match_not_found",
"kycRequestId": "req_01J...",
"context": { "kyb": { "status": "failed" } }
}
Example workflow usage. Request additional incorporation documents when no registry match is found.
kyb.ubo_completed
"UBO verification completed" · org-wide
When it fires. For BUSINESS requests only, after orchestration has resolved every ultimate-beneficial-owner record on the business (none left pending). Carries the screened-UBO count and how many failed, so a workflow can branch on UBO risk without re-querying. Only fires when the business actually has UBO records and the KYB row was touched this run.
Data context.
| Field | Type | Notes |
|---|---|---|
kyb.uboCount | number | Number of ultimate beneficial owners screened |
kyb.failedUboCount | number | How many of those UBOs failed verification |
{
"organizationId": "org_01H...",
"event": "kyb.ubo_completed",
"kycRequestId": "req_01J...",
"context": { "kyb": { "uboCount": 3, "failedUboCount": 1 } }
}
Example workflow usage. Escalate when any beneficial owner fails screening:
trigger:
event: kyb.ubo_completed
filter: 'kyb.failedUboCount > 0'
Human review
reviewer.decision
"A reviewer makes a manual decision" · org-wide
When it fires. A human reviewer records a manual decision in the dashboard — single-approval, either stage of a two-reviewer flow, or a bulk action.
Data context.
| Field | Type | Notes |
|---|---|---|
decision | string | The reviewer's choice |
reviewerId | string | Id of the deciding reviewer |
stage | string | undefined | "first_approval" / "second_approval" on two-reviewer flows |
via | string | undefined | "bulk" when part of a bulk action |
{
"organizationId": "org_01H...",
"event": "reviewer.decision",
"kycRequestId": "req_01J...",
"context": { "decision": "approve", "reviewerId": "usr_01H...",
"stage": "second_approval" }
}
Example workflow usage. Audit-log every manual decision and notify the applicant after a final (second) approval.
reviewer.assigned
"A request is assigned to a reviewer" · org-wide
When it fires. A request is reassigned to a new owner/reviewer from the dashboard's reassign action. The initial owner set at request creation does not fire this — only an explicit reassignment does, so the event marks a genuine routing change.
Data context.
| Field | Type | Notes |
|---|---|---|
assignedUserId | string | The new owner/reviewer |
previousUserId | string | null | The prior owner |
teamId | string | null | The team the request now belongs to |
{
"organizationId": "org_01H...",
"event": "reviewer.assigned",
"kycRequestId": "req_01J...",
"context": { "assignedUserId": "usr_new...", "previousUserId": "usr_old...",
"teamId": "team_01H..." }
}
Example workflow usage. Send the newly-assigned reviewer a notification with a deep link to the case.
reviewer.comment_added
"A reviewer adds a comment" · org-wide
When it fires. A reviewer posts a comment on a case (the case-comment stream). The comment body is intentionally not placed on the event context — it can hold case-sensitive notes — so the event carries only metadata.
Data context.
| Field | Type | Notes |
|---|---|---|
commentId | string | The created comment id |
authorId | string | The commenting reviewer |
authorName | string | Display name of the author |
{
"organizationId": "org_01H...",
"event": "reviewer.comment_added",
"kycRequestId": "req_01J...",
"context": { "commentId": "cmt_01J...", "authorId": "usr_01H...",
"authorName": "Jordan Lee" }
}
Example workflow usage. Mirror new case comments into a Slack channel for the compliance team.
edd.triggered
"Enhanced Due Diligence is triggered" · subject applicant
When it fires. Enhanced Due Diligence is started for a request (currently the manual EDD action).
Data context.
| Field | Type | Notes |
|---|---|---|
kind | string | "edd" |
{
"organizationId": "org_01H...",
"event": "edd.triggered",
"kycRequestId": "req_01J...",
"context": { "kind": "edd" }
}
Example workflow usage. On EDD start, request source-of-funds documents and assign the case to the compliance department.
Risk / score
Both score events fire from the decision path once the scorer has written a trust score for the run (a request that finalizes without a score — e.g. cancelled before scoring — emits neither). They carry the full decision context, so overallScore, decision, status, and applicant.* are all available to filters.
score.updated
"The trust score is updated" · subject applicant
When it fires. On every (re)scored finalization — including a re-analysis that recomputes the score. Use it to react to the new trust score regardless of the verdict.
Data context. The shared decision context (overallScore, decision, status, applicant.country, applicant.type).
{
"organizationId": "org_01H...",
"event": "score.updated",
"kycRequestId": "req_01J...",
"applicantId": "app_01J...",
"context": {
"status": "APPROVED",
"decision": "approve",
"overallScore": 72,
"applicant": { "country": "CA", "type": "INDIVIDUAL" }
}
}
Example workflow usage. Tag any request whose score lands in a manual-review band:
trigger:
event: score.updated
subject: applicant
scope: { mode: all }
filter: 'overallScore >= 50 AND overallScore < 70'
score.threshold
"The trust score crosses a threshold" · subject applicant
When it fires. Same emission point as score.updated, but the dispatcher narrows it by the trigger's own scoreMin / scoreMax bounds (it reads context.overallScore). Set the bounds on the trigger and the workflow only runs when the score falls within them — a convenience over writing an overallScore filter by hand.
Data context. Identical to score.updated.
{
"organizationId": "org_01H...",
"event": "score.threshold",
"kycRequestId": "req_01J...",
"applicantId": "app_01J...",
"context": {
"status": "REJECTED",
"decision": "reject",
"overallScore": 38,
"applicant": { "country": "US", "type": "INDIVIDUAL" }
}
}
Example workflow usage. Run only for low scores by setting an upper bound on the trigger:
trigger:
event: score.threshold
subject: applicant
scope: { mode: all }
scoreMax: 50 # fire only when overallScore <= 50
scoreMin/scoreMaxare inclusive. Set one or both. Leaving both unset makesscore.thresholdbehave likescore.updated.
Time / monitoring
schedule
"On a schedule" · org-wide, not request-bound
When it fires. On the workflow's own cron expression (set in the trigger's schedule field). The run is org-level — kycRequestId is null — so use it for batch/maintenance work, not per-applicant logic.
The cron is a standard 5-field expression (minute hour day month weekday) evaluated in UTC, and it may not fire more often than every 5 minutes: publishing refuses * * * * * or */2 * * * *.
Data context.
| Field | Type | Notes |
|---|---|---|
firedAt | string | ISO-8601 fire time |
cron | string | The cron expression that fired |
{
"organizationId": "org_01H...",
"event": "schedule",
"kycRequestId": null,
"context": { "firedAt": "2026-05-31T09:00:00.000Z", "cron": "0 9 * * 1" }
}
Example workflow usage. Every Monday 09:00, generate a weekly verification summary and post it to Slack:
trigger:
event: schedule
schedule: "0 9 * * 1"
verification.expired
"A verification expires" · subject applicant
When it fires. From the expiry cron, when a request passes its expiry window.
Data context.
| Field | Type | Notes |
|---|---|---|
status | string | "EXPIRED" |
{
"organizationId": "org_01H...",
"event": "verification.expired",
"kycRequestId": "req_01J...",
"context": { "status": "EXPIRED" }
}
Example workflow usage. Email the applicant a fresh link and notify the owner when a verification expires.
monitoring.alert
"A monitoring alert fires" · org-wide
When it fires. From ongoing sanctions re-screening, when a previously-cleared subject newly matches a watchlist (continuous monitoring).
Data context.
| Field | Type | Notes |
|---|---|---|
alertType | string | The kind of monitoring alert |
severity | string | Alert severity |
topScore | number | null | Strongest new match score |
{
"organizationId": "org_01H...",
"event": "monitoring.alert",
"kycRequestId": "req_01J...",
"context": { "alertType": "sanctions_rescreen", "severity": "high",
"topScore": 0.88 }
}
Example workflow usage. On a high-severity monitoring alert, escalate the request and open a compliance case.
External / integration
inbound.webhook
"An inbound webhook arrives" · org-wide, arbitrary payload
When it fires. Your system POSTs to the workflow inbound-webhook endpoint. The context is whatever JSON you send, so you control the shape entirely. Not request-bound unless your payload supplies an id your workflow uses.
Endpoint. POST /api/v1/kyc/workflows/inbound, authenticated with an API key that has the workflows:trigger scope. No kyc:* scope includes it: tick Automation workflows → Trigger when you create or edit the key. Your plan must include Automation Workflows (otherwise 402). Send context (your JSON object, max 64 KB) and, optionally, eventId (your own id: retrying with the same eventId never starts the same workflow twice). The call returns 202 and the runs happen in the background. A key restricted to workflows only starts those workflows.
curl -X POST https://app.certivant.com/api/v1/kyc/workflows/inbound \
-H "Authorization: Bearer ck_live_..." \
-H "Content-Type: application/json" \
-d '{"eventId":"crm-9281-1","context":{"externalRef":"user-9281","tier":"vip"}}'
Data context. Arbitrary — the posted JSON body, verbatim.
{
"organizationId": "org_01H...",
"event": "inbound.webhook",
"context": { "externalRef": "user-9281", "tier": "vip", "source": "crm" }
}
Example workflow usage. Let your CRM kick off a re-verification batch for flagged customers; filter on a field you sent:
trigger:
event: inbound.webhook
filter: 'tier = "vip"'
conference.completed
"A live conference completes" · org-wide
When it fires. A live ID-verification web conference finishes and the host (notary/reviewer) records a decision. Emitted once, guarded so a concurrent decision can't double-fire.
Data context.
| Field | Type | Notes |
|---|---|---|
decision | string | The host's recorded decision |
status | string | The request's resulting status |
{
"organizationId": "org_01H...",
"event": "conference.completed",
"kycRequestId": "req_01J...",
"context": { "decision": "approve", "status": "APPROVED" }
}
Example workflow usage. After a conference, generate the signed Certificate of Verification and email it to the applicant.
Filtering with the DSL
The trigger filter (and every Condition node) uses the policy expression grammar — one boolean predicate, no function calls:
- Fields: the flattened event context (the fields shown in each trigger's Data context table).
- Operators:
=!=>>=<<=,IN/NOT IN. - Combinators:
ANDORNOT, with( )grouping. - Literals: numbers,
"strings",true/false/null, and[...]lists.
The equality operator is a single
=(not==). Missing fields are fail-safe: an absent field evaluates to false rather than throwing — so a filter referencingsanctions.topScoreon a non-sanctions event simply doesn't match.
Which fields are populated depends on the event: a sanctions.* event carries sanctions.*, a liveness.* event carries liveness.*, and decision events carry overallScore / decision / status / applicant.*.
| Field | Type | Available on |
|---|---|---|
overallScore | number | decision / status events |
decision | string | decision / status events |
status | string | decision / status / lifecycle events |
applicant.country | string | entry / decision events |
applicant.type | string | entry / decision events |
document.type | string | document.* |
document.side | string | document.* |
liveness.isLive | boolean | liveness.* |
liveness.confidence | number | liveness.* |
faceMatch.isMatch | boolean | face_match.* |
faceMatch.confidence | number | face_match.* |
sanctions.isMatch | boolean | sanctions.* |
sanctions.topScore | number | sanctions.* |
sanctions.matchCount | number | sanctions.* |
pep.isMatch | boolean | pep.match |
pep.matchCount | number | pep.match |
kyb.status | string | kyb.match_* |
kyb.uboCount | number | kyb.ubo_completed |
kyb.failedUboCount | number | kyb.ubo_completed |
reviewerId | string | reviewer.decision |
Example predicates:
overallScore >= 75
decision = "reject"
applicant.country IN ["CA","US"]
document.type = "passport"
sanctions.isMatch = true AND sanctions.topScore >= 0.85
overallScore < 50 OR sanctions.isMatch = true
In the editor, press Ctrl+Space in any expression field for context-aware autocomplete and a help popover of fields, operators, and ready-made examples.
Comparison table
| Trigger | Category | Subject | Request-bound | Frequency | Notes |
|---|---|---|---|---|---|
form.submitted | Entry | form | yes | per submission | Hosted forms only |
request.created | Entry | applicant | yes | per request | Before any check runs |
request.status_changed | Lifecycle | applicant | yes | per status change | Generic; pairs with specific events |
request.approved | Lifecycle | applicant | yes | per finalization | |
request.rejected | Lifecycle | applicant | yes | per finalization | |
request.escalated | Lifecycle | applicant | yes | per finalization | |
request.resubmitted | Lifecycle | applicant | yes | per resubmission | |
request.documents_ready | Lifecycle | applicant | yes | per checklist completion | All required docs uploaded |
document.uploaded | Documents | document | yes | per document | |
document.failed | Documents | document | yes | per failed document | |
check.completed | Signals | check | yes | per check (high) | Guarded; one per check |
liveness.passed | Signals | check | yes | per request | Unambiguous only |
liveness.failed | Signals | check | yes | per request | Unambiguous only |
face_match.passed | Signals | check | yes | per request | Unambiguous only |
face_match.failed | Signals | check | yes | per request | Unambiguous only |
sanctions.hit | Signals | check | yes | per request | Watchlist match |
sanctions.cleared | Signals | check | yes | per request | |
pep.match | Signals | check | yes | per request | PEP entries only |
kyb.match_found | Signals | — | yes | per business request | BUSINESS only |
kyb.match_not_found | Signals | — | yes | per business request | BUSINESS only |
kyb.ubo_completed | Signals | — | yes | per business request | BUSINESS only; UBOs resolved |
reviewer.decision | Review | — | yes | per manual decision | Stages + bulk |
reviewer.assigned | Review | — | yes | per reassignment | Explicit reassign only |
reviewer.comment_added | Review | — | yes | per comment | Body not in context |
edd.triggered | Review | applicant | yes | per EDD start | |
score.updated | Risk | applicant | yes | per (re)scoring | Fires with decision |
score.threshold | Risk | applicant | yes | per (re)scoring | Narrowed by scoreMin/scoreMax |
schedule | Time | — | no | cron | Org-level run |
verification.expired | Time | applicant | yes | per expiry | From expiry cron |
monitoring.alert | Monitoring | — | yes | per alert | Continuous screening |
inbound.webhook | External | — | optional | per POST | Arbitrary payload |
conference.completed | External | — | yes | per conference | Live ID sessions |
Roadmap triggers
A few commonly-requested triggers are not in the picker, because the platform has no signal to emit them yet — wiring them as selectable options would create triggers that never fire. They're listed here with the working alternative so the gap is explicit. (Everything in the picker, by contrast, is genuinely emitted — including score.threshold, face-match, PEP, UBO, and the reviewer events, which were wired in this release.)
| Requested trigger | Why it's not emitted | What to use instead |
|---|---|---|
| Fraud signal detected | No standalone forensics/authenticity check type exists yet (forensics is currently a risk-scoring factor, not a discrete check that emits a result). | Branch on document.failed, or on the decision events / score.threshold for the resulting risk. |
| Business registry data updated | KYB is a one-time verification; there is no ongoing registry re-fetch/monitoring job to fire from. | Re-run verification on a schedule and branch on kyb.match_* / kyb.ubo_completed. |
| Email / SMS link clicked | No email-open / link-click tracking infrastructure exists. | Use form.submitted / document.uploaded as the concrete "the applicant acted" signals. |
These are genuine roadmap items, not documentation gaps — the engine's trigger enum is the single source of truth, and this table reflects what it does not yet contain.
Best practices
- Always set a subject + scope. A trigger with no subject is org-wide and will fire for every matching object in the organization. Prefer
specificorcategoryscope; reserveallfor genuinely org-wide automations. - Listen to specific events, not the generic one, where possible. Use
liveness.failed/sanctions.hitover parsingcheck.completed, and a single decision event overrequest.status_changed— unless you specifically need a status with no dedicated event. - Don't listen to both the specific and generic status events.
request.approvedandrequest.status_changedboth fire on approval; binding two workflows (or two triggers) to the same outcome double-executes side effects. - Treat context as a snapshot. The event context is captured at dispatch. If your workflow waits (a
waitnode, a long delay), re-read live state inside an Action rather than trusting stale context for a critical decision. - Keep
check.completedworkflows lean. It's the highest-frequency trigger; scope it to a category and filter early so you don't fan out across every check on every request. - Mind orphaned scopes. Deleting a form/applicant referenced by a
specificscope makes the trigger dormant (it will not fire and shows a "Needs re-binding" badge). Re-bind it to a live object to reactivate. - Workflows escalate, never decide. If a trigger's outcome should change the verdict, route through
escalateto human review — there is intentionally no approve/reject action. - Expect at-least-once, eventual execution. Dispatch is asynchronous and fire-and-forget; make Action side effects idempotent where it matters (e.g. include an external ref so a re-fire doesn't duplicate downstream records).
FAQ
Can a workflow approve or reject a verification?
No. The Action set has no approve/reject. The strongest outcome control is escalate, which moves the request into human review via the existing optimistic-locked, audited state machine. The verdict is owned by the decision policy and human reviewers.
A specific decision and request.status_changed both fired — is that a bug?
No. The generic request.status_changed is always emitted alongside the specific event so you can react to statuses that have no dedicated event. Listen to one, not both.
Why didn't my check.completed workflow run?
The per-check fan-out is guarded: it only dispatches when an active workflow listens for check.completed. Confirm the workflow is active and its scope/filter matches the check. Also note errored checks with no usable boolean fact don't emit the specific liveness.* / sanctions.* signals.
Why isn't my score.threshold trigger firing?
It fires on every scored finalization, then the dispatcher narrows it by the trigger's scoreMin / scoreMax bounds. If it never runs, check that the score actually falls within your bounds, and that the request reached a scored outcome (a request cancelled before scoring emits no score event at all). With no bounds set it behaves like score.updated.
What's the difference between score.updated and score.threshold?
Same emission point and context. score.updated always runs; score.threshold only runs when overallScore falls within the trigger's scoreMin / scoreMax bounds — a built-in convenience so you don't have to write the overallScore filter yourself.
How do I trigger on a face-match or PEP result?
Use the dedicated face_match.passed / face_match.failed and pep.match events. A PEP hit also fires sanctions.hit (PEP screening rides on watchlist screening), so listen to pep.match when you only care about PEP exposure.
Does deleting a form disable its workflows?
A workflow whose trigger is specific-scoped to only that form becomes orphaned: it goes dormant (won't fire) and shows a "Needs re-binding" badge. Re-bind it to a live object to reactivate. It never silently falls back to firing org-wide.
Is dispatch synchronous with my API call? No. Dispatch is fire-and-forget and runs out of band. The originating action (upload, status change) succeeds regardless of whether the workflow dispatch or run succeeds.
Can one workflow trigger another?
Yes — the trigger_workflow action runs a chosen saved workflow directly, guarded to a maximum chain depth of 5 to prevent loops.
Appendix
Glossary
- Trigger — the single entry node declaring the event, subject, scope, and filter that start a workflow.
- Event — a
WorkflowTriggerEventthe platform emits at a real point in the verification lifecycle. - Subject — the object kind a trigger binds to:
form,applicant,document, orcheck. - Scope — how the subject is narrowed:
specific(ids),category, orall. - Context — the event-specific facts captured at dispatch and read by filters and Action templates.
- Filter — an optional DSL predicate that further narrows which events match.
- Orphaned trigger — a
specific-scoped trigger whose objects were deleted; dormant until re-bound. - Run — a
KycWorkflowRun: one execution of a workflow, with an append-onlylog.
Full event enum
type WorkflowTriggerEvent =
| "form.submitted" | "request.created"
| "request.status_changed" | "request.approved" | "request.rejected"
| "request.escalated" | "request.resubmitted" | "request.documents_ready"
| "document.uploaded" | "document.failed" | "check.completed"
| "liveness.passed" | "liveness.failed"
| "face_match.passed" | "face_match.failed"
| "sanctions.hit" | "sanctions.cleared" | "pep.match"
| "kyb.match_found" | "kyb.match_not_found" | "kyb.ubo_completed"
| "reviewer.decision" | "reviewer.assigned" | "reviewer.comment_added"
| "score.threshold" | "score.updated" | "edd.triggered"
| "monitoring.alert" | "verification.expired" | "schedule"
| "inbound.webhook" | "conference.completed";
Lifecycle of a dispatch
1. Something happens (form submit, check finishes, cron, webhook…)
2. Emitter calls enqueueWorkflowDispatch({ event, …, context })
3. workflow.worker picks it up → dispatchWorkflowEvent(input)
4. Match active workflows trigger.event === input.event
5. Narrow by subject + scope specific ids / category / all (orphans excluded)
6. Evaluate filter (DSL) against input.context (missing fields = false)
7. Create KycWorkflowRun context stashed on the first log entry
8. Execute nodes Condition → Action → Wait → … → End
End-to-end example
A complete onboarding automation bound to one KYC form:
trigger:
event: form.submitted
subject: form
scope: { mode: specific, ids: ["form_onboarding_01H..."] }
# graph (conceptual):
# ▸ Trigger: form.submitted
# → Action: email applicant "We received your submission"
# → Wait: until check.completed (or 24h timeout)
# → Condition: sanctions.isMatch = true
# true → Action: escalate + Action: notify compliance (Slack)
# false → Condition: overallScore >= 80
# true → Action: generate_pdf_report → email applicant
# false → Action: assign_department "manual-review"
# → End
This never sets the verdict: the only outcome-affecting step is escalate, which routes to human review. Everything else is notification, data collection, and reporting around the decision the policy and reviewers own.
Automation Workflows
Event-triggered, executable workflows that route, notify, and orchestrate around a verification — without ever setting the verdict.
Web SDK
Show a Certivant verification inside your own website with one script tag — modal or inline, with ready, step, complete, cancel and error events, strict origin checks and camera support.