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-key for your organization (see Authentication).
  • An x-organization-id for 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:

questionTypeRequired fieldsUse it for
TEXTisMandatory, contentConversational questions answered by the candidate.
DOCUMENTisMandatory, 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 customMessage you sent on the request is returned as customRequest in the response — they refer to the same field.

Capture id from the response — you will pass it as campaignId when creating conversations.

Note: Setting campaignStatus to LIVE does not send anything on its own. Messages are only dispatched when you create a conversation against the campaign (Step 2). A LIVE campaign with no conversations is inert.

Required fields by campaign type

FieldAPPLICANT_OUTREACH / NEW_CANDIDATE_OUTREACHENGAGEMENT_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.

closingMethodRequired companion fieldWhat happens at the end of the conversation
CUSTOMcustomMessageA custom message you supply is sent verbatim (used above)
MEETING_URLmeetingUrlCandidate is given a scheduling link (e.g., Calendly)
PHONE_NUMBERcontactNumberCandidate is given a phone number to call
CALENDAR_MEETING_INTEGRATIONcalendarMeetingTemplateIdPopp'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: true if 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

Rather 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

API reference