Workflow Configuration
A workflow is a graph of nodes connected by routing events. You send the graph as the configuration field on POST /v1/workflows or PATCH /v1/workflows/{workflowId}, then publish the workflow so it can accept runs. POST /v1/workflows/{workflowId}/runs starts one run per candidate.
Lifecycle
| Status | What it means |
|---|---|
UNPUBLISHED | Working draft. Editable. Cannot accept runs. |
PUBLISHED | Live. Editable (the working draft) and accepts new runs against the latest published version. |
PAUSED | Live but not accepting new runs. Runs in progress continue to completion. |
ARCHIVED | Read-only. Cannot be edited, published, or used to start runs. |
Transitions:
UNPUBLISHED → PUBLISHEDviaPOST /v1/workflows/{workflowId}/publish(succeeds only when validation passes).PUBLISHED ↔ PAUSEDviapause/resume.- Any state →
ARCHIVEDviaarchive. Irreversible.
Configuration graph shape
The configuration field is a JSON-encoded string. Decoded, it looks like this:
{
"entryPathways": [{ "entrySource": "API", "entrySlug": "screen-availability" }],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"label": "Registered Nurse — availability and registration screen",
"routing": {
"onComplete": "engagement-booking-confirm",
"onNotInterested": "engagement-nurture",
"onDeliveryFailed": "engagement-email-fallback"
},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability and NMC screen",
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. Confirms NMC registration, region, and shift appetite up front.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-booking-confirm": {
"id": "engagement-booking-confirm",
"type": "engagementOutreach",
"label": "Confirm booking",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Confirm booking",
"channel": "SMS",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, your details are confirmed — our bookings team will reach out shortly with shifts that match your region. Reply STOP to opt out.",
"outreachContext": "Confirming shift preferences with RGN candidates who have already passed screening, before the bookings team calls.",
"questions": [
{
"slug": "confirm-region",
"content": "Which regions are you able to travel to for shifts?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-nurture": {
"id": "engagement-nurture",
"type": "engagementOutreach",
"label": "Nurture for future roles",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Nurture for future roles",
"channel": "EMAIL",
"customTemplatedSubjectLine": "Cedar Health — we'll be in touch when the timing is right",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for letting us know — we'll keep you on file and reach out when you're ready to pick up RGN shifts.",
"outreachContext": "Keeping in touch with RGN candidates who are not ready to pick up shifts yet, so they can re-engage when the timing suits them.",
"questions": [
{
"slug": "nurture-timing",
"content": "Roughly when would you like us to check back in with you?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-email-fallback": {
"id": "engagement-email-fallback",
"type": "engagementOutreach",
"label": "Email fallback for SMS-unreachable candidates",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Email fallback for SMS-unreachable candidates",
"channel": "EMAIL",
"customTemplatedSubjectLine": "Cedar Health Recruitment — RGN shifts in your region",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, we tried to reach you over SMS but couldn't get through. Reply to this email if you're interested and we'll send across the next steps.",
"outreachContext": "Re-reaching RGN candidates over email after SMS delivery failed, to confirm whether they are still interested.",
"questions": [
{
"slug": "fallback-interest",
"content": "Are you still interested in RGN shifts with Cedar Health?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
}
}
}screen-availability routes three events (onComplete, onNotInterested, onDeliveryFailed) to three different nodes, one follow-up per outcome. It leaves onOptedOut unrouted, so the run ends when a candidate opts out. agentName and agentTone name the agent, and Popp creates it on first publish. See Agent reference.
Top-level fields
| Field | Type | Description |
|---|---|---|
startNodeId | string | Deprecated. Legacy entry node, replaced by entryPathways but still accepted. See Entry. |
triggerSource | string | Deprecated. Legacy trigger source, replaced by entryPathways but still accepted. Set to API. See Entry. |
entryPathways | array | Preferred. List of entry pathways. When omitted, startNodeId and triggerSource define the API pathway. |
nodes | object | Map keyed by node ID. Every node in the graph must appear here, including the start node. |
Node fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique within the workflow. Must equal the key in the nodes map. |
type | string | yes | One of screeningOutreach, engagementOutreach, condition, createAtsApplication, updateAtsApplication, schedulingOutreach, analysis. |
label | string | no | Display label. |
slug | string | no | The node's permanent name. It names the same node in every version of the workflow, while the node's id can change. Lowercase letters, digits, _ and -, up to 50 characters, unique within the workflow. Entry pathways target it with entrySlug, and conditions can name the node by it. Required on any node an entry pathway targets. See Node slugs. |
routing | object | no | Map from routing event to target node ID. Omit it or send {} to leave nothing after the node. |
config | object | yes | Per-node-type configuration. See Supported Node Types. |
When a node finishes, the run moves to the node that routing names for the event the node emitted. If routing names no node for that event, the run completes. An analysis node is the exception: with nothing after it, it holds the run, and an unrouted onAnalysisFailed fails the run. A non-empty routing map must route every event the node type marks required; create, update and publish all refuse it otherwise. You can leave optional events unrouted.
Entry
A candidate enters a workflow through an entry pathway: a source paired with the node the candidate starts on. A workflow can have one pathway per source.
Two sources take candidates from your own systems: API and ATS. Popp starts runs from other sources itself: INTERNAL_SOURCING for Search & Match (Popp's own sourcing pool) and SPREADSHEET for a spreadsheet upload in Popp. PUSH is withdrawn. A validation message may name one of these, and createAtsApplication accepts only some of them.
You can write the API pathway two ways:
entryPathways(used in the examples below):{ "entrySource": "API", "entrySlug": "<node slug>" }, whereentrySlugis theslugof the node candidates start on.- Legacy shorthand (deprecated, still accepted):
startNodeId(the entry node'sid) plustriggerSource: "API". These define the API pathway when a config has noentryPathways.
"entryPathways": [
{ "entrySource": "API", "entrySlug": "screen-availability" }
]Both forms behave the same: a candidate sent to POST /v1/workflows/{workflowId}/runs starts at the entry node.
An ATS pathway (entrySource: "ATS") connects the workflow to one ATS job, and its applicants enter the workflow as they apply. It has the entry node's entrySlug, an atsJobIds list holding that job, and an atsStageSelections entry for the job:
"entryPathways": [
{
"entrySource": "ATS",
"entrySlug": "screen-availability",
"atsJobIds": ["<atsJobId>"],
"atsStageSelections": [
{ "type": "selectedStages", "atsJobId": "<atsJobId>", "atsStageIds": ["<atsStageId>"] }
]
}
]atsStageSelections says which applications should start the workflow. Give one entry for the job in atsJobIds:
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | selectedStages or allStages. |
atsJobId | string | yes | The job in the pathway's atsJobIds. |
atsStageIds | string[] | selectedStages only | At least one atsStageId from GET /v1/ats/jobs/{atsJobId}/stages. Not allowed on allStages. |
selectedStagesstarts the workflow only for applications in the stages you list.allStagesstarts the workflow for every application on the job that hasn't been rejected or declined. Use it when the job has no stages, or when you want no filter.atsStageSelectionsis required for a new workflow, and for a job you add to a published workflow. An empty list is refused. A workflow published before stage selection can leave it out for the job it was published with, and your organization's application filter then applies.- After you publish a selection, you cannot remove the field. Send
allStagesto run the workflow for every application.
List your organization's atsJobIds with GET /v1/ats/jobs. An ATS job can be connected to a workflow, or to a campaign or analysis, but not both. Publish fails with ATS_JOB_ALREADY_CONNECTED otherwise.
A workflow has at most one ATS pathway, and it lists one job. To feed several vacancies, create one workflow for each job. You can add other pathways alongside it, such as an API pathway for candidates you send yourself.
Create, update and publish all check pathways:
| Error code | Cause |
|---|---|
MISSING_ENTRY | The config has neither a startNodeId nor an entryPathways entry. |
PATHWAY_TARGET_NOT_FOUND | An entrySlug points at a node slug that does not exist in the graph. |
DUPLICATE_ENTRY_PATHWAY | Two pathways declare the same entrySource. |
MULTIPLE_ATS_ENTRY_PATHWAYS | A workflow declares more than one entrySource: "ATS" pathway. |
MULTIPLE_ATS_ENTRY_JOBS | An ATS pathway lists more than one atsJobId. Create one workflow for each job instead. |
DUPLICATE_NODE_SLUG | Two nodes share the same slug. |
MISSING_ATS_STAGE_SELECTION | atsStageSelections has no entry for the job, or is an empty list. Also: atsStageSelections is left out, and the job is not in the workflow's published version. |
UNEXPECTED_ATS_STAGE_SELECTION | An atsStageSelections entry names a job that is not in the pathway's atsJobIds. |
DUPLICATE_ATS_STAGE_SELECTION | Two atsStageSelections entries name the same job. |
DUPLICATE_ATS_STAGE | One atsStageSelections entry lists the same atsStageId two times. |
ATS_STAGE_SELECTION_REMOVED | The published version has a stage selection, and the new configuration leaves out atsStageSelections. |
Supported node types
The two conversation node types, screeningOutreach and engagementOutreach, share the same channels, routing events and webhook outcomes. A conversation reports PASSED_CRITERIA or FAILED_CRITERIA when an answer route closes it with CLOSE_CONVERSATION and that status. engagementOutreach lists how they differ.
screeningOutreach
screeningOutreachSends a screening conversation to the candidate over SMS, WhatsApp, or email and collects answers to a fixed set of questions.
Config fields
Required core:
| Field | Type | Required | Notes |
|---|---|---|---|
agentName | string | yes | Display name of the agent. Up to 100 characters. See Agent reference. |
agentTone | string | yes | One of CASUAL, ENCOURAGING, FRIENDLY, MOTIVATIONAL, NEUTRAL, PROFESSIONAL, CUSTOM. |
title | string | yes | Label for the campaign Popp creates at publish. Candidates never see it. |
channel | string | yes | One of SMS, WHATSAPP, EMAIL. |
employerName | string | yes | Name of the hiring company. The agent uses it when it mentions the employer. |
roleTitle | string | yes | Title of the role. The agent uses it when it mentions the role. |
location | string | yes | Role location. The agent uses it when it mentions where the role is based. |
outreachContext | string | yes | The job description, and the agent's main source when it discusses the role. Not tone notes. See Context the agent uses to write replies. |
questions | array | yes | Ordered list of questions. See Questions below. |
Agent reference
Every outreach node names an agent: the persona that writes and signs its messages. agentName and agentTone together identify the agent, and both are required.
On publish, Popp reuses your organization's agent with that name and tone, or creates one if none exists. Nodes with the same pair share one agent, so you don't need to create agents before your first publish.
A node missing either field fails with MISSING_AGENT_REFERENCE, once per missing field.
Channel and templating:
| Field | Type | Required | Notes |
|---|---|---|---|
templateId | string | conditional | Required for WHATSAPP; rejected for EMAIL. |
customTemplatedMessage | string | conditional | Used as the opening message. For SMS the message must include STOP to satisfy opt-out compliance. |
customTemplatedSubjectLine | string | conditional | Required for EMAIL. |
Optional context for the agent (see Context the agent uses to write replies):
| Field | Type | Required | Notes |
|---|---|---|---|
additionalContext | string | no | Free-form guidance for the agent, such as tone, constraints and edge cases. The job description goes in outreachContext. |
summaryOfRole | string | no | One-paragraph role summary, read alongside the full description. Useful when the description is long. |
contractType | string | no | Contract type (e.g. permanent, contract, bank shifts). The agent uses it when contract terms come up. |
campaignType | string | no | Whether the recipients have already applied to this role. One of APPLICANT_OUTREACH (default) or NEW_CANDIDATE_OUTREACH. See Campaign type. |
Campaign type
campaignType sets which audience a screeningOutreach node is contacting.
| Value | Use when |
|---|---|
APPLICANT_OUTREACH | Recipients have applied to this role. Default when campaignType is omitted. |
NEW_CANDIDATE_OUTREACH | Recipients have not applied and are being approached about the role. |
Recipients who have not applied are introduced to the role and asked whether they are interested before the screening questions begin. Applicants go straight to the questions.
engagementOutreach nodes accept only ENGAGEMENT_OUTREACH, which is their default, so the field can be omitted.
A value the node type cannot run, such as SCHEDULING on a screeningOutreach node, is rejected with INCOMPATIBLE_CAMPAIGN_TYPE.
Optional closing messages:
| Field | Type | Required | Notes |
|---|---|---|---|
criteriaSatisfiedClosingMessage | string | no | Message the agent uses when it closes a conversation with a PASSED_CRITERIA outcome. |
criteriaNotSatisfiedClosingMessage | string | no | Message the agent uses when it closes a conversation with a FAILED_CRITERIA outcome. |
candidateNotInterestedClosingMessage | string | no | Message the agent uses when it closes a conversation after the candidate says they are not interested. |
Optional pacing and auto-close:
| Field | Type | Required | Notes |
|---|---|---|---|
disableNudging | boolean | no | When true, no nudges are sent. |
timeToAutoCloseConversationsInHours | number | no | Hours of inactivity before a conversation auto-closes. Default: 24. |
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | Conversation reached its terminal state successfully | yes |
onNotInterested | Candidate signalled disinterest | optional |
onOptedOut | Candidate opted out (e.g. SMS STOP) | optional |
onDeliveryFailed | Delivery failed across all available channels | optional |
onNoReply | The conversation closed after timeToAutoCloseConversationsInHours with no new message | optional |
Questions
Each question is one question-and-answer step in the conversation.
Every answer route must say where the run goes next. Its actions must take one of three shapes:
[{ type: "CONTINUE", slug: "<sibling-slug>" }]: move to another question in the same node.[{ type: "CLOSE_CONVERSATION", status: "<status>" }, { type: "ROUTE_TO", nodeId: "<target>" }]: close the conversation and hand off to another node.[{ type: "CLOSE_CONVERSATION", status: "<status>" }]: end the run at this question.The agent asks
questions[0]first. It reaches every other question only through aCONTINUEaction that names its slug. Array order after[0]has no effect.Create, update and publish reject a question without
answerRoutes(MISSING_ANSWER_ROUTES) and aCONTINUEwithout aslug(BARE_CONTINUE_ROUTE).
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | no | Popp keeps it. Publish matches a draft question to the live question with the same id first, then by exactly the same content, in the same outreach node. See Question slugs. |
slug | string | yes | Unique across the workflow. Used in answers[].slug, in answers.<slug> conditions and as a CONTINUE target. Can't change once published: see Question slugs. |
content | string | yes | The question prompt. |
questionType | string | no | TEXT (default) or DOCUMENT. Determines which question shape applies. See Question shapes. |
expectedAnswer | string | no | IS_YES or IS_NO. Makes the question a yes/no knockout that passes on the expected answer. TEXT questions only; rejected on DOCUMENT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. |
preferredAnswerText | string | no | Description of the ideal answer. Setting it makes the question scored: the answer counts toward the conversation's scorecardTotalValue, and score routes become available. Rejected on yes/no shapes (PREFERRED_ANSWER_ON_YES_NO_QUESTION) and DOCUMENT questions (INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE). |
documentTypes | string[] | no | Deprecated free-text list of accepted document kinds for DOCUMENT questions (e.g. ["cv", "passport"]). Prefer documentItems. Mutually exclusive with documentItems. Rejected on TEXT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. |
documentItems | object[] | no | Document types for DOCUMENT questions: [{ "documentTypeId": "<uuid>" }]. Each must be one of your organization's document types and not archived, or publish refuses it (DOCUMENT_TYPE_NOT_FOUND, DOCUMENT_TYPE_ARCHIVED). Mutually exclusive with documentTypes. Rejected on TEXT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. See Document Collection Flow to set up document types. |
strictness | string | no | LENIENT or STRICT. Sets how strictly a borderline answer is judged. TEXT questions only; rejected on DOCUMENT. |
answerRoutes | array | yes | The question's branching rules. At least one. See Answer-route DSL. |
Question shapes
A question's questionType, expectedAnswer, preferred-answer fields and answerRoutes operators must fit one of four shapes. Create, update and publish reject a question that mixes shapes.
| Shape | questionType | Required fields | Allowed answerRoutes operators | Rejected fields |
|---|---|---|---|---|
| Yes/no | TEXT (or omitted) | slug, content; expectedAnswer: IS_YES or IS_NO is optional | answerState=eq (yes/no) | preferredAnswerText |
| Open-ended (no scoring) | TEXT (or omitted) | slug, content, answerRoutes | answerState=eq=any (sole or catch-all); answerText mentions/does_not_mention + answerState=any catch-all | expectedAnswer, preferredAnswerText |
| Open-ended scoring | TEXT (or omitted) | preferredAnswerText (non-empty) | score (gte/lt, must partition 0–100) | expectedAnswer |
| Document upload | DOCUMENT | Exactly one of documentItems (typed, recommended) or the deprecated documentTypes (free-text array) | documentState=eq (uploaded/not_uploaded, complete binary pair) | expectedAnswer, preferredAnswerText, strictness |
Yes/no questions are knockouts. With expectedAnswer set, Popp does not score the answer, and the expected answer passes. A reply Popp cannot classify is reported as outcome: INCOMPLETE (see Workflow Events). Without expectedAnswer, Popp still classifies the answer as yes or no, but your answerRoutes decide which one passes: for example, CLOSE_CONVERSATION with PASSED_CRITERIA on the answerState=yes route and FAILED_CRITERIA on the answerState=no route. strictness is allowed on yes/no questions.
Open-ended (no scoring) questions capture the candidate's free-form answer without scoring it. Two routing patterns:
- Record and continue: a single
answerState=anyroute. Popp stores the reply on the conversation and runs the route's actions (CONTINUEto the next question,CLOSE_CONVERSATIONto end the run, orROUTE_TO+CLOSE_CONVERSATIONto hand off). Use it when the answer is for the record and doesn't branch, such as "Tell me about your shift preferences" for a recruiter to review. - Keyword branching: one or more
answerTextmentions/does_not_mentionroutes, then ananswerState=anycatch-all for replies no earlier route matched. Use it when a keyword or two should drive routing, such as region (mentions "London"→ a city follow-up) or an absence check (does_not_mention "weekend"→ ask about weekend availability).
Open-ended scoring questions are scored against preferredAnswerText. expectedAnswer turns scoring off, so it is rejected here with EXPECTED_ANSWER_ON_NON_YES_NO_QUESTION.
Document questions route on upload state only. expectedAnswer, preferred-answer fields and strictness are rejected with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. Routes must use documentState; any other route fails with INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE.
See Example 3 for valid payloads and common rejections per shape.
engagementOutreach
engagementOutreachSends a follow-up, nurture or re-engagement conversation, for nodes after screening such as confirming a booking, requesting a compliance pack or keeping a candidate warm for future roles.
Differences from screeningOutreach
| Aspect | screeningOutreach | engagementOutreach |
|---|---|---|
employerName, roleTitle, location | required | optional |
outreachContext | required | required |
questions | required, non-empty array | required, non-empty array |
| Allowed template variables | full set, including {{JOB_TITLE}}, {{EMPLOYER_NAME}}, {{LOCATION}} | full set minus {{JOB_TITLE}}, {{EMPLOYER_NAME}}, {{LOCATION}} (rejected with INVALID_TEMPLATE_VARIABLE) |
| Typical webhook outcomes | PASSED_CRITERIA, FAILED_CRITERIA, COMPLETED, plus the candidate-driven set | COMPLETED, plus the candidate-driven set (NOT_INTERESTED, OPTED_OUT, DELIVERY_FAILED) |
All other config fields (agentName, agentTone, title, channel, customTemplatedMessage, customTemplatedSubjectLine, templateId, criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage, timeToAutoCloseConversationsInHours, additionalContext) and all five routing events behave the same on both node types.
condition
conditionBranches on the run's context.
Config fields
| Field | Type | Required | Notes |
|---|---|---|---|
branches | array | yes | Ordered list of branches. Each { id, label?, when, targetNodeId }. First match wins. |
else | string | yes | Node ID to route to when no branch matches. |
elseLabel | string | no | Display label for the else path. |
Each branch's when is a Condition-node expression.
Routing
A condition node has no routing events. The matched branch's targetNodeId, or else, decides the next node. Leave routing out or send {}. Any entry in it is refused with UNKNOWN_ROUTE at nodes.<node-id>.routing.<event>.
Workflow events still report the node's completion with routingEvent: onConditionResolved.
createAtsApplication
createAtsApplicationAdds the run's candidate to a chosen stage in an ATS job. The Popp builder calls this node Add to job.
Config fields
| Field | Type | Required | Notes |
|---|---|---|---|
atsJobId | string | yes | The destination job. Take it from GET /v1/ats/jobs. |
atsStageId | string | yes | The stage in that job where the candidate lands. Take it from GET /v1/ats/jobs/{atsJobId}/stages or from the job's stages[]. |
No other config keys are accepted.
Use a stage with a non-null atsStageId. Some ATSs report stages without an ID, and those cannot be destinations.
Discovering a destination
curl "https://api.joinpopp.com/v1/ats/jobs" \
-H "x-api-key: $POPP_API_KEY" \
-H "x-organization-id: $POPP_ORGANIZATION_ID"
curl "https://api.joinpopp.com/v1/ats/jobs/{atsJobId}/stages" \
-H "x-api-key: $POPP_API_KEY" \
-H "x-organization-id: $POPP_ORGANIZATION_ID"GET /v1/ats/jobs returns each job's stages[], so one call is enough to configure a node. Use the per-job endpoint when you have an atsJobId and need only its stages.
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | The candidate was added, or was already on the job | yes, when the node routes at all |
Route onComplete to continue, or send routing: {} to end the run here.
Only an ATS or Search & Match entry may reach this node. Any other entry source that can reach it (API, SPREADSHEET, PUSH, or a legacy startNodeId no pathway covers) is refused with CREATE_ATS_APPLICATION_UNSUPPORTED_ENTRY. API and spreadsheet candidates can arrive without the last name or CV your ATS needs. Search & Match is allowed because its profiles are usually complete.
The rule checks which entry sources have a path to this node. A workflow whose ATS and spreadsheet pathways both reach it is refused. One whose spreadsheet pathway leads only to a different branch is valid. A node no entry can reach gets UNREACHABLE_NODE instead.
Adding someone to a job can start another workflow. The new application counts as an ATS entry, so a workflow, campaign or analysis connected to the destination job can start for that candidate. Use this to hand a candidate from one workflow to the next job's workflow.
The destination cannot be a job on this workflow's own ATS entry pathway. That would loop the workflow into itself, and is rejected with ATS_DESTINATION_IS_ENTRY_JOB. Check entryPathways before you choose a destination.
Later nodes in the same run cannot use the new application. Popp records it only after the node completes, so a later stage change will not see it. Put follow-on stage moves in the workflow the destination job starts.
When a run reaches the node, Popp calls your ATS and waits for the answer. A newly added candidate and one already on the job both count as success and route onComplete. The WORKFLOW_NODE_COMPLETED webhook tells them apart. See Add to job outcome.
If your ATS refuses the application because the job or stage is gone, the job is closed, the ATS cannot create applications, or it rejects the submission, the run fails with errorCode: "ATS_CREATE_REJECTED" and lastFailedNodeId names the node. Popp retries temporary failures, and the run continues if a retry succeeds. If Popp cannot read your ATS's answer, the run fails with errorCode: "DOWNSTREAM_SERVICE_ERROR" and Popp does not retry, because the candidate may already be on the job.
An incomplete profile fails the run before Popp calls your ATS. An application needs a first name, a last name and an email address. If the stored name is missing, Popp uses the name from the candidate's CV. The run record names the missing field. ATS-sourced profiles can be incomplete too, so the entry-source rule above makes this rarer but does not prevent it.
updateAtsApplication
updateAtsApplicationUpdates the run's application in your ATS: moves it to an interview stage, or rejects it with a reason.
The node acts only on the application the run entered through, never on the candidate's other applications. So it acts only for runs that entered through an ATS pathway. See Runs with no ATS application for the rest.
Config fields
Each node does one operation:
| Field | Type | Required | Notes |
|---|---|---|---|
operation | string | yes | "moveStage" or "reject". Use two nodes to do both. |
targets | array | yes | One entry per ATS job that can reach this node. At least one entry. Shape depends on operation. |
targets entries, by operation:
operation | Entry shape | Meaning |
|---|---|---|
"moveStage" | { atsJobId, atsInterviewStageId } | Move the application to this stage of this job's pipeline. |
"reject" | { atsJobId, atsRejectedReasonId } | Reject the application with this reason. If your ATS rejects by moving the application to a "Rejected" stage, the reason is not recorded, and the node still succeeds. |
An unrecognized key is refused with INVALID_NODE_CONFIG.
"move-to-interview": {
"id": "move-to-interview",
"type": "updateAtsApplication",
"label": "Move to first interview",
"slug": "move_to_interview",
"config": {
"operation": "moveStage",
"targets": [
{ "atsJobId": "<atsJobId>", "atsInterviewStageId": "<atsStageId>" }
]
},
"routing": { "onComplete": "notify-recruiter" }
}Why targets is a list
targets is a listEach targets entry names its job, because a stage id belongs to one job's pipeline. A workflow has one ATS job, so a node holds one entry, for that job. A second entry gives UNEXPECTED_ATS_JOB_MAPPING, or DUPLICATE_ATS_JOB_MAPPING if it repeats the job.
The atsJobIds in targets must match exactly the ATS jobs that can reach this node through your entry pathways and routing maps. Adding a route can make a valid node invalid. ATS application targets lists the errors.
Discovering targets
All three ids are your ATS's own ids:
| Config field | Where it comes from |
|---|---|
atsJobId | atsJobId from GET /v1/ats/jobs. |
atsInterviewStageId | atsStageId from GET /v1/ats/jobs/{atsJobId}/stages. The jobs response also includes them as stages. |
atsRejectedReasonId | atsReasonId from GET /v1/ats/rejection-reasons. |
- Stages belong to a job. Read them for the job on your ATS pathway.
- Rejection reasons belong to your organization, not to a job.
An id may be the stage or reason name. Many ATSs use the label as the id, so atsStageId can read "1st Interview". Send it exactly as returned. Renaming such a stage in your ATS changes its id. The same applies to the atsStageIds in atsStageSelections: after a rename, the selection no longer names that stage. See When it fails.
An id can be null when your ATS gives none for a stage or reason. You cannot target it, so choose a different one.
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | The ATS write finished, or the node was skipped. | yes |
There is no failure route: a failure fails the run.
To make it the last node, omit routing or send {}. A non-empty map must include onComplete, or validation fails with MISSING_REQUIRED_ROUTE.
When a run reaches the node
Popp calls your ATS and waits for the answer before the run moves on, so later nodes can rely on the update. An application already in the requested state counts as success.
On success the run emits WORKFLOW_NODE_COMPLETED and continues along onComplete (or completes, if the node is terminal).
Runs with no ATS application
A candidate started through POST /v1/workflows/{workflowId}/runs can reach this node with no ATS application. Popp skips the node and the run carries on, with decision: "skipped_no_ats_application" on the node outcome. To treat those candidates differently, branch on a condition node before this one.
When it fails
A failure fails the run, and no later node runs. The WORKFLOW_RUN_FAILED event's errorCode says why:
errorCode | Cause |
|---|---|
ATS_TARGET_UNCONFIGURED | The run's job has no entry in targets. Your ATS is never called. |
ATS_APPLICATION_UNAVAILABLE | A run that entered from the ATS reached the node without an application to act on. This should not happen. Contact support if you see it. |
ATS_UPDATE_REJECTED | The ATS refused the write permanently: the application or the target no longer exists, your ATS cannot perform this operation, or it returned an error. |
DOWNSTREAM_SERVICE_ERROR | Popp could not read your ATS's answer to the write. The write may have taken effect, so check the application. Popp does not retry. |
SYSTEM_FAILURE | A transient ATS problem that did not clear after retries. |
Popp checks targets against your ATS only at publish (see ATS application targets). If a stage or reason is later deleted or renamed in your ATS, runs that reach the node fail with ATS_UPDATE_REJECTED, including runs already in progress. Fix the configuration and publish a new version. Editing the draft alone does not change the published version.
In webhooks
The node appears in the workflow events you already receive, with nodeType: "UPDATE_ATS_APPLICATION" and an atsApplicationUpdate node outcome. See Workflow Events.
schedulingOutreach
schedulingOutreachSends the candidate a booking link over SMS, WhatsApp, or email, so the candidate can book a meeting with your team. The node completes when the candidate books.
Nothing can come after a scheduling node. Omit routing, or send {}.
Config fields
Agent and message:
| Field | Type | Required | Notes |
|---|---|---|---|
agentName | string | yes | Display name of the agent. Up to 100 characters. See Agent reference. |
agentTone | string | yes | One of CASUAL, ENCOURAGING, FRIENDLY, MOTIVATIONAL, NEUTRAL, PROFESSIONAL, CUSTOM. |
channel | string | yes | One of SMS, WHATSAPP, EMAIL. |
openingMessageTemplateId | string | conditional | Approved SCHEDULING opening-message template. Required for WHATSAPP. Not allowed for EMAIL. On SMS, use this field or customTemplatedMessage. Scheduling nodes use this field in place of templateId. |
customTemplatedMessage | string | conditional | The opening message. Required for EMAIL. Not allowed for WHATSAPP. Must contain {{MEETING_URL}} and the word STOP. |
customTemplatedSubjectLine | string | conditional | Required for EMAIL. |
language | string | no | Language code of the conversation, for example en. |
timeToAutoCloseConversationsInHours | number | no | Hours of inactivity before the conversation closes. Default: 168. |
Meeting:
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | Title of the meeting. Available in messages as {{MEETING_TITLE}}. |
durationMinutes | integer | yes | Length of the meeting in minutes. 1 to 1440. |
timezone | string | yes | IANA time zone, for example Europe/London. The times in availability use this time zone. |
availability | object | yes | The weekly hours in which a candidate can book. See Availability. |
participants | array | yes | The people from your team who attend. See Participants. |
availabilityMethod | string | no | COLLECTIVE (default) or ROUND_ROBIN_MAX_AVAILABILITY. See Participants. |
description | string | no | Description of the meeting. |
location | string | conditional | Physical location of an in-person meeting. Required when videoConference is not true, and must not be empty or only spaces. Not allowed when videoConference is true. Leading and trailing spaces are removed. A missing location fails create, update and publish with INVALID_NODE_CONFIG at nodes.<id>.config.location: location: A location is required for an in-person meeting. |
videoConference | boolean | no | true adds a video link to the meeting. Default: false. |
videoConferencingProvider | string | conditional | Google Meet or Microsoft Teams. Required when videoConference is true. If the organizer has a connected calendar, the meeting uses that calendar's provider. |
buffer | integer | no | Minutes kept free before and after each meeting. 0 to 120, in steps of 5. Default: 0. |
noticePeriodMinutes | integer | no | Minimum minutes between booking and the start of the meeting. Default: 0. |
availableDaysInTheFuture | integer | no | How many days ahead a candidate can book. Default: 30. |
sendMeetingReminder | integer | no | Minutes before the meeting to remind the candidate. SMS and WHATSAPP only. Default: 1440. |
availabilityOutreachSettings | object | no | Settings for the email that asks participants for their availability. See Availability Outreach Settings. |
guestLabel | string | no | Word the availability email uses for the person who books, for example recruiter. Up to 50 characters. Must not begin with "the". |
Availability
availability is the set of weekly hours in which a candidate can book. It repeats every week. noticePeriodMinutes and availableDaysInTheFuture set the first and last day a candidate can book.
Each key is a day of the week, from 0 (Sunday) to 6 (Saturday). Each value is a list of { "start", "end" } slots in HH:mm. Slots on the same day must not overlap. Leave out a day to make it unavailable. availability must contain at least one slot.
A candidate can book a time inside availability when the participants are free: every participant for a COLLECTIVE meeting, or at least one participant for a ROUND_ROBIN_MAX_AVAILABILITY meeting.
{
"timezone": "Europe/London",
"durationMinutes": 30,
"availability": {
"1": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"2": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"3": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"4": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"5": [{ "start": "09:00", "end": "12:00" }]
},
"noticePeriodMinutes": 1440,
"availableDaysInTheFuture": 14
}With this configuration, a candidate can book a 30-minute meeting on a weekday during these hours, London time, from 24 hours after booking up to 14 days ahead.
Participants
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Email address of the participant. |
name | string | yes | Display name. |
userCalendarId | string | no | ID of the calendar to use for this participant. To find it, call GET /v1/user-calendars?email= with the participant's email. The calendar must belong to email and to your organisation. If absent, Popp uses a calendar connected for email. |
isOrganizer | boolean | no | Default: false. |
autoCollectAvailability | boolean | no | true to ask a participant with no connected calendar for their availability by email. See Scheduling with Availability Collection. |
availabilityOutreachSettings | object | no | Overrides the node-level availabilityOutreachSettings for this participant. |
Each participant needs a connected calendar or autoCollectAvailability: true.
availabilityMethod | Who attends | Participants |
|---|---|---|
COLLECTIVE (default) | Every participant | Exactly one with isOrganizer: true. |
ROUND_ROBIN_MAX_AVAILABILITY | One participant from the pool | 2 to 10. |
Routing events
Nothing can come after a scheduling node, so every event ends the run.
| Event | When emitted | Run-level outcome |
|---|---|---|
onComplete | The candidate booked a meeting | completed |
onNotInterested | The candidate declined | completed |
onOptedOut | The candidate opted out | opted_out |
onNoReply | The conversation closed before the candidate booked | no_response |
onDeliveryFailed | Delivery failed | delivery_failed |
To tell a booking from a decline, read the node's routingEvent.
In webhooks
The node appears in the workflow events you already receive, with nodeType: "SCHEDULING_OUTREACH". Its outcome is meeting when the candidate books, and empty otherwise. See Workflow Events. If the candidate cancels or moves the meeting later, the CALENDAR_MEETING_CANCELLED and CALENDAR_MEETING_RESCHEDULED scheduling events report it. A reschedule request has no scheduling event. See Workflow Events for where to read it.
See Example 6 for a complete configuration.
analysis
analysisBeta. You can publish and run analysis nodes now. Fields, scoring and webhook payloads may change before general availability.
Scores each applicant against requirements you write, from their CV and the screening answers on their ATS application. The node does not branch: put a condition node after it to route on the result.
A workflow can have one analysis node, and it needs an ATS pathway. The node reads the applicant and the job from the ATS application the run entered through.
When you publish, Popp creates an analysis for the node. Read its scores with the workflow analysis routes: see Workflow-managed analyses.
Config fields
| Field | Type | Required | Notes |
|---|---|---|---|
requirements | array | yes | The requirements to score against, 1 to 40. |
Each requirement:
| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | yes | Identifies the requirement across versions. Lowercase letters, digits, _ and -, up to 50 characters, unique within the node. See Analysis node slugs. |
text | string | yes | The requirement text Popp scores against. Must not be empty. |
isMandatory | boolean | yes | requirementsMet is true only when the applicant meets every mandatory requirement. Whether a requirement is mandatory does not change the score. |
weight | number | yes | Greater than 0. The score is the weight of the requirements met, as a share of the total weight. |
Give the node its own slug too. Its analysis and the conditions after it name the node by it. See Analysis node slugs.
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | The applicant was scored | yes, when the node routes at all |
onAnalysisFailed | Popp could not score the applicant | no |
Route onComplete to the next node. If you leave routing empty ({}), the node has nothing after it, and it holds each run it scores. See An analysis node with nothing after it.
When Popp cannot score the applicant, onAnalysisFailed decides what happens:
- Wired: the run follows it.
analysis.<slug>holdsanalysisErrorCodeandcandidateIdinstead of a score. Route it to one node that handles every failure, or to aconditionnode that branches on the reason (see Branching on a failure). - Not wired: the run fails. Its
errorCodegives the reason:CV_TOO_LARGE,ANALYSIS_INPUT_UNAVAILABLEorANALYSIS_FAILED(see Workflow Events).
Branching on the result
A condition node after it names the analysis node by its slug, as in analysis.<slug>.score. The node's id works in place of the slug, but the slug names the same node in every version. The condition can read two fields:
| Field | Value | Operators |
|---|---|---|
analysis.<slug>.score | A whole number from 0 to 100 | gte, gt, lt, lte |
analysis.<slug>.requirementsMet | true or false | eq |
exists and empty work on both. One condition node branches on one of the two fields: using both across its branches is refused with MIXED_OPERATOR_FAMILIES. To check both, chain two condition nodes, as in the example below. The analysis node must be on every path from an entry to the condition, and every such path must leave it by onComplete, or the condition is refused with UNREACHABLE_CONTEXT.
This workflow rejects an applicant who misses a mandatory requirement or scores under 70, and moves everyone else to a first interview:
{
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "score-cv", "atsJobIds": ["<atsJobId>"], "atsStageSelections": [{ "type": "allStages", "atsJobId": "<atsJobId>" }] }
],
"nodes": {
"score-cv": {
"id": "score-cv",
"type": "analysis",
"slug": "score-cv",
"label": "Score the CV",
"routing": { "onComplete": "check-mandatory" },
"config": {
"requirements": [
{
"slug": "nmc-registration",
"text": "Active NMC registration as a Registered General Nurse",
"isMandatory": true,
"weight": 3
},
{
"slug": "acute-experience",
"text": "At least two years of acute ward experience",
"isMandatory": false,
"weight": 2
},
{
"slug": "band-6",
"text": "Has worked at Band 6 or above",
"isMandatory": false,
"weight": 1
}
]
}
},
"check-mandatory": {
"id": "check-mandatory",
"type": "condition",
"label": "Mandatory requirements met?",
"config": {
"branches": [
{
"id": "mandatory-met",
"label": "Every mandatory requirement met",
"when": { "op": "eq", "field": "analysis.score-cv.requirementsMet", "value": true },
"targetNodeId": "check-score"
}
],
"else": "reject",
"elseLabel": "A mandatory requirement missed"
}
},
"check-score": {
"id": "check-score",
"type": "condition",
"label": "Score 70 or more?",
"config": {
"branches": [
{
"id": "strong-match",
"label": "Score 70 or more",
"when": { "op": "gte", "field": "analysis.score-cv.score", "value": 70 },
"targetNodeId": "move-to-interview"
}
],
"else": "reject",
"elseLabel": "Score under 70"
}
},
"move-to-interview": {
"id": "move-to-interview",
"type": "updateAtsApplication",
"label": "Move to first interview",
"routing": {},
"config": {
"operation": "moveStage",
"targets": [{ "atsJobId": "<atsJobId>", "atsInterviewStageId": "<atsStageId>" }]
}
},
"reject": {
"id": "reject",
"type": "updateAtsApplication",
"label": "Reject",
"routing": {},
"config": {
"operation": "reject",
"targets": [{ "atsJobId": "<atsJobId>", "atsRejectedReasonId": "<atsReasonId>" }]
}
}
}
}Branching on a failure
On onAnalysisFailed, the node writes why it could not score the applicant:
| Field | Value | Operators |
|---|---|---|
analysis.<slug>.analysisErrorCode | DOCUMENT_TOO_LARGE, ANALYSIS_INPUT_UNAVAILABLE or ANALYSIS_FAILED | eq |
| Value | Meaning |
|---|---|
DOCUMENT_TOO_LARGE | The CV's text is too long to score. |
ANALYSIS_INPUT_UNAVAILABLE | There was no CV text and no screening answer to score. |
ANALYSIS_FAILED | Scoring failed on Popp's side, and retrying did not fix it. |
A code you don't branch on takes the condition's else.
A condition can only read what the route into it wrote. These branches are refused with UNREACHABLE_CONTEXT, including with exists and empty:
- a branch on
scoreorrequirementsMetthat a path throughonAnalysisFailedreaches - a branch on
analysisErrorCodethat a path throughonCompletereaches
So a condition that both routes reach can't branch on any of the three fields. Wire the two routes to different nodes instead.
To send applicants who had nothing to score to rejection, and every other failure to a recruiter, change the score-cv node's routing in the example above and add two nodes:
{
"score-cv": {
"routing": { "onComplete": "check-mandatory", "onAnalysisFailed": "check-failure" }
},
"check-failure": {
"id": "check-failure",
"type": "condition",
"label": "Why couldn't it be scored?",
"config": {
"branches": [
{
"id": "nothing-to-score",
"label": "No CV text or answers",
"when": {
"op": "eq",
"field": "analysis.score-cv.analysisErrorCode",
"value": "ANALYSIS_INPUT_UNAVAILABLE"
},
"targetNodeId": "reject"
}
],
"else": "move-to-review",
"elseLabel": "Any other failure"
}
},
"move-to-review": {
"id": "move-to-review",
"type": "updateAtsApplication",
"label": "Move to recruiter review",
"routing": {},
"config": {
"operation": "moveStage",
"targets": [{ "atsJobId": "<atsJobId>", "atsInterviewStageId": "<reviewStageId>" }]
}
}
}An analysis node with nothing after it
An analysis node with empty routing has nothing after it, and it holds each run it scores. You can publish the requirements first and connect the nodes that act on the score in a later version.
- The run stays
RUNNINGat the node. Itsstateisanalysed. Popp sends noWORKFLOW_NODE_COMPLETEDfor the node and noWORKFLOW_RUN_COMPLETED. - A newer score replaces the one the run holds. A publish that changes the requirements re-scores the node's applicants, and each run held there takes its new score. A score older than the one the run holds changes nothing.
- A publish that connects a node after it moves each held run on, once, if the run moves to the new version. The run leaves the analysis node by
onComplete, so the node after it routes on the score the run holds. If that publish also changes the requirements, the run waits for its re-score and moves on with the new score. A run that stays on its version stays held. See Publishing while runs wait at the node. - A re-score that can't score the applicant is handled like a first score that can't. The run follows
onAnalysisFailed, or fails with the reason. A failure older than the score the run holds changes nothing, and so does one made on requirements a later publish changed: the run keeps its score and waits for the re-score. - Before a held run moves on, Popp checks the ATS application the run entered through. If the application was rejected or declined, or is in a stage the live version doesn't select, Popp cancels the run without contacting the candidate.
cancellationCodeisATS_APPLICATION_REJECTEDorATS_STAGE_NOT_SELECTED.
Publishing while runs wait at the node
A run waiting at an analysis node, scored or not, moves to each new version that has an analysis node under the same slug. Its workflowVersionId and workflowVersionNumber change, and its journey shows the version each node ran on. The run stays on its version if the new version puts after the analysis node any node that can lead to it in the run's version, whether or not the run passed through that node. A run at any other node stays on its version.
Popp cancels the runs waiting at an analysis node, without contacting the candidates, when a publish removes the node (cancellationCode: ANALYSIS_STEP_REMOVED) or the workflow is archived (WORKFLOW_ARCHIVED). See Cancellation codes.
In webhooks
The node appears in the workflow events you already receive, with nodeType: "ANALYSIS" and an analysis outcome that carries the same score and requirementsMet. See Workflow Events. When the run leaves by onAnalysisFailed, the outcome is analysisFailure instead, with the analysisErrorCode. See Workflow Events.
Context the agent uses to write replies
When a candidate replies, the agent writes the next message from the node's config. The more of these fields you set, the better it can answer questions about the role and word nudges, rejections and closing messages.
| Role | Source field(s) | Purpose |
|---|---|---|
| Job description | outreachContext | The full role description. Lets the agent answer questions about the role. |
| Job summary | summaryOfRole | Short summary read alongside the full description. Useful when the description is long and you want the agent to lead with a one-line framing. |
| Job title | roleTitle | Available to the agent. Messages can use it as {{JOB_TITLE}}, except on engagementOutreach. |
| Employer name | employerName | Available to the agent. Messages can use it as {{EMPLOYER_NAME}}, except on engagementOutreach. |
| Job location | location | Available to the agent. Messages can use it as {{LOCATION}}, except on engagementOutreach. |
| Contract type | contractType | The agent uses it when contract terms come up. |
| Steering | additionalContext | Free-form notes for the agent: tone, style, things to avoid, edge cases, anything you would brief a recruiter on. |
| Agent persona | agentName, agentTone | Sets the agent's name, voice and signature. See Agent reference. |
| Opening copy | customTemplatedMessage (+ customTemplatedSubjectLine for email) | The first message, sent as written. Popp fills in {{...}} template variables when it sends. |
| Lifecycle copy | criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage | Messages the agent uses when it closes on a passed candidate, closes on a failed candidate, or wraps up after a not-interested reply. |
The agent gets roleTitle, employerName, location and summaryOfRole only when roleTitle, employerName and location are all set, on either node type.
- Put the role description in
outreachContextand tone notes inadditionalContext. Swapping them produces replies that miss the brief.
Routing events
Each node type emits these events. Route the ones you want to handle in the node's routing map.
| Event | Emitted by | When it fires |
|---|---|---|
onComplete | screeningOutreach, engagementOutreach | The conversation finished: passed, failed or closed normally. |
onComplete | createAtsApplication | The candidate was added to the destination ATS job, or was already on it. |
onComplete | updateAtsApplication | The ATS update finished, or the node was skipped for a run with no ATS application. |
onNotInterested | screeningOutreach, engagementOutreach | The candidate declined. |
onOptedOut | screeningOutreach, engagementOutreach | The candidate opted out (e.g. replied STOP over SMS). |
onDeliveryFailed | screeningOutreach, engagementOutreach | Message delivery failed across the available channels (e.g. invalid mobile number). |
onNoReply | screeningOutreach, engagementOutreach | The conversation closed after timeToAutoCloseConversationsInHours with no new message. If you don't route it, the run ends with outcome: no_response. |
onComplete | schedulingOutreach | The candidate booked a meeting. |
onNotInterested, onOptedOut, onDeliveryFailed | schedulingOutreach | As for the outreach nodes above. |
onNoReply | schedulingOutreach | The conversation closed before the candidate booked. |
onComplete | analysis | The applicant was scored. A run held at an analysis node with nothing after it leaves by it when a publish connects a node after it and moves the run on. |
onAnalysisFailed | analysis | Popp could not score the applicant. Unlike other events, leaving it unwired fails the run. |
Answer-route DSL
Answer routes belong to individual questions on screeningOutreach and engagementOutreach nodes. They act while the candidate is still answering: skip a follow-up after a "no", close the conversation early when a score is too low, or hand off to a different node.
Two layers of routing
Outreach nodes route at two levels:
- Answer routes (this section) act during the conversation, on each answer, score or document upload. They pick the next question, close the conversation early, or (with
ROUTE_TO) send the run somewhere other thanrouting.onComplete. - Routing events (see Routing Events) fire when the conversation ends. The node's
routingmap picks the next node.
An answer route's actions decide which routing event fires:
| In-conversation action | Effect on node-level routing |
|---|---|
CONTINUE | None. The conversation continues. |
CLOSE_CONVERSATION with status: "PASSED_CRITERIA" | The conversation ends. onComplete fires with webhook outcome PASSED_CRITERIA, and the run follows routing.onComplete. |
CLOSE_CONVERSATION with status: "FAILED_CRITERIA" | The conversation ends. onComplete fires with webhook outcome FAILED_CRITERIA, and the run follows routing.onComplete. |
ROUTE_TO (paired with CLOSE_CONVERSATION) | The conversation ends with the paired status, and the run goes to nodeId instead of routing.onComplete. |
Answer routes cannot fire onNotInterested, onOptedOut, onDeliveryFailed or onNoReply. Popp fires them from what the candidate does or from delivery. Route them in the node's routing map to handle them.
Route shape
Each entry in answerRoutes[] looks like this:
{
"when": { "field": "...", "op": "...", "value": "..." },
"actions": [
{ "type": "CONTINUE", "slug": "next-question" }
],
"closingMessage": "Optional closing copy used when actions terminate the conversation"
}| Field | Type | Required | Notes |
|---|---|---|---|
when | object | yes | A single condition. See Condition vocabulary. |
actions | array | yes | One or more actions, run in order. See Actions. |
closingMessage | string | no | Up to 1000 characters. Used as the last message when the route's actions end the conversation. |
The first matching route wins. Popp checks routes in array order and runs the first whose
whenmatches; later routes don't fire. Put specific routes before broader ones (e.g.mentions "Greater London"beforementions "London"). A catch-all (answerState=any) must come last, or it is rejected withCATCH_ALL_NOT_LAST. Condition-nodebrancheswork the same way.
Condition vocabulary
An answer route's when is a single condition; and, or and not are not allowed. Use a condition node for compound logic.
field | Operators | Value type | Notes |
|---|---|---|---|
answerState | eq | "yes" | "no" | "any" | How Popp classified the candidate's answer. |
answerText | mentions, does_not_mention | non-empty string | Matches the meaning of the answer, not exact words. |
documentState | eq | "uploaded" | "not_uploaded" | Whether the candidate uploaded the requested document. |
score | gte, lt | integer 0–100 | Score against preferredAnswerText, which the question must have. See the note below. |
Answer-route
scoreconditions accept onlygteandlt.gtandlteare rejected withINVALID_NODE_CONFIG. Writescore >= 70instead ofscore > 69, andscore < 70instead ofscore <= 69.conditionnodes accept all four (see Condition-node DSL).
Only the operators in the table work on answer routes. Any other, such as
neqonanswerState,inondocumentStateoreqonscore, is rejected withINVALID_NODE_CONFIG, at apathendinganswerRoutes.<j>.when.op.existsandemptywork only onconditionnodes.
Actions
A route's actions run in order. Publish errors for actions are listed under Answer-route specific.
CONTINUE
CONTINUEMoves to another question in the same node, named by slug:
{ "type": "CONTINUE", "slug": "intent-to-register" }CLOSE_CONVERSATION
CLOSE_CONVERSATIONEnds the conversation. The status you set (PASSED_CRITERIA or FAILED_CRITERIA) is the outcome onComplete reports on the WORKFLOW_NODE_COMPLETED webhook. See Conversation outcomes.
{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }The run then follows routing.onComplete, unless the route also has a ROUTE_TO.
ROUTE_TO
ROUTE_TOSends the run to a different node when the conversation closes, instead of routing.onComplete.
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }Pair it with a CLOSE_CONVERSATION in the same route, which sets the conversation's final status. Without one, the route is rejected with ROUTE_TO_WITHOUT_CLOSE:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" },
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
]
}Examples
Fail the candidate if they aren't NMC-registered:
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }]
}Skip to the shift-pattern question if the candidate scored 70 or more:
{
"when": { "field": "score", "op": "gte", "value": 70 },
"actions": [{ "type": "CONTINUE", "slug": "shift-pattern" }]
}Ask a London-specific question if the answer mentions London:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-trust-preference" }]
}Close the conversation and hand off to a London-specific compliance node when the answer mentions London:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" },
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
],
"closingMessage": "Thanks — I'll hand you over to our London compliance team."
}Condition-node DSL
A condition node branch's when can nest expressions with and, or and not.
Operators
| Operator | Args | Description |
|---|---|---|
eq | field, value | Field equals value |
neq | field, value | Field not equal to value |
gt, gte, lt, lte | field, value (number) | Numeric comparison |
in | field, values[] | Field equals one of the listed values |
mentions | field, value (string) | String/array contains the substring or item |
does_not_mention | field, value (string) | Inverse of mentions |
exists | field | Field is present and non-null |
empty | field | Field is absent, null, or an empty string/array |
and | conditions[] | All sub-expressions must be true. conditions must be non-empty. |
or | conditions[] | At least one sub-expression must be true. conditions must be non-empty. |
not | condition | Inverts the sub-expression |
An empty conditions[] is rejected with INVALID_NODE_CONFIG, with path pointing at the empty array.
Field paths
Field paths are dot-separated, and the first segment must be one of these namespaces:
| Namespace | What it contains |
|---|---|
trigger | How and when the run started. See trigger fields. |
candidate | Identity and contact fields for the candidate enrolled in this run. |
job | Role/job-level fields associated with the run. |
conversations | Conversation outcomes, by node. |
answers | Question outcomes, by question slug. |
analysis | Analysis results, by analysis node. See analysis. |
ats | Add to job and Update ATS application outcomes, by node: ats.<slug>.decision. |
Any other first segment is rejected with INVALID_CONDITION_FIELD.
In conversations, analysis and ats, the second segment names the node by its slug or its id, as in analysis.<slug>.score. Use the slug: it names the same node in every version, while a node's id can change. Both forms read the same outcome. The run's context keeps each outcome under the node's id.
Every path from an entry to the condition must produce what you read, or the condition is refused with UNREACHABLE_CONTEXT:
conversations.<slug>andanalysis.<slug>must name a node in the workflow. Foranalysis, it must be an analysis node on every such path, and every path must leave it by the route that writes the field (see Branching on a failure).answers.<slug>must name a question that an outreach node defines, and that node must complete on every such path. This doesn't guarantee the run asked the question: an earlier answer route can close the conversation before it.
trigger fields
trigger fieldsThese trigger fields are available in conditions:
| Field | Value type |
|---|---|
source | API, ATS, INTERNAL_SOURCING, SPREADSHEET or PUSH: the entry source of the run |
triggeredAt | ISO 8601 timestamp of the start of the run |
atsStageId | string. ATS runs only: the application's ATS stage ID when the run started |
trigger.atsStageId is the atsStageId from GET /v1/ats/jobs/{atsJobId}/stages, the same id that atsStageSelections[].atsStageIds lists. Popp records it one time, when the run starts. A later stage change in your ATS does not change it. Runs from other sources do not have it. An ATS run also does not have it when your ATS gave no stage for the application.
Branch on the entry stage with in:
{ "op": "in", "field": "trigger.atsStageId", "values": ["<atsStageId>", "<atsStageId>"] }A run without trigger.atsStageId matches no such branch, and takes else.
answers fields
answers fieldsA condition compares two fields of a question's answer, answers.<slug>.score and answers.<slug>.outcome:
| Field | Value type | Operators |
|---|---|---|
score | integer 0–100 (only when the question carried preferredAnswerText) | gte, gt, lt, lte |
outcome | PASSED, FAILED, NOT_COMPLETED, NEEDS_REVIEW, PENDING, INCOMPLETE. See Workflow Events | eq, neq, in |
Branch on a score:
{ "op": "gte", "field": "answers.shift-availability.score", "value": 70 }Branch on an outcome:
{ "op": "eq", "field": "answers.nmc-pin.outcome", "value": "PASSED" }Comparing any other field under answers.<slug>, such as answerState or answerText, is refused with INVALID_FIELD_NAMESPACE. exists and empty still check whether the answer holds any field the run stores, such as answerState. To branch on what a candidate said, use the question's answer routes. A yes/no question with expectedAnswer has outcome: PASSED when the candidate gave the expected answer.
INCOMPLETE means Popp could not classify a yes/no answer because the reply was too vague. Route on it with { "op": "eq", "field": "answers.<slug>.outcome", "value": "INCOMPLETE" } to send those candidates to a clarification branch.
Depth limit
An expression nested more than 10 levels deep evaluates to false. Split deep logic across several condition nodes.
Examples
Fast-track candidates who scored at least 70 on the shift-availability question:
{ "op": "gte", "field": "answers.shift-availability.score", "value": 70 }Route candidates who passed the NMC PIN question and scored 60 or more on shift availability:
{
"op": "and",
"conditions": [
{ "op": "eq", "field": "answers.nmc-pin.outcome", "value": "PASSED" },
{ "op": "gte", "field": "answers.shift-availability.score", "value": 60 }
]
}Configuration validation
Create, update and publish all validate the configuration, and each lists every problem it finds, not only the first.
| Endpoint | When validation fails |
|---|---|
POST /v1/workflows | 400. The workflow is not created. |
PATCH /v1/workflows/{workflowId} | 400. |
POST /v1/workflows/{workflowId}/publish | 422. Nothing is published, and no version or campaign is created. |
A 400 body's message starts Workflow validation failed: and lists each problem as ERROR_CODE: message, separated by ; . A problem that belongs to one node starts with [node-id]. Only publish returns the problems as structured data.
A 422 body has the shape of a successful publish plus a top-level message. validation.valid is false, and validation.errors has one entry per problem with its errorCode, message, nodeId and path. A successful publish returns 200 with validation.valid: true.
Publish checks in three stages, and runs each only when the one before passes: the configuration itself; then its references to your organization's templates, scheduling setup, ATS and document types; then the comparison with the live version. Fixing one set of errors can reveal the next.
Publish-only checks. Create and update run the same checks as publish, including references such as WhatsApp template eligibility, except these:
documentItemsreferences (DOCUMENT_TYPE_NOT_FOUND,DOCUMENT_TYPE_ARCHIVED).- ATS node targets and Add to job destinations, which Popp checks against your ATS. See ATS application targets and Add to job config.
- The node slug rules, except
DUPLICATE_NODE_SLUG.
Publish also refuses for three reasons outside the configuration, with 400 rather than 422: ATS_JOB_ALREADY_CONNECTED, RUBRIC_GENERATION_FAILED and JOB_SUMMARY_GENERATION_FAILED. The two generation codes return 503 instead when generation is unavailable.
Graph-level
| Error code | Cause |
|---|---|
START_NODE_NOT_FOUND | startNodeId does not appear in nodes. |
UNSUPPORTED_NODE_TYPE | Node type is not a supported node type. |
MISSING_REQUIRED_ROUTE | A node did not route a routing event marked as required. |
UNKNOWN_ROUTE | A node's routing map contains an event name the node does not emit. A condition node emits none, so any entry in its routing is refused. path is nodes.<node-id>.routing.<event>. |
UNREACHABLE_NODE | A node is defined but cannot be reached from any entry point (the start node or an entry pathway target). |
CYCLE_DETECTED | The graph contains a cycle. A run cannot revisit a node. |
INVALID_GRAPH_EDGE_TARGET | A routing target points to a node ID that does not exist in the graph. |
INVALID_CONFIG_SHAPE | The configuration is not valid JSON, or does not match the configuration shape. The message names the problem. This error comes on its own, with no nodeId or path. |
Outreach config
| Error code | Cause |
|---|---|
MISSING_OUTREACH_CONFIG_FIELD | A required field on a screeningOutreach/engagementOutreach config is missing. |
MISSING_AGENT_REFERENCE | agentName or agentTone is missing. One error per missing field. See Agent reference. |
MISSING_NODE_CONFIG | A node has no config object at all. |
INVALID_NODE_CONFIG | The node config doesn't match its shape, and no more specific code applies. |
MISSING_OUTREACH_QUESTIONS | The questions array is empty or missing on a conversational outreach node (screeningOutreach or engagementOutreach). |
MISSING_QUESTION_CONTENT | A question's content is missing or empty. |
MISSING_ANSWER_ROUTES | A question has no answerRoutes, or an empty array. Add at least one route whose actions close the conversation, route to another node, or CONTINUE to another question by slug. |
DUPLICATE_QUESTION_SLUG | Two or more questions share the same slug, in the same node or in different nodes. A question's slug must be unique across the workflow. |
MISSING_OUTREACH_MESSAGE | Neither templateId nor customTemplatedMessage provided (and channel is not WHATSAPP). |
MISSING_EMAIL_SUBJECT | channel: EMAIL without customTemplatedSubjectLine. |
INVALID_EMAIL_TEMPLATE | templateId provided on an EMAIL channel. |
MISSING_SMS_STOP | channel: SMS with customTemplatedMessage missing the STOP keyword. |
MISSING_WHATSAPP_TEMPLATE | channel: WHATSAPP without templateId. |
INCOMPATIBLE_CAMPAIGN_TYPE | campaignType names a campaign the node type cannot run, e.g. SCHEDULING or ENGAGEMENT_OUTREACH on a screeningOutreach node. The message lists the values that node type accepts. See Campaign type. |
MALFORMED_TEMPLATE_MARKUP | A message field holds editor markup in the form @[name](id) instead of a template variable such as {{CANDIDATE_FIRST_NAME}}. One error for each occurrence. |
DOCUMENT_ITEMS_XOR_VIOLATION | A DOCUMENT question has both documentItems and documentTypes. Use one of them. |
MIXED_DOCUMENT_QUESTION_SHAPES | Some DOCUMENT questions on the node use documentItems and others use documentTypes. Use the same field on every DOCUMENT question of a node. One error for each of those questions. |
DOCUMENT_TYPE_NOT_FOUND | Publish only. A documentItems entry names a documentTypeId that your organization does not have. path is nodes.<node-id>.config.questions[<index>].documentItems. |
DOCUMENT_TYPE_ARCHIVED | Publish only. A documentItems entry names a document type that is archived. path is nodes.<node-id>.config.questions[<index>].documentItems. |
MISSING_RUBRIC_SOURCE | An answer route uses field: "score" on a question with no preferredAnswerText. Add a non-empty preferredAnswerText to make the question scored. |
RUBRIC_GENERATION_FAILED | Publish only. Popp could not build a scoring rubric from a question's preferredAnswerText, because the text is too vague or because of a temporary error. The message has the form Workflow validation failed: RUBRIC_GENERATION_FAILED on question '<slug>'. Retry. If it fails again, make preferredAnswerText more concrete. |
JOB_SUMMARY_GENERATION_FAILED | Publish only. A screeningOutreach node has no summaryOfRole, and Popp could not generate one, because of a temporary error or a blank result. The message has the form Workflow validation failed: JOB_SUMMARY_GENERATION_FAILED on node '<nodeId>'. Send the same request again. If it fails again, set a non-blank summaryOfRole on the node; Popp generates a summary only when it is missing or blank. |
Question-shape coherence
Publish checks each question against the four question shapes.
| Error code | Cause |
|---|---|
INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE | A scalar field doesn't match the question's questionType. Examples: expectedAnswer, strictness, or preferred-answer fields on a DOCUMENT question; documentTypes on a TEXT question. The path identifies the offending field. |
INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE | The dominant answer-route operator family doesn't match the question's questionType. DOCUMENT questions accept only document-state routes; TEXT questions accept yes/no, text-mention, or scoring routes. |
EXPECTED_ANSWER_ON_NON_YES_NO_QUESTION | expectedAnswer is set on a question whose answerRoutes use scoring, text-mention or document-state operators. expectedAnswer belongs to yes/no questions only. |
PREFERRED_ANSWER_ON_YES_NO_QUESTION | preferredAnswerText is set on a yes/no question: one with expectedAnswer, or with answerState eq yes / answerState eq no routes. Yes/no and scoring shapes cannot mix. |
INVALID_OPERATOR_FOR_FIELD | A condition branch uses an operator its field does not accept. requirementsMet/analysisErrorCode/decision accept only eq; score accepts gte/gt/lt/lte. Presence operators (exists/empty) are exempt. A wrong operator on an answer route is INVALID_NODE_CONFIG instead. |
Answer-route specific
| Error code | Cause |
|---|---|
INVALID_CONTINUE_SLUG | A CONTINUE action's slug does not match any question in the same node. |
CONTINUE_SLUG_CYCLE | CONTINUE targets within a node form a cycle (e.g. q1 → q2 → q1). |
ROUTE_TO_WITHOUT_CLOSE | A ROUTE_TO action has no CLOSE_CONVERSATION in the same route. |
INVALID_ROUTE_TO_ACTION_TARGET | A ROUTE_TO action's nodeId is not a node in the workflow. |
BARE_CONTINUE_ROUTE | A CONTINUE action has no slug. Add one, or replace the action with CLOSE_CONVERSATION (end the run) or ROUTE_TO + CLOSE_CONVERSATION (hand off to another node). |
Answer-logic coherence and reachability
These check all the answer routes on a question, or all the branches on a condition node, together.
| Error code | Cause |
|---|---|
MIXED_OPERATOR_FAMILIES | The answer routes (or condition branches) on a single node mix operators from different compatibility groups. Each fan-out must use one group throughout: answer-state (answerState=eq), answer-text (answerText mentions/does_not_mention), score (gte/lt), document-state (documentState=eq), requirements-met (analysis.<slug>.requirementsMet eq), or analysis-error-code (analysis.<slug>.analysisErrorCode eq). A trailing answerState=any catch-all is exempt, so it can end an answer-text fan-out. |
MISSING_FALLBACK_ROUTE | The fan-out doesn't cover every input. Cases: • answer-text fan-out is missing the trailing catch-all ({ field: "answerState", op: "eq", value: "any" }).• score fan-out doesn't span the full 0–100 domain.• Binary-intent question (see Question shapes) isn't a complete pair: use documentState=uploaded + =not_uploaded on DOCUMENT, or answerState=yes + =no on TEXT with expectedAnswer. A sole catch-all, a missing half, or a redundant catch-all alongside the pair all fire this code. |
INVALID_FIELD_NAMESPACE | A condition branch names a field its namespace doesn't have, such as answers.<slug>.answerState, or uses the reserved meetings.* namespace. Condition nodes take prefixed fields (answers.<slug>.score, answers.<slug>.outcome, analysis.<slug>.score, analysis.<slug>.requirementsMet, analysis.<slug>.analysisErrorCode and ats.<slug>.decision). An unprefixed field on a condition node is INVALID_CONDITION_FIELD, and a prefixed field on an answer route is INVALID_NODE_CONFIG. |
MISSING_CLOSE_CONVERSATION | A path through the node's questions ends without a CLOSE_CONVERSATION action, so the conversation would close with no closing message. Add a CLOSE_CONVERSATION (with status: "PASSED_CRITERIA" or status: "FAILED_CRITERIA") on every final answer route. |
CATCH_ALL_NOT_LAST | A catch-all route (answerState=any) comes before another route, so the later route can never run. Move the catch-all to the end. |
SCORE_RANGE_UNREACHABLE | Earlier score routes cover this route's whole range, so it never runs. Change or reorder the thresholds. |
ROUTE_CONDITION_UNREACHABLE | A route or branch repeats the exact field, op and value of an earlier one in the same fan-out. The first match wins, so the later one never runs. Remove it or change its condition. |
Worked example — a fan-out that fails today
{
"slug": "region",
"content": "Which UK region or trusts are you available to cover?",
"answerRoutes": [
{ "when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-shifts" }] },
{ "when": { "field": "answerText", "op": "mentions", "value": "Manchester" },
"actions": [{ "type": "CONTINUE", "slug": "manchester-shifts" }] }
]
}This is rejected with MISSING_FALLBACK_ROUTE: an answer-text fan-out needs a catch-all for answers that mention neither term. Add one:
"answerRoutes": [
{ "when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-shifts" }] },
{ "when": { "field": "answerText", "op": "mentions", "value": "Manchester" },
"actions": [{ "type": "CONTINUE", "slug": "manchester-shifts" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }] }
]Condition-node specific
Unknown operators, missing fields and wrong value types are rejected.
| Error code | Cause |
|---|---|
INVALID_CONDITION_FIELD | A field path does not begin with a known namespace (trigger, candidate, job, conversations, answers, analysis, ats). |
UNREACHABLE_CONTEXT | A condition node reads something that is not produced on every path from an entry to the node: a node or question that no node in the workflow has, an answers.<slug>.* question whose node does not complete on every such path, or an analysis.<slug>.* field whose node is not an analysis node on every such path. See Field paths. Also returned for a branch on score or requirementsMet that a path through onAnalysisFailed reaches, or on analysisErrorCode that a path through onComplete reaches. |
Node slugs
A node's slug is its permanent name. It names the same node in every version of the workflow, while the node's id can change, for example when you delete a node and add it back. Entry pathways and the workflow analysis routes name a node by its slug, and conditions can. Workflow webhooks and workflow runs carry it beside the node's id.
- A draft saves without slugs, except on the node an entry pathway targets.
entrySlugmatches only aslug, so a draft whose entry node has none is refused withPATHWAY_TARGET_NOT_FOUND.POST /v1/workflowsandPATCH /v1/workflows/{workflowId}accept any other node with noslug. - Publish fills in a missing slug. A node gets the slug its
idwas published with. A node you removed and added back with the sametypeandlabelgets the slug it had. Any other node gets one made from itslabel, or from itstypewhen the label has no letter or digit. Popp doesn't write the filled slug into your draft, so set the slug yourself on any node you name by slug. - It can't change once published. Removing a node and adding it back with the same
slugkeeps it the same node.
Only publish checks these:
| Error code | Cause |
|---|---|
NODE_SLUG_CHANGED | A node has a different slug from the node with the same id in the live version. path is nodes.<node-id>.slug. |
NODE_SLUG_IS_ANOTHER_NODES_ID | A node's slug is the id of another node in the workflow. A condition names a node by either, so they can't be the same. Rename the other node's id. path is nodes.<node-id>.slug. |
Create, update and publish all refuse two nodes with the same slug, as DUPLICATE_NODE_SLUG.
Analysis node slugs
An analysis node's slug names its analysis, which you read at GET /v1/workflows/{workflowId}/analyses/{workflowNodeSlug}. Removing the node and adding it back with the same slug continues the same analysis.
Each requirement has a slug too. Rewording a requirement under its published slug is allowed. Moving a published requirement's text to a new slug is refused at publish:
| Error code | Cause |
|---|---|
REQUIREMENT_SLUG_CHANGED | A requirement under a new slug has exactly the text of a requirement in the live version that this version removes. path is nodes.<node-id>.config.requirements[<index>].slug. |
Question slugs
A question's slug names its answer in a run, in answers.<slug> conditions and in workflow events, so it can't change once published. Publish matches a draft question to a live question in the same outreach node by its id first, then by exactly the same content when no other question in either node has that content. The same outreach node is the node with the same node id in both versions. Rewording a question under its published slug is allowed, and a first publish is never refused this way.
| Error code | Cause |
|---|---|
QUESTION_SLUG_CHANGED | A question matches a question in the live version but has a different slug. The message names the published slug. path is nodes.<node-id>.config.questions[<index>].slug. Set the question's slug back to the published one and publish again. |
Analysis config
These checks run on create, update and publish. See analysis.
| Error code | Cause |
|---|---|
INVALID_NODE_CONFIG | requirements is missing, empty or longer than 40, or a requirement has a missing field, an empty text, a slug outside the grammar, or a weight of 0 or less. The message names the field. |
DUPLICATE_REQUIREMENT_SLUG | Two requirements on the node share a slug. path is nodes.<node-id>.config.requirements. |
MULTIPLE_ANALYSIS_NODES | The workflow has more than one analysis node. Each analysis node after the first in nodes gets this error, with path nodes.<node-id>, and the message names the first. |
ANALYSIS_REQUIRES_ATS_ENTRY | The workflow has an analysis node but no ATS entry pathway. path is nodes.<node-id>. |
INVALID_VALUE_FOR_FIELD | A condition compares analysis.<slug>.requirementsMet with something other than true or false, or analysis.<slug>.analysisErrorCode with something other than DOCUMENT_TOO_LARGE, ANALYSIS_INPUT_UNAVAILABLE or ANALYSIS_FAILED. |
Add to job config
The first five codes run on create, update and publish. The last four check your ATS, so run only at publish.
| Error code | Cause |
|---|---|
MISSING_ATS_DESTINATION_JOB | atsJobId is missing, empty, or not a string on a createAtsApplication config. |
MISSING_ATS_DESTINATION_STAGE | atsStageId is missing, empty, or not a string. Each required destination field has its own error code. |
ATS_DESTINATION_IS_ENTRY_JOB | The destination is on this workflow's own ATS entry pathway, so adding a candidate would start the workflow again. The message names the node and job; path is nodes.<node-id>.config.atsJobId. |
CREATE_ATS_APPLICATION_UNSUPPORTED_ENTRY | An entry source other than ATS or Search & Match can reach the node. The message names the node and every offending source; path is nodes.<node-id>. |
ATS_DESTINATION_IS_UPSTREAM_JOB | An Add to job node that can lead to this one adds candidates to the same atsJobId. A candidate already on a job cannot be added to it again. path is nodes.<node-id>.config.atsJobId. |
ATS_DESTINATION_JOB_NOT_FOUND | Publish only. Your ATS does not have the atsJobId. path is nodes.<node-id>.config.atsJobId. |
ATS_STAGE_NOT_FOUND | Publish only. The selected stage is unavailable for that job because it was deleted or belongs to another job. The message names the node and atsStageId, and distinguishes a stage unavailable for the job from one unavailable for the organization. |
ATS_CAPABILITY_UNAVAILABLE | Publish only. Your ATS cannot provide job stages, so no createAtsApplication node can work with it. |
ATS_CHECK_UNAVAILABLE | Publish only. Returns 503, not an entry in validation.errors: Popp could not reach your ATS, so nothing was published. Your configuration may be valid. Publish again. |
If the stage is deleted after publish, the next run to reach the node fails. See createAtsApplication.
ATS application targets
Checks on an updateAtsApplication node.
Create, update and publish
| Error code | Cause |
|---|---|
MISSING_ATS_JOB_MAPPING | An ATS job can reach the node but has no entry in targets. |
UNEXPECTED_ATS_JOB_MAPPING | A targets entry names a job that cannot reach the node. Applies only when at least one ATS job reaches the node. |
DUPLICATE_ATS_JOB_MAPPING | The same atsJobId appears in targets more than once. |
MISSING_NODE_CONFIG | The node has no config at all. |
INVALID_NODE_CONFIG | The config does not match the shape: an unknown operation, an empty targets, an empty id, or an unrecognized key. |
MULTIPLE_ATS_TARGETS | targets has more than one entry on a node that no ATS job reaches. path is nodes.<node-id>.config.targets. |
UPDATE_ATS_APPLICATION_AFTER_CREATE_UNSUPPORTED | The node can be reached from an Add to job node. Changing an application after Add to job is not supported yet. path is nodes.<node-id>. |
Publish only, against your ATS
Each error has the node in nodeId and the field in path, for example nodes.move-to-interview.config.targets[0].atsInterviewStageId.
| Error code | Returned as | Cause |
|---|---|---|
ATS_STAGE_NOT_FOUND | validation.errors | A moveStage target names a stage that is not on the job's pipeline. |
ATS_REJECTION_REASON_NOT_FOUND | validation.errors | A reject target names a reason your ATS does not have. |
ATS_CAPABILITY_UNAVAILABLE | validation.errors | Your ATS cannot provide the list a node's targets need, for example rejection reasons for a reject node. |
ATS_TARGET_JOB_LIMIT_EXCEEDED | validation.errors | The workflow's ATS nodes name more than 250 distinct ATS jobs between them. |
ATS_CHECK_UNAVAILABLE | HTTP 503 | Popp could not reach your ATS, so nothing was published. Your configuration may be valid. Send the request again. |
Every code here except ATS_CHECK_UNAVAILABLE means the configuration must change.
A clean publish does not guarantee a run succeeds. Publish allows a target it can neither confirm nor rule out: when your ATS does not return the job a target names, or gives no ids for that job's stages or reasons. A run can then still fail with ATS_UPDATE_REJECTED.
Scheduling config
Create, update and publish all run these checks on a schedulingOutreach node.
| Error code | Cause |
|---|---|
MISSING_AGENT_REFERENCE | agentName is missing, empty, or longer than 100 characters, or agentTone is missing or not one of the listed values. |
INVALID_NODE_CONFIG | A field does not match its type or range. The message names the field. |
MISSING_WHATSAPP_TEMPLATE | channel is WHATSAPP and openingMessageTemplateId is missing. |
INVALID_WHATSAPP_CUSTOM_MESSAGE | channel is WHATSAPP and customTemplatedMessage is set. |
MIXED_SCHEDULING_MESSAGE_SOURCES | channel is SMS and both openingMessageTemplateId and customTemplatedMessage are set. |
MISSING_SCHEDULING_MESSAGE | channel is SMS and neither message field is set, or channel is EMAIL and customTemplatedMessage is missing. |
INVALID_EMAIL_TEMPLATE | channel is EMAIL and openingMessageTemplateId is set. |
MISSING_EMAIL_SUBJECT | channel is EMAIL and customTemplatedSubjectLine is missing. |
MISSING_MEETING_URL_PLACEHOLDER | customTemplatedMessage does not contain {{MEETING_URL}}. |
MISSING_OPT_OUT_INSTRUCTION | customTemplatedMessage does not contain the word STOP in capital letters. |
SMS_MESSAGE_TOO_LONG | channel is SMS and customTemplatedMessage is longer than 600 characters. A variable listed in Template variables counts as no characters, and any other {{...}} text counts in full. Length is counted in UTF-16 code units, so most emoji count as two or more. |
INVALID_TEMPLATE_VARIABLE | A message uses a variable that the scheduling node cannot fill. See Template variables. |
MALFORMED_TEMPLATE_MARKUP | A message holds editor markup in the form @[name](id) instead of a template variable such as {{CANDIDATE_FIRST_NAME}}. One error for each occurrence. |
INVALID_SCHEDULING_PARTICIPANTS | A COLLECTIVE meeting does not have exactly one organizer, or a ROUND_ROBIN_MAX_AVAILABILITY meeting does not have 2 to 10 participants. |
INFEASIBLE_AVAILABILITY_REQUEST | availability has no time slots, or availabilityOutreachSettings.requiredHours is more than availability offers in availabilityOutreachSettings.periodDays days. Popp checks this for each participant with autoCollectAvailability: true, and a participant's settings override the node's. |
UNKNOWN_ROUTE | The node has a routing entry. Nothing can come after a scheduling node. One error for each route, at nodes.<node-id>.routing.<event>. |
SCHEDULING_PARTICIPANT_CALENDAR_NOT_FOUND | A participant's userCalendarId does not exist, is not linked to your organisation, or belongs to another email. For example, the calendar was removed after you saved the workflow. Call GET /v1/user-calendars?email= and send the new id. |
SCHEDULING_PARTICIPANT_CALENDAR_DISCONNECTED | A participant's Google or Microsoft calendar is no longer connected. Reconnect it, or choose another calendar. The message lists the email addresses. |
SCHEDULING_PARTICIPANT_UNREACHABLE | A participant has no connected calendar and no autoCollectAvailability: true. The message lists the email addresses. |
SCHEDULING_ORGANIZER_NOT_CONNECTED | The organizer's calendar is disconnected. Reconnect it. |
SCHEDULING_BOOKING_URL_TEMPLATE_NOT_FOUND | channel is WHATSAPP and your organization has no approved WhatsApp booking-link template for language. |
SCHEDULING_ORGANIZATION_NOT_IN_GROUP | Your organization is not set up for scheduling. Contact support. |
INVALID_OUTREACH_TEMPLATE | openingMessageTemplateId is not a SCHEDULING opening-message template of your organization for this channel. |
Template variables
screeningOutreach and engagementOutreach accept these {{...}} variables in customTemplatedMessage, customTemplatedSubjectLine, criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage and any route's closingMessage. Any other variable is rejected with INVALID_TEMPLATE_VARIABLE.
| Variable | Available on | Notes |
|---|---|---|
{{CANDIDATE_FIRST_NAME}} | both | Candidate's first name. |
{{CANDIDATE_LAST_NAME}} | both | Candidate's last name. Removed, with the space before it, when Popp has no last name for the candidate. |
{{ORGANIZATION_NAME}} | both | The organization running the workflow. |
{{AGENT_NAME}} | both | Name of the agent. |
{{CAMPAIGN_OWNER_NAME}} | both | The user who owns the campaign. |
{{INTERVIEWER_NAME}} | both | Interviewer for any associated meeting. |
{{MEETING_TITLE}} | both | Title of the associated meeting (when one is configured). |
{{MEETING_URL}} | both | Booking link for the associated meeting. |
{{MEETING_AVAILABILITY_TEXT}} | both | The meeting's availability, as text. |
{{JOB_APPLICATION_URL}} | both | URL of the job application. |
{{JOB_TITLE}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
{{EMPLOYER_NAME}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
{{LOCATION}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
Unknown placeholders, such as {{firstName}} or {{candidate.firstName}}, are rejected on either node type.
Template variables on schedulingOutreach
schedulingOutreachA schedulingOutreach node has two kinds of message, and each kind accepts a different set of variables:
- Candidate messages:
customTemplatedMessageandcustomTemplatedSubjectLine. Popp sends these to the candidate. - Availability messages:
availabilityOutreachSettings.customTemplatedMessageandavailabilityOutreachSettings.customTemplatedSubjectLine, on the node and on each participant. Popp sends these to participants withautoCollectAvailability: true.
| Variable | Candidate messages | Availability messages |
|---|---|---|
{{CANDIDATE_FIRST_NAME}} | yes | yes |
{{CANDIDATE_LAST_NAME}} | yes | yes |
{{ORGANIZATION_NAME}} | yes | yes |
{{AGENT_NAME}} | yes | yes |
{{CAMPAIGN_OWNER_NAME}} | yes | yes |
{{INTERVIEWER_NAME}} | yes | yes |
{{MEETING_TITLE}} | yes | no |
{{MEETING_URL}} | yes (required in customTemplatedMessage) | no |
{{MEETING_AVAILABILITY_TEXT}} | no | yes |
{{JOB_APPLICATION_URL}} | no | no |
{{JOB_TITLE}} | no | no |
{{EMPLOYER_NAME}} | no | no |
{{LOCATION}} | no | no |
A variable marked "no" fails validation with INVALID_TEMPLATE_VARIABLE.
Worked examples
Example 1 — Linear nurse onboarding journey
Cedar Health Recruitment runs three steps for every Registered General Nurse it sends in through the API: screen for availability and registration, collect compliance documents, then confirm bookings.
{
"entryPathways": [{ "entrySource": "API", "entrySlug": "screen-availability" }],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"label": "RGN — availability and NMC screen",
"routing": { "onComplete": "engagement-compliance-docs" },
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability and NMC screen",
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. Initial screen confirms NMC registration, region, and shift appetite before we ask for compliance documents.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "region" }]
}
]
},
{
"slug": "region",
"content": "Which UK region or trusts are you available to cover?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }]
}
]
},
{
"slug": "shift-availability",
"content": "How many shifts per week are you looking to pick up over the next month?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-compliance-docs": {
"id": "engagement-compliance-docs",
"type": "engagementOutreach",
"label": "Request compliance pack",
"routing": { "onComplete": "engagement-booking-confirm" },
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Request compliance pack",
"channel": "EMAIL",
"customTemplatedSubjectLine": "Cedar Health — your compliance pack for upcoming RGN shifts",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details over SMS. To get you booked onto shifts we need your compliance pack: an in-date Enhanced DBS on the update service, your right-to-work documentation, your mandatory training certificate (CSTF or equivalent), and two professional references. Reply to this email with the documents attached and our compliance team will take it from there.",
"outreachContext": "Collecting outstanding compliance documents from candidates who have accepted a role but are not yet cleared to work.",
"questions": [
{
"slug": "docs-ready",
"content": "Do you have your compliance documents ready to upload?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-booking-confirm": {
"id": "engagement-booking-confirm",
"type": "engagementOutreach",
"label": "Confirm booking",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Confirm booking",
"channel": "SMS",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, your compliance pack is in and you're now shift-ready with Cedar Health. Our bookings team will be in touch this week with the first set of shifts in your region. Reply STOP to opt out at any time.",
"outreachContext": "Confirming shift preferences with RGN candidates who have already passed screening, before the bookings team calls.",
"questions": [
{
"slug": "confirm-region",
"content": "Which regions are you able to travel to for shifts?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
}
}
}screen-availability routes only onComplete, so opting out, declining or a delivery failure ends the run. Example 2 routes each of those events to its own follow-up.
Example 2 — Branching on how the screen ended
Cedar Health Recruitment sends each outcome to its own follow-up: candidates who aren't interested go to a nurture list, candidates SMS can't reach get an email, London candidates get a London compliance pack, and everyone else gets the standard one. The example shows three techniques:
- Several routing events on one node. The screening node's
onComplete,onNotInterestedandonDeliveryFailedevents go to three different follow-ups. - Answer routes. The
nmc-pinquestion branches mid-conversation: candidates without an active PIN are asked whether they are registering, and that question can end the conversation early withROUTE_TOandCLOSE_CONVERSATION. - A keyword route that hands off. The
regionquestion sends answers that mention London to a London compliance pack withROUTE_TO. Every other answer closes the conversation, andonCompletesends the run to the standard pack.
{
"entryPathways": [{ "entrySource": "API", "entrySlug": "screen-availability" }],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"label": "RGN — availability and NMC screen",
"routing": {
"onComplete": "engagement-compliance-docs",
"onNotInterested": "engagement-nurture",
"onDeliveryFailed": "engagement-email-fallback"
},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability and NMC screen",
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. We confirm NMC registration first; candidates who are mid-registration are still worth a follow-up nurture email, but candidates who don't intend to register at all are not a fit right now.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }]
},
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CONTINUE", "slug": "intent-to-register" }]
}
]
},
{
"slug": "intent-to-register",
"content": "Are you in the process of registering with the NMC?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }]
},
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" },
{ "type": "ROUTE_TO", "nodeId": "engagement-nurture" }
],
"closingMessage": "Thanks for letting us know — we'll keep you on file and reach out when you're ready to pick up RGN shifts."
}
]
},
{
"slug": "shift-availability",
"content": "How many shifts per week are you looking to pick up over the next month?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "region" }]
}
]
},
{
"slug": "region",
"content": "Which UK region or trusts are you available to cover?",
"answerRoutes": [
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" },
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
]
},
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-london-compliance": {
"id": "engagement-london-compliance",
"type": "engagementOutreach",
"label": "London compliance pack",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "London compliance pack",
"channel": "EMAIL",
"customTemplatedSubjectLine": "Cedar Health — your London compliance pack for upcoming RGN shifts",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details. For our Greater London trusts we need the standard pack plus two London-trust-specific clinical references: in-date Enhanced DBS on the update service, right-to-work documentation, mandatory training certificate, and references from two clinical leads at trusts you've worked at in the last two years. Reply with the documents attached and our compliance team will take it from there.",
"outreachContext": "Collecting London-specific compliance documents from candidates placed on London bookings.",
"questions": [
{
"slug": "london-docs-ready",
"content": "Do you have your London compliance documents ready to upload?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-compliance-docs": {
"id": "engagement-compliance-docs",
"type": "engagementOutreach",
"label": "Standard compliance pack",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Standard compliance pack",
"channel": "EMAIL",
"customTemplatedSubjectLine": "Cedar Health — your compliance pack for upcoming RGN shifts",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details. To get you booked onto shifts we need your compliance pack: an in-date Enhanced DBS on the update service, right-to-work documentation, your mandatory training certificate (CSTF or equivalent), and two professional references. Reply to this email with the documents attached and our compliance team will take it from there.",
"outreachContext": "Collecting outstanding compliance documents from candidates who have accepted a role but are not yet cleared to work.",
"questions": [
{
"slug": "docs-ready",
"content": "Do you have your compliance documents ready to upload?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-nurture": {
"id": "engagement-nurture",
"type": "engagementOutreach",
"label": "Nurture for future roles",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Nurture for future roles",
"channel": "EMAIL",
"customTemplatedSubjectLine": "Cedar Health — we'll be in touch when the timing is right",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for letting us know — we'll keep you on file and reach out when you're ready to pick up RGN shifts. If anything changes in the meantime, just reply to this email and we'll get you set up.",
"outreachContext": "Keeping in touch with RGN candidates who are not ready to pick up shifts yet, so they can re-engage when the timing suits them.",
"questions": [
{
"slug": "nurture-timing",
"content": "Roughly when would you like us to check back in with you?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-email-fallback": {
"id": "engagement-email-fallback",
"type": "engagementOutreach",
"label": "Email fallback for SMS-unreachable candidates",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Email fallback for SMS-unreachable candidates",
"channel": "EMAIL",
"customTemplatedSubjectLine": "Cedar Health Recruitment — RGN shifts in your region",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, we tried to reach you over SMS but couldn't get through. Cedar Health Recruitment has new RGN shifts coming up that match your profile — reply to this email if you're interested and we'll send across the next steps.",
"outreachContext": "Re-reaching RGN candidates over email after SMS delivery failed, to confirm whether they are still interested.",
"questions": [
{
"slug": "fallback-interest",
"content": "Are you still interested in RGN shifts with Cedar Health?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
}
}
}How a run moves through it:
- A candidate enters at
screen-availability. - A candidate without an NMC PIN goes to
intent-to-register. If they are not registering either, the route closes the conversation withstatus: "FAILED_CRITERIA", andROUTE_TOsends the run toengagement-nurtureinstead of theonCompletetarget. A candidate who declines in general terms (e.g. "not for me thanks") firesonNotInterested, which also leads toengagement-nurture. - A candidate with a PIN, or one who is registering, continues through
shift-availabilityandregion. - If the region answer mentions London, its answer route closes the conversation, and
ROUTE_TOsends the run toengagement-london-compliance. Any other answer closes the conversation,onCompletefires, and the run moves toengagement-compliance-docs. - If the SMS is not delivered (e.g. an invalid mobile number),
onDeliveryFailedsends the run toengagement-email-fallback, which tries email. onOptedOutis unrouted, so a candidate who opts out gets no further messages.
Example 3 — Question shapes by example
One valid question per shape, then common rejections and their error codes. Each fragment goes in a node's config.questions[] array.
Yes/no question with expectedAnswer: IS_YES (knockout)
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"questionType": "TEXT",
"expectedAnswer": "IS_YES",
"strictness": "STRICT",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CONTINUE", "slug": "region" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}Open-ended (no scoring) question, record and continue
{
"slug": "current-shifts",
"content": "What does your current week look like — which shifts are you working now?",
"questionType": "TEXT",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }] }
]
}For keyword branching, put one or more answerText mentions/does_not_mention routes before the catch-all (see Question shapes).
Open-ended scoring question with preferredAnswerText and score routes
{
"slug": "shift-availability",
"content": "How many shifts per week are you looking to pick up over the next month, and which days work best for you?",
"questionType": "TEXT",
"preferredAnswerText": "Four or more shifts per week, including at least one weekend day, with availability for early or late starts.",
"answerRoutes": [
{ "when": { "field": "score", "op": "gte", "value": 70 },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "score", "op": "lt", "value": 70 },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}Document question with documentTypes and document-state routes
{
"slug": "right-to-work",
"content": "Please attach a clear photo of your right-to-work documentation (passport, BRP, or share code letter).",
"questionType": "DOCUMENT",
"documentTypes": ["passport", "biometric-residence-permit", "share-code-letter"],
"answerRoutes": [
{ "when": { "field": "documentState", "op": "eq", "value": "uploaded" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "documentState", "op": "eq", "value": "not_uploaded" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}Common rejections
Create, update and publish reject each of these with the error code shown.
expectedAnswer with preferredAnswerText returns PREFERRED_ANSWER_ON_YES_NO_QUESTION at path: ...questions.0.preferredAnswerText. It also returns MISSING_FALLBACK_ROUTE, because a question with expectedAnswer needs a complete yes/no route pair, not a lone answerState=any:
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered?",
"expectedAnswer": "IS_YES",
"preferredAnswerText": "Active PIN holder for at least 12 months",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}A document question with an answerState route returns INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE at path: ...questions[0].answerRoutes. Use documentState routes, with both uploaded and not_uploaded:
{
"slug": "right-to-work",
"content": "Attach your right-to-work documentation.",
"questionType": "DOCUMENT",
"documentTypes": ["passport"],
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] }
]
}An operator the field does not accept returns INVALID_NODE_CONFIG at path: ...answerRoutes.0.when.op:
{
"when": { "field": "answerState", "op": "neq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }]
}Use answerState eq no instead, or move richer logic into a condition node.
Example 4 — ATS write-back, mid-workflow and as the last node
Northgate Logistics feeds an HGV Class 1 driver vacancy from its ATS into a workflow. Applicants who finish the screen move to the next stage of their job's pipeline and are then asked about induction dates. Applicants who decline are rejected in the ATS with a withdrawal reason, and the run ends.
advance-in-ats sits mid-workflow and routes onward; withdraw-in-ats is the last node.
{
"entryPathways": [
{
"entrySource": "ATS",
"entrySlug": "screen-driver-eligibility",
"atsJobIds": ["job-hgv-class-1"], "atsStageSelections": [{ "type": "allStages", "atsJobId": "job-hgv-class-1" }]
}
],
"nodes": {
"screen-driver-eligibility": {
"id": "screen-driver-eligibility",
"type": "screeningOutreach",
"slug": "screen-driver-eligibility",
"label": "Driver licence and CPC screen",
"routing": {
"onComplete": "advance-in-ats",
"onNotInterested": "withdraw-in-ats"
},
"config": {
"agentName": "Northgate Recruitment",
"agentTone": "PROFESSIONAL",
"title": "Driver licence and CPC screen",
"channel": "SMS",
"employerName": "Northgate Logistics",
"roleTitle": "HGV Driver",
"location": "Leeds, UK",
"outreachContext": "Screening HGV Class 1 driver applicants for licence category and a valid Driver CPC card before a recruiter call.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for applying to Northgate Logistics. Two quick questions to confirm your licence details. Reply STOP to opt out.",
"questions": [
{
"slug": "licence-category",
"content": "Which HGV licence categories do you currently hold?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "cpc-card" }]
}
]
},
{
"slug": "cpc-card",
"content": "Is your Driver CPC card in date?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"advance-in-ats": {
"id": "advance-in-ats",
"type": "updateAtsApplication",
"slug": "advance_in_ats",
"label": "Move to telephone interview",
"routing": { "onComplete": "invite-to-induction" },
"config": {
"operation": "moveStage",
"targets": [
{
"atsJobId": "job-hgv-class-1",
"atsInterviewStageId": "stage-c1-telephone-interview"
}
]
}
},
"invite-to-induction": {
"id": "invite-to-induction",
"type": "engagementOutreach",
"label": "Invite to induction",
"routing": {},
"config": {
"agentName": "Northgate Recruitment",
"agentTone": "PROFESSIONAL",
"title": "Invite to induction",
"channel": "SMS",
"outreachContext": "Confirming induction attendance for screened HGV driver applicants who have already passed the licence and CPC screen.",
"customTemplatedMessage": "Thanks {{CANDIDATE_FIRST_NAME}} — your details are confirmed and a recruiter will call you to arrange a telephone interview. Induction dates run every other Monday. Reply STOP to opt out.",
"questions": [
{
"slug": "induction-monday",
"content": "Are you able to attend an induction on a Monday?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"withdraw-in-ats": {
"id": "withdraw-in-ats",
"type": "updateAtsApplication",
"slug": "withdraw_in_ats",
"label": "Reject — candidate withdrew",
"routing": {},
"config": {
"operation": "reject",
"targets": [
{
"atsJobId": "job-hgv-class-1",
"atsRejectedReasonId": "reason-candidate-withdrew"
}
]
}
}
}
}Why this configuration is valid:
- One job on the ATS pathway. A second
atsJobIdgivesMULTIPLE_ATS_ENTRY_JOBS. - Both ATS nodes carry that job. Each is reachable from
screen-driver-eligibility, which the job enters, so each needs a target for it. withdraw-in-atsroutes nothing. AnupdateAtsApplicationnode with an emptyroutingmap is a valid last node. The run completes once the ATS update finishes.
A candidate started through POST /v1/workflows/{workflowId}/runs would reach advance-in-ats with no ATS application. The node is skipped with decision: "skipped_no_ats_application", and invite-to-induction still runs.
Example 5 — Add to job
As the last node, handing a screened applicant to a second job
Applicants to the agency's Bank RGN — Spring intake job enter the workflow, are screened, and the ones who reach the end are added to the long-term RGN bank job at the Screened by Popp stage. The entry job and the destination job differ, so this publishes. See the rejection below.
{
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "screen-availability", "atsJobIds": ["job_spring_intake"], "atsStageSelections": [{ "type": "allStages", "atsJobId": "job_spring_intake" }] }
],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"label": "RGN availability screen",
"routing": { "onComplete": "add-to-bank-job" },
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability screen",
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — a couple of quick questions about your availability. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"expectedAnswer": "IS_YES",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
]
}
},
"add-to-bank-job": {
"id": "add-to-bank-job",
"type": "createAtsApplication",
"label": "Add to the RGN bank job",
"routing": {},
"config": {
"atsJobId": "job_8f21c0",
"atsStageId": "stage_screened_by_popp"
}
}
}
}routing: {} ends the run at the node. The run completes as soon as the ATS answers.
onComplete on the screening node fires whether the screen passed or failed, so this graph adds everyone it screens. To add only the candidates who passed, route the screening node into a condition node with one branch, { "op": "eq", "field": "answers.nmc-pin.outcome", "value": "PASSED" }, that targets add-to-bank-job. The node's else must name another node, such as a nurture message.
Mid-workflow, with a follow-up message
The same node with onComplete routed. The candidate is added, then told.
"add-to-bank-job": {
"id": "add-to-bank-job",
"type": "createAtsApplication",
"label": "Add to the RGN bank job",
"routing": { "onComplete": "confirm-added" },
"config": {
"atsJobId": "job_8f21c0",
"atsStageId": "stage_screened_by_popp"
}
}onComplete fires whether the candidate was added or was already on the job, so confirm-added runs either way. To tell them apart, branch a condition node on ats.add-to-bank-job.decision (created or already_on_job), or read outcome.decision on the WORKFLOW_NODE_COMPLETED event.
A rejection: the destination is the workflow's own trigger
{
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "add-to-bank-job", "atsJobIds": ["job_8f21c0"], "atsStageSelections": [{ "type": "allStages", "atsJobId": "job_8f21c0" }] }
],
"nodes": {
"add-to-bank-job": {
"id": "add-to-bank-job",
"slug": "add-to-bank-job",
"type": "createAtsApplication",
"routing": {},
"config": { "atsJobId": "job_8f21c0", "atsStageId": "stage_screened_by_popp" }
}
}
}Rejected with ATS_DESTINATION_IS_ENTRY_JOB: applicants to job_8f21c0 enter this workflow, and the node adds them back to job_8f21c0. Point the node at the job you are handing candidates on to. The rule looks only at the entry pathways, so it applies even when the node sits behind a branch a run never takes.
A rejection: a disallowed entry source reaches the node
{
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "screen-availability", "atsJobIds": ["job_spring_intake"], "atsStageSelections": [{ "type": "allStages", "atsJobId": "job_spring_intake" }] },
{ "entrySource": "API", "entrySlug": "screen-availability" }
],
"nodes": {
"screen-availability": { "id": "screen-availability", "slug": "screen-availability", "type": "screeningOutreach", "routing": { "onComplete": "add-to-bank-job" }, "config": { "…": "…" } },
"add-to-bank-job": {
"id": "add-to-bank-job",
"type": "createAtsApplication",
"routing": {},
"config": { "atsJobId": "job_8f21c0", "atsStageId": "stage_screened_by_popp" }
}
}
}Rejected with CREATE_ATS_APPLICATION_UNSUPPORTED_ENTRY: the API pathway enters at the same node as the ATS pathway, so it also reaches add-to-bank-job. The message names the node and each offending source, here 'API'.
Give the API pathway its own branch that does not reach the node. Create and update return this error too, not only publish.
Example 6 — Screen, then book an interview
Candidates enter through the API and answer one screening question. After the screen, each candidate gets a link to book a 30-minute call. The call is a COLLECTIVE meeting with two participants:
- Priya Shah is the organizer and has a connected calendar.
- Tom Hughes has no connected calendar and gives availability by email.
{
"entryPathways": [
{ "entrySource": "API", "entrySlug": "screen-availability" }
],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"label": "RGN availability screen",
"routing": { "onComplete": "book-interview" },
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability screen",
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — a couple of quick questions about your availability. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"expectedAnswer": "IS_YES",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
]
}
},
"book-interview": {
"id": "book-interview",
"type": "schedulingOutreach",
"label": "Book a screening call",
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"channel": "SMS",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for your answers. Book a 30-minute call with our team here: {{MEETING_URL}} Reply STOP to opt out.",
"timeToAutoCloseConversationsInHours": 72,
"title": "RGN screening call",
"description": "A short call about your registration, shift pattern and preferred trusts.",
"durationMinutes": 30,
"timezone": "Europe/London",
"availability": {
"1": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"2": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"3": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"4": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"5": [{ "start": "09:00", "end": "12:00" }]
},
"noticePeriodMinutes": 1440,
"availableDaysInTheFuture": 14,
"buffer": 15,
"videoConference": true,
"videoConferencingProvider": "Microsoft Teams",
"availabilityMethod": "COLLECTIVE",
"participants": [
{
"email": "[email protected]",
"name": "Priya Shah",
"isOrganizer": true
},
{
"email": "[email protected]",
"name": "Tom Hughes",
"autoCollectAvailability": true
}
],
"availabilityOutreachSettings": { "requiredHours": 4, "periodDays": 14 }
}
}
}
}The configuration gives these results:
book-interviewhas norouting, so it is the last node.- A candidate can book a weekday slot inside
availability, from 24 hours after booking up to 14 days ahead, when both Priya and Tom are free. - The conversation closes after 72 hours of inactivity.
onComplete on the screening node fires whether the screen passed or failed, so this graph offers a call to every candidate it screens. To offer a call only to the candidates who passed, route the screening node into a condition node with one branch, { "op": "eq", "field": "answers.nmc-pin.outcome", "value": "PASSED" }, that targets book-interview. The node's else must name another node, such as a nurture message.
Conversation outcomes in workflow webhooks
When a node's conversation completes, the webhook's NodeOutcome has outcome.kind: "conversation" and an outcome.status from this list. It is separate from the Conversations API's conversationStatus.
| Workflow outreach outcome | 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 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. |
A schedulingOutreach node reports its own outcome: meeting when the candidate books, and empty when the node ends any other way. See Workflow Events.
To read the conversation itself, fetch it from the Conversations API with the conversationId on the NodeOutcome. That API has its own statuses (COMPLETED_SCREENING_PASSED, CLOSED, COMPLETED_SCREENING_FAILED, OPTED_OUT, etc.), documented on the Conversation schema.
Next steps
- Workflow Events: webhook payloads sent as runs progress.
- Webhook Authentication: verify the
x-signatureheader on incoming events.
Updated 2 days ago
