Workflow Events

Workflow Events

Workflow events notify you about the lifecycle of Workflow Builder runs in your organization — when a run starts, when individual nodes complete, when contact resolution succeeds or fails, when a workflow is published, and when a run reaches a terminal state.

All events follow the standard webhook envelope (event, eventId, eventTimestamp, data). The envelope-level eventTimestamp records when the event was delivered to the envelope and is always present. Inside data, every workflow event carries recordId and organizationId, plus a stable set of correlation fields — the parent workflowId, the executing workflowVersionId, the candidateProfileId, your workflowExternalId and candidateProfileExternalId, and a timestamp recording when the event was emitted. A few of these correlation fields are intentionally omitted on specific event types — those exceptions are noted in the table below.

Available events

EventrecordId isDescription
WORKFLOW_RUN_STARTEDWorkflow run IDA workflow run has begun executing
WORKFLOW_RUN_COMPLETEDWorkflow run IDA workflow run reached the end of its graph successfully
WORKFLOW_RUN_FAILEDWorkflow run IDA workflow run failed and could not continue
WORKFLOW_RUN_CANCELLEDWorkflow run IDA workflow run was cancelled (via API or by archiving the workflow)
WORKFLOW_NODE_COMPLETEDWorkflow run IDA node in a run completed and produced an outcome
WORKFLOW_CONTACT_RESOLVEDWorkflow run IDAn inline contact was resolved to or created as a candidate profile
WORKFLOW_CONTACT_RESOLUTION_FAILEDA stable contact dedup keyAn inline contact could not be resolved into a candidate profile
WORKFLOW_CONTACT_RESOLUTION_SKIPPEDThe existing workflow run IDAn inline contact was skipped because they already had an active run
WORKFLOW_PUBLISHEDWorkflow IDA workflow was successfully published

Common correlation fields

Every workflow event's data block contains the following fields in addition to the per-event payload below:

