Automation WorkflowsTriggers

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 context object 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:

FieldTypePresent when
organizationIdstringalways
eventWorkflowTriggerEventalways
kycRequestIdstring | nullthe event concerns a specific verification (most events)
formIdstring | nullform.submitted
formKind"kyc" | "kyb" | nullform.submitted
applicantIdstring | nullapplicant-scoped events
documentTypestring | nulldocument.*
checkKindstring | nullcheck.completed, liveness.*, sanctions.*
contextobjectevent-specific facts (see each trigger below)
workflowIdstringonly on a trigger_workflow direct invocation
chainDepthnumberworkflow-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.

SubjectValid eventsscope.mode optionscategory meaning
formform.submittedspecific (form ids) · category · all"kyc" or "kyb"
applicantrequest.*, score.threshold, edd.triggered, verification.expiredspecific (applicant ids) · all—
documentdocument.uploaded, document.failedspecific · category · alla document type, e.g. "passport"
checkcheck.completed, liveness.*, sanctions.*specific · category · alla check kind, e.g. "sanctions"
(none)schedule, inbound.webhook, monitoring.alert, kyb.*, reviewer.decision, conference.completed—org-wide

Scope modes:

  • specific — match only the listed ids (e.g. fire only for this onboarding form). If every id in a specific scope 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

CategoryTriggers
Entryform.submitted, request.created
Lifecycle / statusrequest.status_changed, request.approved, request.rejected, request.escalated, request.resubmitted, request.documents_ready
Documentsdocument.uploaded, document.failed
Verification signalscheck.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 reviewreviewer.decision, reviewer.assigned, reviewer.comment_added, edd.triggered
Risk / scorescore.threshold, score.updated
Time / monitoringschedule, verification.expired, monitoring.alert
External / integrationinbound.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.

FieldTypeNotes
typestringApplicant 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.

FieldTypeNotes
applicant.countrystring | nullISO-3166 alpha-2, from applicant nationality
applicant.typestring"INDIVIDUAL" or "BUSINESS"
typestringRequest 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):

FieldTypeNotes
statusstringNew status, e.g. "APPROVED", "REJECTED", "ESCALATED", "PENDING_SECOND_REVIEW"
decisionstring | nullScorer verdict: "approve" / "reject" / "escalate"
overallScorenumber | nullTrust score 0–100 (higher = lower risk)
applicant.countrystring | nullISO-3166 alpha-2
applicant.typestringINDIVIDUAL / 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.

FieldTypeNotes
statusstring"PENDING_UPLOAD"
kindstring"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.

FieldTypeNotes
statusstring"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.

FieldTypeNotes
document.typestringe.g. "passport", "id_card", "drivers_license"
document.sidestring | 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.

FieldTypeNotes
check.typestringCheck kind, e.g. "sanctions", "face_match", "ocr"
check.statusstring"passed" / "failed" / "error"
check.providerstring | nullProvider that ran the check
check.scorenumber | nullProvider 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.

FieldTypeNotes
liveness.isLivebooleanWhether the applicant passed liveness
liveness.confidencenumber | nullConfidence 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.

FieldTypeNotes
faceMatch.isMatchbooleanWhether the selfie matched the document face
faceMatch.confidencenumber | nullConfidence 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.

FieldTypeNotes
sanctions.isMatchbooleanTrue on a hit
sanctions.matchCountnumberNumber of watchlist matches
sanctions.topScorenumber | nullStrongest match score 0–1
sanctions.listsCheckedstring[]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.

FieldTypeNotes
pep.isMatchbooleanAlways true (the event only fires on a PEP match)
pep.matchCountnumberNumber 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.

FieldTypeNotes
kyb.statusstring"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.

FieldTypeNotes
kyb.uboCountnumberNumber of ultimate beneficial owners screened
kyb.failedUboCountnumberHow 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.

FieldTypeNotes
decisionstringThe reviewer's choice
reviewerIdstringId of the deciding reviewer
stagestring | undefined"first_approval" / "second_approval" on two-reviewer flows
viastring | 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.

FieldTypeNotes
assignedUserIdstringThe new owner/reviewer
previousUserIdstring | nullThe prior owner
teamIdstring | nullThe 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.

FieldTypeNotes
commentIdstringThe created comment id
authorIdstringThe commenting reviewer
authorNamestringDisplay 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.

