Job Application Flow
Job Application Flow
An end-to-end walkthrough of running an APPLICANT_OUTREACH campaign on Popp: setting up the campaign that holds your outreach configuration, launching conversations against it for each applicant (one at a time or in bulk), and stepping in as a recruiter when the AI flags a thread for human review.
The example below uses WhatsApp as the channel. The same payload shape applies to SMS and EMAIL, and to NEW_CANDIDATE_OUTREACH campaigns.
Prerequisites
- A valid
x-api-keyfor your organization (see Authentication). - An
x-organization-idfor the target organization. - An opening message template for the channel + campaign type you want to use. You can list yours via
GET /v1/templates(see Step 1).
Step 1: Create the campaign
A campaign defines how your AI agent communicates with candidates: the channel, the screening questions, the closing method, and the agent's personality. Once created, every conversation you start under it inherits that configuration.
1a. Find an opening message template
APPLICANT_OUTREACH campaigns require an openingMessageTemplateId matching the channel and campaign type. List your organization's templates and pick one:
curl -X GET "https://api.joinpopp.com/v1/templates" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID"Filter the response client-side for items where channel = "WHATSAPP", type = "OPENING_MESSAGE", and campaignType = "APPLICANT_OUTREACH". Pick any matching id.
1b. Create the campaign
curl -X POST "https://api.joinpopp.com/v1/campaigns" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"campaignType": "APPLICANT_OUTREACH",
"channel": "WHATSAPP",
"closingMethod": "CUSTOM",
"campaignTitle": "Senior Software Engineer",
"campaignDescription": "Senior Software Engineer with 5+ years experience in TypeScript, React, and AWS. Hybrid working (3 days a week in office). Salary 80-100k.",
"company": "Popp",
"location": "London, UK",
"customMessage": "Thanks for your interest! Our recruiting team will review your responses and reach out within 2 business days to schedule a chat.",
"openingMessageTemplateId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"agentName": "Alex",
"agentTone": "FRIENDLY",
"campaignStatus": "LIVE",
"externalId": "ats-job-12345",
"questions": [
{
"questionType": "TEXT",
"isMandatory": true,
"content": "Do you have at least 5 years of experience with TypeScript and React?"
},
{
"questionType": "TEXT",
"isMandatory": true,
"content": "Are you authorised to work in the UK without sponsorship?"
},
{
"questionType": "DOCUMENT",
"isMandatory": false,
"documentTypes": ["CV"]
}
]
}'The externalId is your own reference for the job (e.g., the job requisition ID in your ATS). It is echoed back in webhook events and lets you correlate Popp activity with your system without keeping a separate mapping.
customMessage is the message Popp sends to a candidate who completes screening successfully — it replaces the default closing copy.
Questions
questions is the screening checklist your AI agent will work through with each applicant. It is required for every non-SCHEDULING campaign type — the agent uses these to qualify candidates before applying the closing method. Each question is one of two types:
questionType | Required fields | Use it for |
|---|---|---|
TEXT | isMandatory, content | Conversational questions answered by the candidate. |
DOCUMENT | isMandatory, documentTypes (string array) | Asking the candidate to upload a file (e.g. CV). |
If isMandatory is true, a candidate who refuses or fails the question is screened out (the conversation closes with COMPLETED_SCREENING_FAILED). If false, the answer is recorded but doesn't gate the result.
Sample response
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"organizationId": "org_123456",
"type": "APPLICANT_OUTREACH",
"channel": "WHATSAPP",
"closingMethod": "CUSTOM",
"campaignStatus": "LIVE",
"campaignTitle": "Senior Software Engineer",
"templateId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"customRequest": "Thanks for your interest! Our recruiting team will review your responses and reach out within 2 business days to schedule a chat.",
"externalId": "ats-job-12345",
"agent": {
"id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"agentName": "Alex",
"agentTone": "FRIENDLY"
},
"jobDescriptionParsed": {
"employerName": "Popp",
"jobTitle": "Senior Software Engineer",
"summaryOfRole": "..."
},
"questions": [
{
"questionType": "TEXT",
"isMandatory": true,
"content": "Do you have at least 5 years of experience with TypeScript and React?"
},
{
"questionType": "TEXT",
"isMandatory": true,
"content": "Are you authorised to work in the UK without sponsorship?"
},
{
"questionType": "DOCUMENT",
"isMandatory": false,
"documentTypes": ["CV"]
}
]
}Note: The
customMessageyou sent on the request is returned ascustomRequestin the response — they refer to the same field.
Capture id from the response — you will pass it as campaignId when creating conversations.
Note: Setting
campaignStatustoLIVEdoes not send anything on its own. Messages are only dispatched when you create a conversation against the campaign (Step 2). ALIVEcampaign with no conversations is inert.
Required fields by campaign type
| Field | APPLICANT_OUTREACH / NEW_CANDIDATE_OUTREACH | ENGAGEMENT_OUTREACH |
|---|---|---|
campaignType | ✅ | ✅ |
channel | ✅ | ✅ |
closingMethod | ✅ | ✅ |
campaignTitle | ✅ | ✅ |
campaignDescription | ✅ | ✅ |
questions | ✅ | ✅ |
company | ✅ | — |
location | ✅ | — |
openingMessageTemplateId | ✅ (unless using customTemplatedMessage) | optional |
Closing methods
The closing method controls what happens after the AI agent finishes screening a candidate.
closingMethod | Required companion field | What happens at the end of the conversation |
|---|---|---|
CUSTOM | customMessage | A custom message you supply is sent verbatim (used above) |
MEETING_URL | meetingUrl | Candidate is given a scheduling link (e.g., Calendly) |
PHONE_NUMBER | contactNumber | Candidate is given a phone number to call |
CALENDAR_MEETING_INTEGRATION | calendarMeetingTemplateId | Popp's built-in scheduler books a meeting with the candidate |
For deeper coverage of campaign options, see Understanding Campaigns.
Step 2: Trigger conversations with candidate data
A campaign is only useful once you start conversations under it. Each conversation represents one outbound message thread with one candidate. Popp will send the opening message, screen the candidate against your questions, and route them through the closing method.
2a. Create a single conversation
curl -X POST "https://api.joinpopp.com/v1/conversations" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"campaignId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"firstName": "John",
"lastName": "Doe",
"phoneNumber": "+14155551234",
"externalId": "candidate-001"
}'The endpoint returns 202 Accepted:
{ "message": "Conversation created" }Phone numbers must be in E.164 format (e.g., +14155551234). For email-channel campaigns, send emailAddress instead of phoneNumber.
The externalId is your reference for the candidate. It is echoed back in webhook events, making it easy to correlate Popp activity with your ATS or CRM.
To find the conversation that was just created (e.g., to retrieve its id for the next step), list conversations on the campaign:
curl -X GET "https://api.joinpopp.com/v1/conversations?campaignId=a1b2c3d4-e5f6-7890-abcd-ef1234567890&sortDirection=DESC&limit=1" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID"2b. Upload many candidates at once
When you have a batch of applicants from your ATS, use the bulk endpoint instead of looping single creates:
curl -X POST "https://api.joinpopp.com/v1/conversations/bulk" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"campaignId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"shouldSendDuringBusinessHours": true,
"contacts": [
{
"firstName": "John",
"lastName": "Doe",
"phoneNumber": "+14155551234",
"externalId": "candidate-001"
},
{
"firstName": "Sarah",
"lastName": "Smith",
"phoneNumber": "+447700900002",
"externalId": "candidate-002"
}
]
}'The endpoint returns 202 Accepted with a per-contact status:
{
"message": "Bulk conversation creation completed",
"total": 2,
"queued": 2,
"failed": 0,
"results": [
{ "index": 0, "firstName": "John", "status": "queued" },
{ "index": 1, "firstName": "Sarah", "status": "queued" }
]
}Up to 10,000 contacts can be uploaded per request. Each contact may also carry its own openingMessage, additionalContext, or closePreviousConversations flag — see Understanding Conversations for the full field reference.
Tip: Set
shouldSendDuringBusinessHours: trueif you want messages to be queued and sent during local business hours rather than the moment the API call is made. The candidate's timezone is inferred from the phone number.
Step 3: Take manual control of a conversation
Sometimes a candidate's reply needs a human touch — for example, a complex question, a sensitive topic, or a high-priority hire you want to handle personally. The Popp API lets a recruiter pause the AI, send messages directly, then hand the conversation back when ready.
The takeover endpoints only accept conversations in an active state: ONGOING, COMPLETED_MEETING_REQUESTED, COMPLETED_AWAITING_CALL, or COMPLETED_CUSTOM_REQUEST. Conversations that have failed delivery, opted out, or completed cannot be taken over.
3a. Listen for CONVERSATION_NEEDS_REVIEW
CONVERSATION_NEEDS_REVIEWRather than polling for conversations that need attention, subscribe a webhook to the CONVERSATION_NEEDS_REVIEW event. Popp fires this event whenever the AI determines a thread should be reviewed by a human — common triggers include the candidate asking something the AI can't answer, a sensitive topic being raised, or an ambiguous response. See Conversation Events for the full payload reference and Webhooks for how to register a webhook URL.
A typical payload looks like:
{
"event": "CONVERSATION_NEEDS_REVIEW",
"eventId": "evt_abc123",
"eventTimestamp": "2024-01-15T10:30:00.000Z",
"data": {
"recordId": "conv_xyz789",
"organizationId": "org_123456",
"externalId": "candidate-001",
"externalCampaignId": "your_campaign_external_id",
"campaignConversationUrl": "https://ai.joinpopp.com/campaign/outbound/{campaignId}?conversationId={conversationId}",
"isHumanTakeover": false
}
}Use data.recordId as the conversationId in the takeover call below. Check data.isHumanTakeover first — if it's already true, another teammate or process has already stepped in and you should skip the takeover call.
3b. Take over the conversation
curl -X POST "https://api.joinpopp.com/v1/conversations/{conversationId}/takeover" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID"The endpoint takes no request body. On success it returns 200 OK with the updated conversation summary:
{
"id": "d4e5f6a7-b8c9-0123-def4-567890123456",
"isHumanTakeover": true,
"conversationStatus": "ONGOING"
}From this point the AI agent will not reply automatically — incoming messages from the candidate are still received and stored, but no outbound replies are generated until you send one yourself or hand the conversation back.
3c. Send a message as the recruiter
While in human-takeover mode, send messages by either supplying message text or referencing one of your templates:
# Send free-text
curl -X POST "https://api.joinpopp.com/v1/conversations/{conversationId}/messages" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"messageBody": "Hi John, this is Alex from Popp recruiting. Could you share your portfolio link so we can review before our chat?"
}'# Or send using one of your saved templates
curl -X POST "https://api.joinpopp.com/v1/conversations/{conversationId}/messages" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"templateId": "b2c3d4e5-f6a7-8901-bcde-f23456789012"
}'Exactly one of messageBody or templateId must be provided. The endpoint returns 201 Created with the message that was sent:
{
"id": "e5f6a7b8-c9d0-1234-ef56-789012345678",
"conversationId": "d4e5f6a7-b8c9-0123-def4-567890123456",
"body": "Hi Ilyes — quick test message from a recruiter taking over this conversation. Replying STOP unsubscribes you.",
"sender": "USER",
"status": "SENT_SUCCESS",
"createdAt": "2026-05-08T11:26:37.920Z"
}3d. Hand the conversation back to the AI
When you are ready for the agent to resume:
curl -X POST "https://api.joinpopp.com/v1/conversations/{conversationId}/hand-to-agent" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID"The endpoint takes no request body. Response:
{
"id": "d4e5f6a7-b8c9-0123-def4-567890123456",
"status": "ONGOING",
"stage": "INITIAL",
"isHumanTakeover": false
}The AI agent will pick up where the conversation left off — using the screening questions and closing method configured on the campaign.
Putting it all together
┌──────────────────────────┐
│ POST /v1/campaigns │ Create the outreach campaign once
└──────────┬───────────────┘
│ campaignId
▼
┌──────────────────────────┐
│ POST /v1/conversations │ Trigger a conversation per candidate
│ (or /bulk) │ (1 candidate or up to 10k at a time)
└──────────┬───────────────┘
│ conversationId
▼
┌──────────────────────────────────────┐
│ Webhook: CONVERSATION_NEEDS_REVIEW │ Popp signals when the AI wants help
└──────────┬───────────────────────────┘
│ data.recordId
▼
┌──────────────────────────┐
│ /takeover → /messages │ Step in manually
│ └→ /hand-to-agent │ Hand back so the AI resumes
└──────────────────────────┘
Related guides
- Understanding Campaigns — full reference for campaign configuration
- Understanding Conversations — conversation lifecycle, statuses, and message history
- Webhooks — receive real-time events when conversations start, complete, or need review
- Authentication — how to obtain and use an API key
API reference
Updated 5 days ago