FieldTypeDescription
recordIdstringIdentifier of the primary record for this event (see "Available events" above)
organizationIdstringYour organization ID
workflowIdstring | nullIdentifier of the parent workflow (absent on WORKFLOW_PUBLISHED — the workflow ID is in recordId)
workflowVersionIdstring | nullIdentifier of the workflow version that produced this event (absent on WORKFLOW_CONTACT_RESOLUTION_FAILED and WORKFLOW_CONTACT_RESOLUTION_SKIPPED)
workflowExternalIdstring | nullCaller-supplied external identifier on the workflow, when set
candidateProfileIdstring | nullIdentifier of the candidate profile (absent on WORKFLOW_CONTACT_RESOLUTION_FAILED and WORKFLOW_PUBLISHED)
candidateProfileExternalIdstring | nullCaller-supplied external identifier on the candidate profile, when set on the run-lifecycle events (WORKFLOW_RUN_* and WORKFLOW_NODE_COMPLETED). Not carried on the four contact-resolution / publish events — WORKFLOW_CONTACT_RESOLVED, WORKFLOW_CONTACT_RESOLUTION_SKIPPED, WORKFLOW_CONTACT_RESOLUTION_FAILED, or WORKFLOW_PUBLISHED (those events expose the contact's externalId directly in their per-event payload instead)
timestampstring | nullISO 8601 timestamp recording when the event was emitted. Carried on the run-lifecycle events (WORKFLOW_RUN_* and WORKFLOW_NODE_COMPLETED); absent on the four contact-resolution / publish events. Use the envelope-level eventTimestamp if you need a guaranteed timestamp on every event.

The per-event sections below show the additional fields each event type carries.

Node outcomes

Run-terminal events (WORKFLOW_RUN_COMPLETED, WORKFLOW_RUN_FAILED, WORKFLOW_RUN_CANCELLED) and WORKFLOW_NODE_COMPLETED carry a NodeOutcome shape that describes what each node produced. This is the canonical place to read what happened in a run.

{
  "nodeId": "screen-availability",
  "nodeType": "SCREENING_OUTREACH",
  "enteredAt": "2026-04-28T10:30:00.000Z",
  "completedAt": "2026-04-28T10:42:11.000Z",
  "routingEvent": "onComplete",
  "outcome": {
    "kind": "conversation",
    "conversationId": "conv_abc123",
    "status": "PASSED_CRITERIA",
    "completedAt": "2026-04-28T10:42:11.000Z",
    "answers": [
      {
        "slug": "nmc-pin",
        "answer": "Yes I'm registered",
        "answerState": "IS_YES",
        "trafficLight": "GREEN",
        "outcome": "PASSED",
        "score": 100
      }
    ]
  }
}

NodeOutcome fields

FieldTypeDescription
nodeIdstringIdentifier of the node within the workflow configuration
nodeTypestringThe workflow node type. One of: SCREENING_OUTREACH, ENGAGEMENT_OUTREACH, CONDITION
enteredAtstring (date-time)When execution entered this node
completedAtstring (date-time)When execution left this node
routingEventstringThe routing event the node emitted (e.g. onComplete, onConditionResolved)
outcome.kind"conversation", "condition", "empty"Discriminator for the per-node-type payload

Conversation outcome (outcome.kind: "conversation")

Emitted by screeningOutreach and engagementOutreach nodes.

FieldTypeDescription
conversationIdstringMessaging service conversation ID — correlate with the Conversations API
statusstring (enum)Workflow-domain summary of how the conversation ended (see outcome enum)
completedAtstring (date-time)When the conversation completed (optional)
answersarrayPer-question answers given under this node, keyed by question slug. Empty when no questions were answered (e.g. opted out before any reply).

WorkflowOutreachOutcome enum

The conversation status field uses a workflow-domain enum that is intentionally separate from the public Conversations API's conversationStatus — the workflow domain owns its own outcome contract. Correlate the two by conversationId, not by status string.

ValueMeaning
PASSED_CRITERIAThe candidate met the screening criteria the conversation was designed to test for.
FAILED_CRITERIAThe candidate did not meet the screening criteria.
COMPLETEDThe conversation ran its course or was closed without a specific pass/fail signal.
NOT_INTERESTEDThe candidate explicitly declined.
OPTED_OUTThe candidate opted out (e.g. SMS STOP).
DELIVERY_FAILEDDelivery failed across the available channels.

Answer fields

Each entry in answers[] represents a single question outcome:

FieldTypeDescription
slugstringStable question identifier from the workflow definition
answerstring | nullRaw answer text from the candidate
answerStatestring | nullClassification of the answer by the conversation engine. One of: IS_YES, IS_NO, IS_ANY, INCLUDES, DOES_NOT_INCLUDE, DOCUMENT_UPLOADED, DOCUMENT_NOT_UPLOADED, NEEDS_CLARIFICATION
trafficLightstring | nullTraffic-light evaluation of the answer. One of: GREEN, AMBER, RED
outcomestring | nullFinal outcome of the question. One of: PASSED, FAILED, NOT_COMPLETED, NEEDS_REVIEW, PENDING
scorenumber | nullPreferred-answer score as integer percentage 0–100 (only set when the question had hasPreferredAnswer: true)
documentS3Keystring | nullS3 key of the uploaded document (when the question accepted a document and was answered over email)
documentTwilioMediaSidstring | nullTwilio media SID of the uploaded document (WhatsApp/SMS channel)
classifiedDocumentTypestring | nullDocument type classified by the conversation engine when a document was uploaded (e.g. cv, passport, dbs-certificate)

Condition outcome (outcome.kind: "condition")

Emitted by condition nodes.

FieldTypeDescription
targetNodeIdstring | nullIdentifier of the node selected by the matched branch (or by else)
targetNodeLabelstring | nullDisplay label of the selected node, when available

The expression that produced the match is intentionally not surfaced — workflow definitions are private.

Empty outcome (outcome.kind: "empty")

Reserved for forward-compatibility. None of the node types available today (screeningOutreach, engagementOutreach, condition) produce empty under normal conditions — your runtime parser should accept it but you should not expect to see it. It exists to (a) leave room for future node types that do not carry structured outcome data (e.g. timed waits) and (b) act as a defensive fallback if the engine cannot resolve outcome data for a node it traversed. Payload carries only the kind discriminator.

Run-level outcomes

Run-terminal events (WORKFLOW_RUN_COMPLETED, WORKFLOW_RUN_FAILED, WORKFLOW_RUN_CANCELLED) carry a stable outcome field that summarises how the run ended:

ValueMeaning
completedThe run reached the end of its graph successfully
opted_outThe run terminated because the candidate opted out
delivery_failedThe run terminated because delivery failed across the available channels
cancelledThe run was cancelled (via API or by archiving the workflow)
failedThe run failed with an unrecoverable error

Branch on this enum rather than parsing free-text — values are stable.


WORKFLOW_RUN_STARTED

Triggered when a workflow run begins executing.

Payload

{
  "event": "WORKFLOW_RUN_STARTED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:30:00.000Z",
  "data": {
    "recordId": "run_xyz789",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowVersionId": "wfv_def456",
    "workflowExternalId": "cedar-rgn-screening",
    "candidateProfileId": "cp_abc123",
    "candidateProfileExternalId": "ats_42",
    "timestamp": "2026-04-28T10:30:00.000Z"
  }
}

This event carries no fields beyond the common correlation fields.


WORKFLOW_RUN_COMPLETED

Triggered when a workflow run reaches the end of its graph successfully. Carries the full traversal in nodes[].

Payload

{
  "event": "WORKFLOW_RUN_COMPLETED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:45:00.000Z",
  "data": {
    "recordId": "run_xyz789",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowVersionId": "wfv_def456",
    "workflowExternalId": "cedar-rgn-screening",
    "candidateProfileId": "cp_abc123",
    "candidateProfileExternalId": "ats_42",
    "timestamp": "2026-04-28T10:45:00.000Z",
    "startedAt": "2026-04-28T10:30:00.000Z",
    "completedAt": "2026-04-28T10:45:00.000Z",
    "finalNodeId": "engagement-booking-confirm",
    "outcome": "completed",
    "truncated": false,
    "nodes": [
      {
        "nodeId": "screen-availability",
        "nodeType": "SCREENING_OUTREACH",
        "enteredAt": "2026-04-28T10:30:00.000Z",
        "completedAt": "2026-04-28T10:42:11.000Z",
        "routingEvent": "onComplete",
        "outcome": {
          "kind": "conversation",
          "conversationId": "conv_abc123",
          "status": "PASSED_CRITERIA",
          "completedAt": "2026-04-28T10:42:11.000Z",
          "answers": [
            {
              "slug": "nmc-pin",
              "answer": "Yes I'm registered",
              "answerState": "IS_YES",
              "trafficLight": "GREEN",
              "outcome": "PASSED",
              "score": 100
            }
          ]
        }
      },
      {
        "nodeId": "engagement-booking-confirm",
        "nodeType": "ENGAGEMENT_OUTREACH",
        "enteredAt": "2026-04-28T10:42:11.000Z",
        "completedAt": "2026-04-28T10:45:00.000Z",
        "routingEvent": "onComplete",
        "outcome": {
          "kind": "conversation",
          "conversationId": "conv_def456",
          "status": "COMPLETED",
          "completedAt": "2026-04-28T10:45:00.000Z",
          "answers": []
        }
      }
    ]
  }
}

Additional fields

FieldTypeDescription
startedAtstringISO 8601 timestamp when the run began executing
completedAtstringISO 8601 timestamp when the run reached its terminal state
finalNodeIdstringIdentifier of the last node the run traversed (optional)
outcomestringRun-level outcome — see Run-level outcomes
truncatedbooleanTrue when the nodes[] traversal was truncated due to size limits
nodesarrayOrdered list of NodeOutcome objects describing each step

WORKFLOW_RUN_FAILED

Triggered when a workflow run cannot continue due to an unrecoverable error. The traversal up to and including the failing node is included in nodes[]. Failure detail beyond errorCode and lastFailedNodeId is intentionally not surfaced — raw exception text may carry internal IDs and is not safe to expose to customer endpoints.

Payload

{
  "event": "WORKFLOW_RUN_FAILED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:45:00.000Z",
  "data": {
    "recordId": "run_xyz789",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowVersionId": "wfv_def456",
    "workflowExternalId": "cedar-rgn-screening",
    "candidateProfileId": "cp_abc123",
    "candidateProfileExternalId": "ats_42",
    "timestamp": "2026-04-28T10:45:00.000Z",
    "startedAt": "2026-04-28T10:30:00.000Z",
    "completedAt": "2026-04-28T10:45:00.000Z",
    "finalNodeId": "screen-availability",
    "outcome": "failed",
    "errorCode": "DOWNSTREAM_SERVICE_ERROR",
    "lastFailedNodeId": "screen-availability",
    "truncated": false,
    "nodes": [
      {
        "nodeId": "screen-availability",
        "nodeType": "SCREENING_OUTREACH",
        "enteredAt": "2026-04-28T10:30:00.000Z",
        "completedAt": "2026-04-28T10:45:00.000Z",
        "routingEvent": "onDeliveryFailed",
        "outcome": {
          "kind": "conversation",
          "conversationId": "conv_abc123",
          "status": "DELIVERY_FAILED",
          "completedAt": "2026-04-28T10:45:00.000Z",
          "answers": []
        }
      }
    ]
  }
}

Additional fields

FieldTypeDescription
errorCodestringStable failure category. See values below.
errorClassstringCoarser-grained classifier than errorCodeDATA or SYSTEM. Optional; present only when the run failed via the retry-exhausted infrastructure path (typically alongside errorCode: "SYSTEM_FAILURE"). Lets consumers branch retry-vs-give-up without parsing errorCode. Absent on in-engine node-executor failures.
lastFailedNodeIdstringIdentifier of the node where the run failed (optional)

errorCode values

ValueMeaning
EXECUTOR_TIMEOUTA node exceeded its execution time limit (e.g. waiting for a candidate reply past the configured window).
INVALID_NODE_CONFIGRuntime detected a node configuration that is invalid for execution. Usually indicates a workflow-version issue that publish-time validation did not catch.
DOWNSTREAM_SERVICE_ERRORA downstream service the engine relies on (messaging, scheduling, analysis) returned an error and the run could not continue.
EXECUTOR_FAILEDA node executor failed for a reason that does not fit the more specific timeout / config / downstream classes. Investigate via lastFailedNodeId.
SYSTEM_FAILURERetry was exhausted on a transient infrastructure-class issue. Typically emitted with errorClass: "SYSTEM" — safe to retry the run via the API once the underlying issue is resolved.

Plus the run-terminal fields (startedAt, completedAt, finalNodeId, outcome, truncated, nodes).


WORKFLOW_RUN_CANCELLED

Triggered when a workflow run is cancelled — via the Cancel Workflow Run endpoint or as a side effect of archiving the parent workflow.

Payload

{
  "event": "WORKFLOW_RUN_CANCELLED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:45:00.000Z",
  "data": {
    "recordId": "run_xyz789",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowVersionId": "wfv_def456",
    "workflowExternalId": "cedar-rgn-screening",
    "candidateProfileId": "cp_abc123",
    "candidateProfileExternalId": "ats_42",
    "timestamp": "2026-04-28T10:45:00.000Z",
    "startedAt": "2026-04-28T10:30:00.000Z",
    "completedAt": "2026-04-28T10:45:00.000Z",
    "finalNodeId": "screen-availability",
    "outcome": "cancelled",
    "cancellationReason": "Candidate withdrew via ATS",
    "truncated": false,
    "nodes": [
      {
        "nodeId": "screen-availability",
        "nodeType": "SCREENING_OUTREACH",
        "enteredAt": "2026-04-28T10:30:00.000Z",
        "completedAt": "2026-04-28T10:45:00.000Z",
        "routingEvent": "onComplete",
        "outcome": {
          "kind": "conversation",
          "conversationId": "conv_abc123",
          "status": "COMPLETED",
          "completedAt": "2026-04-28T10:45:00.000Z",
          "answers": [
            {
              "slug": "nmc-pin",
              "answer": "Yes I'm registered",
              "answerState": "IS_YES",
              "trafficLight": "GREEN",
              "outcome": "PASSED",
              "score": 100
            }
          ]
        }
      }
    ]
  }
}

Additional fields

FieldTypeDescription
cancellationReasonstringReason supplied to the Cancel endpoint, when one was provided

Plus the run-terminal fields.


WORKFLOW_NODE_COMPLETED

Triggered when a node in a workflow run completes and produces an outcome. Use this event for fine-grained progress tracking; for end-of-run summaries, prefer the run-terminal events which include the full traversal.

Payload

{
  "event": "WORKFLOW_NODE_COMPLETED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:42:11.000Z",
  "data": {
    "recordId": "run_xyz789",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowVersionId": "wfv_def456",
    "workflowExternalId": "cedar-rgn-screening",
    "candidateProfileId": "cp_abc123",
    "candidateProfileExternalId": "ats_42",
    "timestamp": "2026-04-28T10:42:11.000Z",
    "nodeId": "screen-availability",
    "nodeType": "SCREENING_OUTREACH",
    "enteredAt": "2026-04-28T10:30:00.000Z",
    "completedAt": "2026-04-28T10:42:11.000Z",
    "routingEvent": "onComplete",
    "outcome": {
      "kind": "conversation",
      "conversationId": "conv_abc123",
      "status": "PASSED_CRITERIA",
      "completedAt": "2026-04-28T10:42:11.000Z",
      "answers": [
        {
          "slug": "nmc-pin",
          "answer": "Yes I'm registered",
          "answerState": "IS_YES",
          "trafficLight": "GREEN",
          "outcome": "PASSED",
          "score": 100
        }
      ]
    }
  }
}

Additional fields

The per-node fields (nodeId, nodeType, enteredAt, completedAt, routingEvent, outcome) are flat on the event payload — they are not wrapped in a node object. The shape is otherwise identical to a single entry in a run-terminal event's nodes[] array, so consumers can reuse the same parser. Receivers subscribed to both per-node and run-terminal events should deduplicate by nodeId and the run's recordId.


WORKFLOW_CONTACT_RESOLVED

Triggered when an inline contact supplied to Start Workflow Runs is resolved to or created as a candidate profile, ahead of the run starting. The recordId is the new run's ID, so this event correlates with the matching WORKFLOW_RUN_STARTED that follows.

Payload

{
  "event": "WORKFLOW_CONTACT_RESOLVED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:29:55.000Z",
  "data": {
    "recordId": "run_xyz789",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowExternalId": "cedar-rgn-screening",
    "workflowVersionId": "wfv_def456",
    "versionNumber": 4,
    "candidateProfileId": "cp_abc123",
    "externalId": "ats_42",
    "email": "[email protected]",
    "phoneNumber": "+447700900000",
    "isNewProfile": false,
    "contactIndex": 0
  }
}

Additional fields

FieldTypeDescription
versionNumberintegerNumeric version of the workflow this run will execute against
externalIdstring | nullThe external identifier supplied with the inline contact (when set)
emailstring | nullContact email
phoneNumberstring | nullContact phone in E.164 format
isNewProfilebooleanTrue when a new candidate profile was created; false when an existing profile matched
contactIndexintegerZero-based index of this contact within the original Start Workflow Runs request

WORKFLOW_CONTACT_RESOLUTION_SKIPPED

Triggered when an inline contact resolves to a candidate who already has an active run on this workflow version. The duplicate run is not started; the event's recordId points at the existing run so consumers can correlate.

Payload

{
  "event": "WORKFLOW_CONTACT_RESOLUTION_SKIPPED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:29:55.000Z",
  "data": {
    "recordId": "run_existing_xyz",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowExternalId": "cedar-rgn-screening",
    "candidateProfileId": "cp_abc123",
    "externalId": "ats_42",
    "email": "[email protected]",
    "phoneNumber": "+447700900000",
    "existingRunStatus": "RUNNING",
    "contactIndex": 0
  }
}

Additional fields

FieldTypeDescription
existingRunStatusstringStatus of the run that already exists for this candidate (RUNNING or COMPLETED)
externalIdstring | nullExternal identifier supplied with the inline contact
emailstring | nullContact email
phoneNumberstring | nullContact phone in E.164 format
contactIndexintegerZero-based index of this contact within the original Start Workflow Runs request

WORKFLOW_CONTACT_RESOLUTION_FAILED

Triggered when an inline contact cannot be resolved into a candidate profile after the start-run queue retry budget is exhausted. The recordId is a stable contact dedup key (not a workflow run ID — no run was created). The detail-type itself communicates the failure; raw error text is intentionally not surfaced.

Payload

{
  "event": "WORKFLOW_CONTACT_RESOLUTION_FAILED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:29:55.000Z",
  "data": {
    "recordId": "contact_dedup_abc123",
    "organizationId": "org_123456",
    "workflowId": "wf_abc123",
    "workflowExternalId": "cedar-rgn-screening",
    "externalId": "ats_42",
    "email": "invalid@example",
    "phoneNumber": null,
    "contactIndex": 0
  }
}

Additional fields

FieldTypeDescription
externalIdstring | nullExternal identifier supplied with the inline contact
emailstring | nullContact email
phoneNumberstring | nullContact phone in E.164 format
contactIndexintegerZero-based index of this contact within the original Start Workflow Runs request

This event does not carry candidateProfileId, candidateProfileExternalId, or workflowVersionId — no candidate profile was resolved and the failure may have occurred outside the scope of a specific version.


WORKFLOW_PUBLISHED

Triggered when a workflow is successfully published — the working draft becomes the new live version. The recordId is the workflow ID. Use this to mirror workflow versions into your own audit log.

Payload

{
  "event": "WORKFLOW_PUBLISHED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T09:00:00.000Z",
  "data": {
    "recordId": "wf_abc123",
    "organizationId": "org_123456",
    "workflowExternalId": "cedar-rgn-screening",
    "workflowVersionId": "wfv_def456",
    "versionNumber": 4,
    "publishedByUserId": "user_abc123",
    "publishedAt": "2026-04-28T09:00:00.000Z"
  }
}

Additional fields

FieldTypeDescription
versionNumberintegerNumeric version assigned to the new published version
publishedByUserIdstringIdentifier of the user that published this version
publishedAtstringISO 8601 timestamp when the version became live

This event does not carry candidateProfileId, candidateProfileExternalId, or workflowId (the workflow ID is in recordId).


What's supported

  • Workflow runs are triggered via the API: POST /v1/workflows/{workflowId}/runs with triggerSource: "API".
  • Three node types are available: screeningOutreach, engagementOutreach, and condition. See the Workflow Configuration guide.
  • Failures surface via WORKFLOW_RUN_FAILED's lastFailedNodeId and errorCode fields — there is no separate per-node failure event.

Reliability

  • Workflow webhooks follow the same delivery model as other Popp webhooks: HMAC-SHA256 signature verification (see Authentication), at-least-once delivery with retries, and a dead-letter queue on persistent failure.
  • Use eventId for idempotency — events may be redelivered.
  • Run-terminal events (WORKFLOW_RUN_COMPLETED / _FAILED / _CANCELLED) are the canonical record of what happened in a run. Per-node events (WORKFLOW_NODE_COMPLETED) are useful for live progress UIs but should not be the source of truth.
  • WORKFLOW_CONTACT_RESOLUTION_FAILED fires exactly once per terminally-failed contact, after the start-run queue retry budget is exhausted.

Example: handling workflow events

type WorkflowOutreachOutcome =
  | 'PASSED_CRITERIA'
  | 'COMPLETED'
  | 'FAILED_CRITERIA'
  | 'NOT_INTERESTED'
  | 'OPTED_OUT'
  | 'DELIVERY_FAILED';

type RunOutcome =
  | 'completed'
  | 'opted_out'
  | 'delivery_failed'
  | 'cancelled'
  | 'failed';

interface AnswerOutcome {
  slug: string;
  answer?: string;
  answerState?: string;
  trafficLight?: string;
  outcome?: string;
  score?: number;
  documentS3Key?: string;
  documentTwilioMediaSid?: string;
  classifiedDocumentType?: string;
}

interface NodeOutcome {
  nodeId: string;
  nodeType: 'SCREENING_OUTREACH' | 'ENGAGEMENT_OUTREACH' | 'CONDITION';
  enteredAt: string;
  completedAt: string;
  routingEvent: string;
  outcome:
    | { kind: 'conversation'; conversationId: string; status: WorkflowOutreachOutcome; completedAt?: string; answers: AnswerOutcome[] }
    | { kind: 'condition'; targetNodeId: string | null; targetNodeLabel: string | null }
    | { kind: 'empty' };
}

interface WorkflowRunTerminalPayload {
  recordId: string;
  organizationId: string;
  workflowId: string;
  workflowVersionId: string;
  workflowExternalId?: string | null;
  candidateProfileId: string;
  candidateProfileExternalId?: string | null;
  timestamp: string;
  startedAt: string;
  completedAt: string;
  finalNodeId?: string;
  outcome: RunOutcome;
  truncated: boolean;
  nodes: NodeOutcome[];
  errorCode?: 'EXECUTOR_TIMEOUT' | 'INVALID_NODE_CONFIG' | 'DOWNSTREAM_SERVICE_ERROR' | 'UNKNOWN';
  lastFailedNodeId?: string;
  cancellationReason?: string;
}

function handleWorkflowWebhook(payload: {
  event: string;
  eventId: string;
  eventTimestamp: string;
  data: any;
}) {
  switch (payload.event) {
    case 'WORKFLOW_RUN_STARTED':
      markRunStarted(payload.data.recordId);
      break;

    case 'WORKFLOW_RUN_COMPLETED':
    case 'WORKFLOW_RUN_FAILED':
    case 'WORKFLOW_RUN_CANCELLED': {
      const data = payload.data as WorkflowRunTerminalPayload;
      // Branch on the stable run-level outcome enum, not on the event name.
      persistRunOutcome(data.recordId, data.outcome, data.nodes);
      break;
    }

    case 'WORKFLOW_PUBLISHED':
      mirrorWorkflowVersion(payload.data.recordId, payload.data.workflowVersionId);
      break;
  }
}