FieldTypeNotes
kindstring"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 / scoreMax are inclusive. Set one or both. Leaving both unset makes score.threshold behave like score.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.

FieldTypeNotes
firedAtstringISO-8601 fire time
cronstringThe 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.

FieldTypeNotes
statusstring"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.

FieldTypeNotes
alertTypestringThe kind of monitoring alert
severitystringAlert severity
topScorenumber | nullStrongest 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.

FieldTypeNotes
decisionstringThe host's recorded decision
statusstringThe 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: AND OR NOT, 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 referencing sanctions.topScore on 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.*.

FieldTypeAvailable on
overallScorenumberdecision / status events
decisionstringdecision / status events
statusstringdecision / status / lifecycle events
applicant.countrystringentry / decision events
applicant.typestringentry / decision events
document.typestringdocument.*
document.sidestringdocument.*
liveness.isLivebooleanliveness.*
liveness.confidencenumberliveness.*
faceMatch.isMatchbooleanface_match.*
faceMatch.confidencenumberface_match.*
sanctions.isMatchbooleansanctions.*
sanctions.topScorenumbersanctions.*
sanctions.matchCountnumbersanctions.*
pep.isMatchbooleanpep.match
pep.matchCountnumberpep.match
kyb.statusstringkyb.match_*
kyb.uboCountnumberkyb.ubo_completed
kyb.failedUboCountnumberkyb.ubo_completed
reviewerIdstringreviewer.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

TriggerCategorySubjectRequest-boundFrequencyNotes
form.submittedEntryformyesper submissionHosted forms only
request.createdEntryapplicantyesper requestBefore any check runs
request.status_changedLifecycleapplicantyesper status changeGeneric; pairs with specific events
request.approvedLifecycleapplicantyesper finalization
request.rejectedLifecycleapplicantyesper finalization
request.escalatedLifecycleapplicantyesper finalization
request.resubmittedLifecycleapplicantyesper resubmission
request.documents_readyLifecycleapplicantyesper checklist completionAll required docs uploaded
document.uploadedDocumentsdocumentyesper document
document.failedDocumentsdocumentyesper failed document
check.completedSignalscheckyesper check (high)Guarded; one per check
liveness.passedSignalscheckyesper requestUnambiguous only
liveness.failedSignalscheckyesper requestUnambiguous only
face_match.passedSignalscheckyesper requestUnambiguous only
face_match.failedSignalscheckyesper requestUnambiguous only
sanctions.hitSignalscheckyesper requestWatchlist match
sanctions.clearedSignalscheckyesper request
pep.matchSignalscheckyesper requestPEP entries only
kyb.match_foundSignals—yesper business requestBUSINESS only
kyb.match_not_foundSignals—yesper business requestBUSINESS only
kyb.ubo_completedSignals—yesper business requestBUSINESS only; UBOs resolved
reviewer.decisionReview—yesper manual decisionStages + bulk
reviewer.assignedReview—yesper reassignmentExplicit reassign only
reviewer.comment_addedReview—yesper commentBody not in context
edd.triggeredReviewapplicantyesper EDD start
score.updatedRiskapplicantyesper (re)scoringFires with decision
score.thresholdRiskapplicantyesper (re)scoringNarrowed by scoreMin/scoreMax
scheduleTime—nocronOrg-level run
verification.expiredTimeapplicantyesper expiryFrom expiry cron
monitoring.alertMonitoring—yesper alertContinuous screening
inbound.webhookExternal—optionalper POSTArbitrary payload
conference.completedExternal—yesper conferenceLive 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 triggerWhy it's not emittedWhat to use instead
Fraud signal detectedNo 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 updatedKYB 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 clickedNo 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 specific or category scope; reserve all for genuinely org-wide automations.
  • Listen to specific events, not the generic one, where possible. Use liveness.failed / sanctions.hit over parsing check.completed, and a single decision event over request.status_changed — unless you specifically need a status with no dedicated event.
  • Don't listen to both the specific and generic status events. request.approved and request.status_changed both 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 wait node, a long delay), re-read live state inside an Action rather than trusting stale context for a critical decision.
  • Keep check.completed workflows 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 specific scope 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 escalate to 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 WorkflowTriggerEvent the platform emits at a real point in the verification lifecycle.
  • Subject — the object kind a trigger binds to: form, applicant, document, or check.
  • Scope — how the subject is narrowed: specific (ids), category, or all.
  • 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-only log.

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.