Workflow Events

Workflow events tell you when a run starts, when a node completes, when Popp resolves a candidate you sent to start a run, when a workflow is published, and when a run ends.

Every event uses the standard webhook envelope (event, eventId, eventTimestamp, data). eventTimestamp is when Popp prepared the webhook, and every event has it. Every event's data carries the common correlation fields.

Available events

EventrecordId isDescription
WORKFLOW_RUN_STARTEDWorkflow run IDA run started
WORKFLOW_RUN_COMPLETEDWorkflow run IDA run completed
WORKFLOW_RUN_FAILEDWorkflow run IDA run failed and could not continue
WORKFLOW_RUN_CANCELLEDWorkflow run IDA run was cancelled with the Cancel workflow run endpoint, or by Popp
WORKFLOW_NODE_COMPLETEDWorkflow run IDA node in a run completed
WORKFLOW_CONTACT_RESOLVEDWorkflow run IDPopp matched a candidate you sent to a candidate profile, or created one
WORKFLOW_CONTACT_RESOLUTION_FAILEDA key for the contact's email and phone numberPopp could not match or create a candidate profile for a candidate you sent
WORKFLOW_CONTACT_RESOLUTION_SKIPPEDThe existing workflow run IDPopp skipped a candidate who already has a run on this workflow, in any status
WORKFLOW_PUBLISHEDWorkflow IDA workflow was published

Common correlation fields

Every event's data carries these fields, except where a row says otherwise. Each event's section lists its other fields.

FieldTypeDescription
recordIdstringThe event's main record. See Available events.
organizationIdstringYour organization ID
workflowIdstringThe workflow. Absent on WORKFLOW_PUBLISHED, which carries it in recordId.
workflowVersionIdstringThe workflow version that produced the event. Absent on WORKFLOW_CONTACT_RESOLUTION_FAILED and WORKFLOW_CONTACT_RESOLUTION_SKIPPED.
workflowExternalIdstring | nullYour external ID for the workflow, when set
candidateProfileIdstring | nullThe candidate profile. null on WORKFLOW_CONTACT_RESOLUTION_FAILED for an inline contact. Absent on WORKFLOW_PUBLISHED.
candidateProfileExternalIdstring | nullYour external ID for the candidate profile, when set. Only on WORKFLOW_RUN_* and WORKFLOW_NODE_COMPLETED. The contact-resolution events carry the contact's externalId instead.
timestampstringWhen the event was emitted (ISO 8601). Only on WORKFLOW_RUN_* and WORKFLOW_NODE_COMPLETED. Use eventTimestamp for a timestamp on every event.

Node outcomes

Run-terminal events (WORKFLOW_RUN_COMPLETED, WORKFLOW_RUN_FAILED, WORKFLOW_RUN_CANCELLED) and WORKFLOW_NODE_COMPLETED carry a NodeOutcome for each node: what the node produced.

