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
| Event | recordId is | Description |
|---|---|---|
WORKFLOW_RUN_STARTED | Workflow run ID | A run started |
WORKFLOW_RUN_COMPLETED | Workflow run ID | A run completed |
WORKFLOW_RUN_FAILED | Workflow run ID | A run failed and could not continue |
WORKFLOW_RUN_CANCELLED | Workflow run ID | A run was cancelled with the Cancel workflow run endpoint, or by Popp |
WORKFLOW_NODE_COMPLETED | Workflow run ID | A node in a run completed |
WORKFLOW_CONTACT_RESOLVED | Workflow run ID | Popp matched a candidate you sent to a candidate profile, or created one |
WORKFLOW_CONTACT_RESOLUTION_FAILED | A key for the contact's email and phone number | Popp could not match or create a candidate profile for a candidate you sent |
WORKFLOW_CONTACT_RESOLUTION_SKIPPED | The existing workflow run ID | Popp skipped a candidate who already has a run on this workflow, in any status |
WORKFLOW_PUBLISHED | Workflow ID | A 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.
| Field | Type | Description |
|---|---|---|
recordId | string | The event's main record. See Available events. |
organizationId | string | Your organization ID |
workflowId | string | The workflow. Absent on WORKFLOW_PUBLISHED, which carries it in recordId. |
workflowVersionId | string | The workflow version that produced the event. Absent on WORKFLOW_CONTACT_RESOLUTION_FAILED and WORKFLOW_CONTACT_RESOLUTION_SKIPPED. |
workflowExternalId | string | null | Your external ID for the workflow, when set |
candidateProfileId | string | null | The candidate profile. null on WORKFLOW_CONTACT_RESOLUTION_FAILED for an inline contact. Absent on WORKFLOW_PUBLISHED. |
candidateProfileExternalId | string | null | Your 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. |
timestamp | string | When 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
| Field | Type | Description |
|---|---|---|
nodeId | string | Identifier of the node within the workflow configuration |
nodeSlug | string | null | Beta. The node's slug, which names the same node in every version. null for a node published without one. |
nodeType | string | The workflow node type. One of: SCREENING_OUTREACH, ENGAGEMENT_OUTREACH, CONDITION, ANALYSIS, CREATE_ATS_APPLICATION, UPDATE_ATS_APPLICATION, SCHEDULING_OUTREACH |
enteredAt | string (date-time) | When the run entered the node. Always in nodes[]. WORKFLOW_NODE_COMPLETED leaves it out for outreach, scheduling and analysis nodes. |
completedAt | string (date-time) | When the run left the node |
routingEvent | string | The 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")
outcome.kind: "conversation")Emitted by screeningOutreach and engagementOutreach nodes.
| Field | Type | Description |
|---|---|---|
conversationId | string | The conversation's ID in the Conversations API |
status | string (enum) | How the conversation ended. See WorkflowOutreachOutcome. |
completedAt | string (date-time) | When the conversation completed (optional) |
answers | array | One entry per answered question, keyed by question slug. Empty when the candidate answered nothing, for example after opting out before replying. |
WorkflowOutreachOutcome enum
WorkflowOutreachOutcome enumstatus is not the Conversations API's conversationStatus. To match a conversation across the two, use conversationId, not the status value.
| Value | Meaning |
|---|---|
PASSED_CRITERIA | The candidate met the screening criteria the conversation was designed to test for. |
FAILED_CRITERIA | The candidate did not meet the screening criteria. |
COMPLETED | The conversation ran its course or was closed without a specific pass/fail signal. |
NOT_INTERESTED | The candidate explicitly declined. |
OPTED_OUT | The candidate opted out (e.g. SMS STOP). |
DELIVERY_FAILED | Delivery failed across the available channels. |
NO_REPLY | The 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.
| Field | Type | Description |
|---|---|---|
slug | string | The question's slug from the workflow configuration |
answer | string | The candidate's answer text |
answerState | string | How Popp classified the answer. One of: IS_YES, IS_NO, IS_ANY, INCLUDES, DOES_NOT_INCLUDE, DOCUMENT_UPLOADED, DOCUMENT_NOT_UPLOADED, NEEDS_CLARIFICATION |
trafficLight | string | Traffic-light rating of the answer. One of: GREEN, AMBER, RED |
outcome | string | Final 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. |
score | number | Preferred-answer score, a whole number from 0 to 100. Set only when the question has preferredAnswerText. |
documentS3Key | string | Storage key for the uploaded document (when the question accepted a document and was answered over email) |
documentTwilioMediaSid | string | Media identifier for the uploaded document (WhatsApp / SMS channel) |
classifiedDocumentType | string | The document type Popp detected in an uploaded document, for example cv, passport or dbs-certificate |
Condition outcome (outcome.kind: "condition")
outcome.kind: "condition")Emitted by condition nodes.
| Field | Type | Description |
|---|---|---|
targetNodeId | string | null | Identifier of the node selected by the matched branch (or by else) |
targetNodeLabel | string | null | Display label of the selected node, when available |
The event does not include the expression that matched.
Analysis outcome (outcome.kind: "analysis")
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.
| Field | Type | Description |
|---|---|---|
score | number | Overall analysis score, as a whole number from 0 to 100 |
requirementsMet | boolean | Whether 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")
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.
| Field | Type | Description |
|---|---|---|
analysisErrorCode | string | Why 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")
outcome.kind: "atsApplicationCreate")Emitted by createAtsApplication (Add to job) nodes.
| Field | Type | Description |
|---|---|---|
decision | string (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")
outcome.kind: "atsApplicationUpdate")Emitted by updateAtsApplication nodes.
| Field | Type | Description |
|---|---|---|
decision | string (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")
outcome.kind: "meeting")Emitted by schedulingOutreach nodes when the candidate books a meeting. The node's routingEvent is onComplete.
| Field | Type | Description |
|---|---|---|
meetingId | string | Identifier of the booked meeting |
scheduledAt | string (date-time) | Start time of the meeting |
status | string (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")
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:
| Value | Meaning |
|---|---|
completed | The run reached the end of its graph |
opted_out | The candidate opted out |
delivery_failed | Messages could not be delivered on any available channel |
no_response | The conversation closed without the reply or booking the run was waiting for |
cancelled | The run was cancelled with the Cancel workflow run endpoint, or by Popp |
failed | The 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
| Field | Type | Description |
|---|---|---|
startedAt | string | When the run started (ISO 8601) |
completedAt | string | When the run ended (ISO 8601) |
finalNodeId | string | The last node the run passed through (optional) |
finalNodeSlug | string | null | Beta. The slug of the last node, or null for a node published without one. Sent with finalNodeId. |
outcome | string | See Run-level outcomes |
truncated | boolean | true 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. |
nodes | array | The 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
| Field | Type | Description |
|---|---|---|
errorCode | string | Why the run failed. See errorCode values. |
errorClass | string | DATA 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. |
lastFailedNodeId | string | Identifier of the node where the run failed (optional) |
lastFailedNodeSlug | string | null | Beta. The slug of the node where the run failed, or null for a node published without one. Sent with lastFailedNodeId. |
errorCode values
errorCode values| Value | Meaning |
|---|---|
EXECUTOR_TIMEOUT | No longer sent. Older failed runs may still carry it. |
INVALID_NODE_CONFIG | A node's configuration could not run, although the version passed publish validation. |
DOWNSTREAM_SERVICE_ERROR | Popp 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_FAILED | No longer sent. Older failed runs may still carry it. |
ATS_CREATE_REJECTED | The 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_FAILURE | Popp 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_UNAVAILABLE | An 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_UNCONFIGURED | An 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_UNAVAILABLE | An 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_REJECTED | Your 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_REFUSED | A 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_UNAVAILABLE | No longer sent. Older failed runs may still carry it. |
CV_TOO_LARGE | The applicant's CV text is too long to score, and the analysis node has no onAnalysisFailed route. Emitted with errorClass: "DATA". |
ANALYSIS_INPUT_UNAVAILABLE | There was no CV text and no screening answer to score, and the analysis node has no onAnalysisFailed route. Emitted with errorClass: "DATA". |
ANALYSIS_FAILED | Scoring failed on Popp's side, retrying did not fix it, and the analysis node has no onAnalysisFailed route. Emitted with errorClass: "DATA". |
CV_UNSCOREABLE | The 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
| Field | Type | Description |
|---|---|---|
cancellationReason | string | The reason you sent to the Cancel workflow run endpoint, if any |
cancellationCode | string | Beta. 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
| Field | Type | Description |
|---|---|---|
versionNumber | integer | The workflow version number the run will use |
externalId | string | null | The external ID you sent: the contact's externalId, or the candidateProfileExternalIds entry. null for a candidateProfileIds entry. |
email | string | null | Contact email. null for an ID entry. |
phoneNumber | string | null | Contact phone in E.164 format. null for an ID entry. |
isNewProfile | boolean | true if Popp created a new candidate profile, false if it matched an existing one. Always false for an ID entry. |
contactIndex | integer | Zero-based position of the entry in the list it came from: contacts, candidateProfileIds or candidateProfileExternalIds. Each list counts from 0. |
workflowContactId | string | null | The 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
| Field | Type | Description |
|---|---|---|
existingRunStatus | string | Status of the candidate's existing run: RUNNING, COMPLETED, FAILED or CANCELLED |
externalId | string | null | The external ID you sent: the contact's externalId, or the candidateProfileExternalIds entry. null for a candidateProfileIds entry. |
email | string | null | Contact email. null for an ID entry. |
phoneNumber | string | null | Contact phone in E.164 format. null for an ID entry. |
contactIndex | integer | Zero-based position of the entry in the list it came from: contacts, candidateProfileIds or candidateProfileExternalIds. Each list counts from 0. |
workflowContactId | string | null | The 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
| Field | Type | Description |
|---|---|---|
externalId | string | null | The external ID you sent: the contact's externalId, or the candidateProfileExternalIds entry. null for a candidateProfileIds entry. |
email | string | null | Contact email. null for an ID entry. |
phoneNumber | string | null | Contact phone in E.164 format. null for an ID entry. |
contactIndex | integer | Zero-based position of the entry in the list it came from: contacts, candidateProfileIds or candidateProfileExternalIds. Each list counts from 0. |
workflowContactId | string | null | The contact's ID for a contact from a spreadsheet upload in Popp. null for Start workflow runs. |
reason | string | Always 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
| Field | Type | Description |
|---|---|---|
versionNumber | integer | The new version's number |
publishedByUserId | string | The user who published the version |
publishedAt | string | When 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
eventIdto deduplicate. - Run-terminal events (
WORKFLOW_RUN_COMPLETED,WORKFLOW_RUN_FAILED,WORKFLOW_RUN_CANCELLED) are the record of what happened in a run. UseWORKFLOW_NODE_COMPLETEDfor live progress, not as the source of truth. - Popp sends
WORKFLOW_CONTACT_RESOLUTION_FAILEDonce 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;
}
}Updated 14 days ago