{
  "nodeId": "screen-availability",
  "nodeSlug": "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
nodeSlugstring | nullBeta. The node's slug, which names the same node in every version. null for a node published without one.
nodeTypestringThe workflow node type. One of: SCREENING_OUTREACH, ENGAGEMENT_OUTREACH, CONDITION, ANALYSIS, CREATE_ATS_APPLICATION, UPDATE_ATS_APPLICATION, SCHEDULING_OUTREACH
enteredAtstring (date-time)When the run entered the node. Always in nodes[]. WORKFLOW_NODE_COMPLETED leaves it out for outreach, scheduling and analysis nodes.
completedAtstring (date-time)When the run left the node
routingEventstringThe routing event the node emitted (e.g. onComplete, onConditionResolved)
outcome.kind"conversation", "condition", "analysis", "analysisFailure", "atsApplicationCreate", "atsApplicationUpdate", "meeting", "empty"The outcome type. Each type's fields are below.

Conversation outcome (outcome.kind: "conversation")

Emitted by screeningOutreach and engagementOutreach nodes.

FieldTypeDescription
conversationIdstringThe conversation's ID in the Conversations API
statusstring (enum)How the conversation ended. See WorkflowOutreachOutcome.
completedAtstring (date-time)When the conversation completed (optional)
answersarrayOne entry per answered question, keyed by question slug. Empty when the candidate answered nothing, for example after opting out before replying.

WorkflowOutreachOutcome enum

status is not the Conversations API's conversationStatus. To match a conversation across the two, use conversationId, not the status value.

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.
NO_REPLYThe conversation closed because the candidate stopped replying. The node's routingEvent is onNoReply.

Answer fields

Each entry in answers[] is one question's outcome. Every field but slug is optional. Popp leaves out a field that has no value, and never sends it as null.

FieldTypeDescription
slugstringThe question's slug from the workflow configuration
answerstringThe candidate's answer text
answerStatestringHow Popp classified the answer. One of: IS_YES, IS_NO, IS_ANY, INCLUDES, DOES_NOT_INCLUDE, DOCUMENT_UPLOADED, DOCUMENT_NOT_UPLOADED, NEEDS_CLARIFICATION
trafficLightstringTraffic-light rating of the answer. One of: GREEN, AMBER, RED
outcomestringFinal outcome of the question. One of: PASSED, FAILED, NOT_COMPLETED, NEEDS_REVIEW, PENDING, INCOMPLETE. INCOMPLETE means Popp could not classify a yes/no answer, and comes with answerState: NEEDS_CLARIFICATION.
scorenumberPreferred-answer score, a whole number from 0 to 100. Set only when the question has preferredAnswerText.
documentS3KeystringStorage key for the uploaded document (when the question accepted a document and was answered over email)
documentTwilioMediaSidstringMedia identifier for the uploaded document (WhatsApp / SMS channel)
classifiedDocumentTypestringThe document type Popp detected in an uploaded document, for example cv, passport or 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 event does not include the expression that matched.

Analysis outcome (outcome.kind: "analysis")

Emitted when the run leaves an analysis node by onComplete. If the node has a node after it, that happens once the applicant is scored. At an analysis node with nothing after it, it happens when a publish connects a node after it and the run moves on, and score is the score the run holds then.

FieldTypeDescription
scorenumberOverall analysis score, as a whole number from 0 to 100
requirementsMetbooleanWhether the candidate met every mandatory requirement
{
  "nodeId": "score-cv",
  "nodeSlug": "score-cv",
  "nodeType": "ANALYSIS",
  "enteredAt": "2026-04-28T10:30:00.000Z",
  "completedAt": "2026-04-28T10:31:05.000Z",
  "routingEvent": "onComplete",
  "outcome": {
    "kind": "analysis",
    "score": 86,
    "requirementsMet": true
  }
}

score is the candidate's totalScoreScaled from analysis events as a percentage: 0.86 there is 86 here. requirementsMet matches isAllMandatoryRequirementsMet.

If the candidate cannot be scored and the node has an onAnalysisFailed route, the node completes with an analysisFailure outcome. Without that route the node does not complete, and the run fails with CV_TOO_LARGE, ANALYSIS_INPUT_UNAVAILABLE or ANALYSIS_FAILED.

Analysis failure outcome (outcome.kind: "analysisFailure")

Emitted by analysis nodes that could not score the candidate, when the run leaves by the node's onAnalysisFailed route. The node's routingEvent is onAnalysisFailed.

FieldTypeDescription
analysisErrorCodestringWhy the candidate could not be scored: DOCUMENT_TOO_LARGE, ANALYSIS_INPUT_UNAVAILABLE or ANALYSIS_FAILED. See Branching on a failure.
{
  "nodeId": "score-cv",
  "nodeSlug": "score-cv",
  "nodeType": "ANALYSIS",
  "enteredAt": "2026-04-28T10:30:00.000Z",
  "completedAt": "2026-04-28T10:31:05.000Z",
  "routingEvent": "onAnalysisFailed",
  "outcome": {
    "kind": "analysisFailure",
    "analysisErrorCode": "DOCUMENT_TOO_LARGE"
  }
}

Add to job outcome (outcome.kind: "atsApplicationCreate")

Emitted by createAtsApplication (Add to job) nodes.

FieldTypeDescription
decisionstring (enum)What the node did: created (the candidate was added to the destination job) or already_on_job (the ATS said they were already on it, so nothing was created).

Both values are successes: the node completes and the run continues. Running the same candidate through the node again returns already_on_job.

The outcome does not repeat the destination job and stage. Read them from the node's configuration.

Adding a candidate to an ATS job can start a workflow, campaign or analysis connected to that job. If that starts another workflow run, its events follow this one.

ATS application update outcome (outcome.kind: "atsApplicationUpdate")

Emitted by updateAtsApplication nodes.

FieldTypeDescription
decisionstring (enum)updated: your ATS accepted the change. skipped_no_ats_application: the run had no ATS application to update.
{
  "nodeId": "advance-in-ats",
  "nodeSlug": "advance-in-ats",
  "nodeType": "UPDATE_ATS_APPLICATION",
  "enteredAt": "2026-04-28T10:42:12.000Z",
  "completedAt": "2026-04-28T10:42:14.000Z",
  "routingEvent": "onComplete",
  "outcome": {
    "kind": "atsApplicationUpdate",
    "decision": "updated"
  }
}

The outcome does not name the stage or reason. Read them from the node's configuration. updated also covers an application that was already in the requested state.

skipped_no_ats_application is not a failure. A run that entered through a non-ATS pathway, such as the API, has no application to update, so Popp skips the node and the run continues.

Meeting outcome (outcome.kind: "meeting")

Emitted by schedulingOutreach nodes when the candidate books a meeting. The node's routingEvent is onComplete.

FieldTypeDescription
meetingIdstringIdentifier of the booked meeting
scheduledAtstring (date-time)Start time of the meeting
statusstring (enum)Optional. BOOKED: the candidate booked; RESCHEDULING_REQUESTED: a reschedule was requested, and the meeting keeps its time until the candidate picks a new one; RESCHEDULED: the meeting's start time changed; CANCELLED: the meeting was cancelled.
{
  "nodeId": "book-interview",
  "nodeSlug": "book-interview",
  "nodeType": "SCHEDULING_OUTREACH",
  "enteredAt": "2026-10-01T13:58:00.000Z",
  "completedAt": "2026-10-01T14:12:00.000Z",
  "routingEvent": "onComplete",
  "outcome": {
    "kind": "meeting",
    "meetingId": "9d2f6c1e-4b7a-4f0e-9c3d-2a5b8e7f1a60",
    "scheduledAt": "2026-10-06T09:00:00.000Z",
    "status": "BOOKED"
  }
}

The outcome includes status when a meeting status is recorded, including BOOKED at booking. If none is recorded, the field is omitted. To follow later changes, subscribe to the CALENDAR_MEETING_CANCELLED and CALENDAR_MEETING_RESCHEDULED scheduling events. A reschedule request has no scheduling event. Read it from meetings in the run's context on GET /v1/workflow-runs/{runId}: the status stays RESCHEDULING_REQUESTED until the candidate picks a new time or cancels.

If the node ends without a booking, the outcome is empty. Read routingEvent to see how the node ended.

Empty outcome (outcome.kind: "empty")

Carries only kind. A schedulingOutreach node sends empty when it ends without a booking. Other node types send it only as a fallback. Accept it from every node type.

Run-level outcomes

Run-terminal events (WORKFLOW_RUN_COMPLETED, WORKFLOW_RUN_FAILED, WORKFLOW_RUN_CANCELLED) carry an outcome that says how the run ended:

ValueMeaning
completedThe run reached the end of its graph
opted_outThe candidate opted out
delivery_failedMessages could not be delivered on any available channel
no_responseThe conversation closed without the reply or booking the run was waiting for
cancelledThe run was cancelled with the Cancel workflow run endpoint, or by Popp
failedThe run hit an error it could not recover from

These values are stable. Branch on them.

These values appear in the run-terminal webhooks. Get workflow run returns the run's status and finishReason in their place. See Run lifecycle.

For a run that ends on a schedulingOutreach node, completed covers a booking (onComplete) and a decline (onNotInterested). Read the last node's routingEvent to tell them apart.


WORKFLOW_RUN_STARTED

Sent when a run starts.

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

Sent when a run completes: it leaves a node, and the node's routing names no node for the event it left by. nodes[] lists every node the run passed through.

An analysis node with nothing after it holds the run instead, so this event doesn't fire there. It fires later if the run moves on and completes at a later node.

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",
    "finalNodeSlug": "engagement-booking-confirm",
    "outcome": "completed",
    "truncated": false,
    "nodes": [
      {
        "nodeId": "screen-availability",
        "nodeSlug": "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",
        "nodeSlug": "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
startedAtstringWhen the run started (ISO 8601)
completedAtstringWhen the run ended (ISO 8601)
finalNodeIdstringThe last node the run passed through (optional)
finalNodeSlugstring | nullBeta. The slug of the last node, or null for a node published without one. Sent with finalNodeId.
outcomestringSee Run-level outcomes
truncatedbooleantrue when nodes[] is incomplete: the run had too many nodes to list, or Popp could not read part of its history. Treat nodes[] as partial.
nodesarrayThe run's nodes in order, each a NodeOutcome

WORKFLOW_RUN_FAILED

Sent when a run hits an error it cannot recover from. A failed node sends no WORKFLOW_NODE_COMPLETED, so this event is where failures appear. nodes[] lists the nodes the run completed before it failed. finalNodeId is the last node the run completed, or the failed node if it completed none. lastFailedNodeId names the node that failed and errorCode gives the reason; the underlying error text is not included. A failed run can't be retried.

Payload

{
  "event": "WORKFLOW_RUN_FAILED",
  "eventId": "evt_abc123",
  "eventTimestamp": "2026-04-28T10:42:14.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:14.000Z",
    "startedAt": "2026-04-28T10:30:00.000Z",
    "completedAt": "2026-04-28T10:42:14.000Z",
    "finalNodeId": "screen-availability",
    "finalNodeSlug": "screen-availability",
    "outcome": "failed",
    "errorCode": "ATS_UPDATE_REJECTED",
    "errorClass": "DATA",
    "lastFailedNodeId": "advance-in-ats",
    "lastFailedNodeSlug": "advance-in-ats",
    "truncated": false,
    "nodes": [
      {
        "nodeId": "screen-availability",
        "nodeSlug": "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

FieldTypeDescription
errorCodestringWhy the run failed. See errorCode values.
errorClassstringDATA or SYSTEM. SYSTEM: Popp ran out of retries on a temporary platform problem. DATA: the input cannot succeed as it is, so the run failed on the first attempt. Optional: not every failure carries it.
lastFailedNodeIdstringIdentifier of the node where the run failed (optional)
lastFailedNodeSlugstring | nullBeta. The slug of the node where the run failed, or null for a node published without one. Sent with lastFailedNodeId.

errorCode values

ValueMeaning
EXECUTOR_TIMEOUTNo longer sent. Older failed runs may still carry it.
INVALID_NODE_CONFIGA node's configuration could not run, although the version passed publish validation.
DOWNSTREAM_SERVICE_ERRORPopp sent an Add to job or updateAtsApplication write to your ATS but could not read the answer. The write may have taken effect, so check the application in your ATS. Emitted with errorClass: "DATA". Popp does not retry, so the write is not sent again.
EXECUTOR_FAILEDNo longer sent. Older failed runs may still carry it.
ATS_CREATE_REJECTEDThe ATS permanently refused an Add to job node: the destination job or stage is gone, the job is closed, the ATS cannot create applications, or it rejected the submission. Popp does not retry, because another call would get the same answer. lastFailedNodeId names the node.
SYSTEM_FAILUREPopp ran out of retries on a temporary problem. For example, your ATS or a Popp service the run depends on, such as messaging, scheduling or analysis, did not respond. Emitted with errorClass: "SYSTEM". A run held at an analysis node is not failed for this: it stays held.
CANDIDATE_CONTACT_UNAVAILABLEAn outreach node had no way to reach the candidate: no email address or phone number on the contact you sent or on the candidate's profile. Emitted with errorClass: "DATA" on the first attempt.
ATS_TARGET_UNCONFIGUREDAn updateAtsApplication node had no targets entry for the ATS job this run came in on, so Popp did not call the ATS. Emitted with errorClass: "DATA". Fix the node's targets and publish a new version.
ATS_APPLICATION_UNAVAILABLEAn updateAtsApplication node was reached by a run that entered from the ATS but had no application to act on. Emitted with errorClass: "DATA". This is not a configuration mistake and should not happen. Contact support if you see it.
ATS_UPDATE_REJECTEDYour ATS permanently refused the write: the application or the configured stage/reason no longer exists, your ATS cannot perform the operation, or it returned an error. Emitted with errorClass: "DATA". Popp checks targets only at publish, so a stage deleted or renamed in your ATS after publish fails here. Correct the configuration and publish a new version.
ATS_CREATE_ENTRY_SOURCE_REFUSEDA createAtsApplication node was reached by a run from a pathway it does not accept. The node accepts only the ATS pathway and INTERNAL_SOURCING. Popp did not call the ATS. Emitted with errorClass: "DATA".
CV_UNAVAILABLENo longer sent. Older failed runs may still carry it.
CV_TOO_LARGEThe applicant's CV text is too long to score, and the analysis node has no onAnalysisFailed route. Emitted with errorClass: "DATA".
ANALYSIS_INPUT_UNAVAILABLEThere was no CV text and no screening answer to score, and the analysis node has no onAnalysisFailed route. Emitted with errorClass: "DATA".
ANALYSIS_FAILEDScoring failed on Popp's side, retrying did not fix it, and the analysis node has no onAnalysisFailed route. Emitted with errorClass: "DATA".
CV_UNSCOREABLEThe applicant could not be scored for a reason the codes above do not cover. The run fails even when the analysis node has an onAnalysisFailed route. Emitted with errorClass: "DATA".

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


WORKFLOW_RUN_CANCELLED

Sent when a run is cancelled with the Cancel workflow run endpoint, or when Popp cancels it without contacting the candidate. Cancellation codes lists when Popp does. Archiving a workflow cancels the runs waiting at an analysis node, with cancellationCode: "WORKFLOW_ARCHIVED". Its other runs continue.

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",
    "finalNodeSlug": "screen-availability",
    "outcome": "cancelled",
    "cancellationReason": "Candidate withdrew via ATS",
    "truncated": false,
    "nodes": [
      {
        "nodeId": "screen-availability",
        "nodeSlug": "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
cancellationReasonstringThe reason you sent to the Cancel workflow run endpoint, if any
cancellationCodestringBeta. Why Popp cancelled the run. See Cancellation codes. Absent on a run cancelled with the Cancel workflow run endpoint.

Plus the run-terminal fields.


WORKFLOW_NODE_COMPLETED

Sent when a node completes. Use it to show progress as it happens. An analysis node completes when the run leaves it. See Analysis outcome.

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",
    "nodeSlug": "screen-availability",
    "nodeType": "SCREENING_OUTREACH",
    "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 node fields (nodeId, nodeSlug, nodeType, enteredAt, completedAt, routingEvent, outcome) sit directly in data, not in a node object. Otherwise they match one entry of a run-terminal event's nodes[], except that this event leaves out enteredAt for outreach, scheduling and analysis nodes. One parser handles both if it treats enteredAt as optional. If you subscribe to both, deduplicate by nodeId and the run's recordId.


WORKFLOW_CONTACT_RESOLVED

Sent before a run starts, for each candidate you send to Start workflow runs: an inline contact, or an entry of candidateProfileIds or candidateProfileExternalIds (an ID entry). Popp matches it to a candidate profile, or creates a profile for an inline contact. recordId is the new run's ID, matching the WORKFLOW_RUN_STARTED that follows. Popp also sends the contact events for runs it starts itself, from Search & Match and from spreadsheet uploads.

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,
    "workflowContactId": null
  }
}

Additional fields

FieldTypeDescription
versionNumberintegerThe workflow version number the run will use
externalIdstring | nullThe external ID you sent: the contact's externalId, or the candidateProfileExternalIds entry. null for a candidateProfileIds entry.
emailstring | nullContact email. null for an ID entry.
phoneNumberstring | nullContact phone in E.164 format. null for an ID entry.
isNewProfilebooleantrue if Popp created a new candidate profile, false if it matched an existing one. Always false for an ID entry.
contactIndexintegerZero-based position of the entry in the list it came from: contacts, candidateProfileIds or candidateProfileExternalIds. Each list counts from 0.
workflowContactIdstring | nullThe contact's ID for a contact from a spreadsheet upload in Popp. null for Start workflow runs.

WORKFLOW_CONTACT_RESOLUTION_SKIPPED

Sent when a candidate you send already has a run on this workflow, in any status (RUNNING, COMPLETED, FAILED or CANCELLED). A candidate gets one run per workflow across all its versions, so publishing a new version does not re-enrol or re-message them. recordId is the existing run's ID.

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,
    "workflowContactId": null
  }
}

Additional fields

FieldTypeDescription
existingRunStatusstringStatus of the candidate's existing run: RUNNING, COMPLETED, FAILED or CANCELLED
externalIdstring | nullThe external ID you sent: the contact's externalId, or the candidateProfileExternalIds entry. null for a candidateProfileIds entry.
emailstring | nullContact email. null for an ID entry.
phoneNumberstring | nullContact phone in E.164 format. null for an ID entry.
contactIndexintegerZero-based position of the entry in the list it came from: contacts, candidateProfileIds or candidateProfileExternalIds. Each list counts from 0.
workflowContactIdstring | nullThe contact's ID for a contact from a spreadsheet upload in Popp. null for Start workflow runs.

WORKFLOW_CONTACT_RESOLUTION_FAILED

Sent when Popp cannot match or create a candidate profile for a candidate you sent, after its retries run out. No run is created. recordId identifies the failed input, not a run. For an inline contact it comes from the contact's email and phone number, so two contacts with the same details share one. For a candidateProfileIds or candidateProfileExternalIds entry it comes from the candidate profile. The same input sent to the same workflow again gets the same recordId. To match the event to the entry you sent, use contactIndex and externalId. The event does not include the underlying error.

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",
    "candidateProfileId": null,
    "phoneNumber": null,
    "contactIndex": 0,
    "workflowContactId": null,
    "reason": "CONTACT_RESOLUTION_FAILED"
  }
}

Additional fields

FieldTypeDescription
externalIdstring | nullThe external ID you sent: the contact's externalId, or the candidateProfileExternalIds entry. null for a candidateProfileIds entry.
emailstring | nullContact email. null for an ID entry.
phoneNumberstring | nullContact phone in E.164 format. null for an ID entry.
contactIndexintegerZero-based position of the entry in the list it came from: contacts, candidateProfileIds or candidateProfileExternalIds. Each list counts from 0.
workflowContactIdstring | nullThe contact's ID for a contact from a spreadsheet upload in Popp. null for Start workflow runs.
reasonstringAlways CONTACT_RESOLUTION_FAILED.

WORKFLOW_PUBLISHED

Sent when a publish succeeds and the working draft becomes the new live version. recordId is the workflow ID.

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
versionNumberintegerThe new version's number
publishedByUserIdstringThe user who published the version
publishedAtstringWhen the version went live (ISO 8601)

Reliability

  • Popp signs workflow webhooks with HMAC-SHA256, like every Popp webhook (see Authentication), and delivers them at least once, with retries.
  • An event can arrive more than once. Use eventId to deduplicate.
  • Run-terminal events (WORKFLOW_RUN_COMPLETED, WORKFLOW_RUN_FAILED, WORKFLOW_RUN_CANCELLED) are the record of what happened in a run. Use WORKFLOW_NODE_COMPLETED for live progress, not as the source of truth.
  • Popp sends WORKFLOW_CONTACT_RESOLUTION_FAILED once per contact that fails, after its retries run out.

Example: handling workflow events

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

type RunOutcome =
  | 'completed'
  | 'opted_out'
  | 'delivery_failed'
  | 'no_response'
  | '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;
  nodeSlug?: string | null;
  nodeType: 'SCREENING_OUTREACH' | 'ENGAGEMENT_OUTREACH' | 'CONDITION' | 'ANALYSIS' | 'CREATE_ATS_APPLICATION' | 'UPDATE_ATS_APPLICATION' | 'SCHEDULING_OUTREACH';
  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: 'analysis'; score: number; requirementsMet: boolean }
    | { kind: 'analysisFailure'; analysisErrorCode: 'DOCUMENT_TOO_LARGE' | 'ANALYSIS_INPUT_UNAVAILABLE' | 'ANALYSIS_FAILED' }
    | { kind: 'atsApplicationCreate'; decision: 'created' | 'already_on_job' }
    | { kind: 'atsApplicationUpdate'; decision: 'updated' | 'skipped_no_ats_application' }
    | { kind: 'meeting'; meetingId: string; scheduledAt: string; status?: 'BOOKED' | 'RESCHEDULING_REQUESTED' | 'RESCHEDULED' | 'CANCELLED' }
    | { 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;
  finalNodeSlug?: string | null;
  outcome: RunOutcome;
  truncated: boolean;
  nodes: NodeOutcome[];
  errorCode?:
    | 'EXECUTOR_TIMEOUT'
    | 'INVALID_NODE_CONFIG'
    | 'DOWNSTREAM_SERVICE_ERROR'
    | 'EXECUTOR_FAILED'
    | 'ATS_CREATE_REJECTED'
    | 'SYSTEM_FAILURE'
    | 'CANDIDATE_CONTACT_UNAVAILABLE'
    | 'ATS_TARGET_UNCONFIGURED'
    | 'ATS_APPLICATION_UNAVAILABLE'
    | 'ATS_UPDATE_REJECTED'
    | 'ATS_CREATE_ENTRY_SOURCE_REFUSED'
    | 'CV_UNAVAILABLE'
    | 'CV_TOO_LARGE'
    | 'ANALYSIS_INPUT_UNAVAILABLE'
    | 'ANALYSIS_FAILED'
    | 'CV_UNSCOREABLE';
  errorClass?: 'DATA' | 'SYSTEM';
  lastFailedNodeId?: string;
  lastFailedNodeSlug?: string | null;
  cancellationReason?: string;
  cancellationCode?:
    | 'ATS_APPLICATION_REJECTED'
    | 'ATS_STAGE_NOT_SELECTED'
    | 'ANALYSIS_STEP_REMOVED'
    | 'WORKFLOW_ARCHIVED'
    | 'CANDIDATE_ERASED';
}

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;
  }
}

Did this page help you?