Document Collection Flow
Document Collection Flow
An end-to-end walkthrough of using a campaign to collect typed, validated documents from candidates. You define each kind of document you accept once (passport, certification, background check, ...), reference those definitions on the campaign's DOCUMENT questions, then trigger conversations. Popp's AI agent asks for each document in turn and runs the validation checks you configured.
The example below sets up a new-hire onboarding flow that collects three documents: a right-to-work proof, a professional certification, and a background check. The same pattern applies to any document-collection use case (compliance, KYC, contractor onboarding, ...).
Prerequisites
- A valid
x-api-keyfor your organization (see Authentication). - An
x-organization-idfor the target organization. - An agent name and tone, or a saved opening message template, depending on which channel you plan to use.
Step 1: Define your DocumentTypes
A DocumentType is a re-usable definition of a kind of document you accept. Each carries:
name— the human-readable label your AI agent uses when asking for the document and that recruiters see in the dashboard.validation— the automated checks Popp runs after the candidate uploads. Pick from the enum below.classificationRules— hints Popp uses to recognise the document on upload. Each rule asserts a field must appear in the parsed document.validationSettings— JSON-encoded knobs that tune the validation checks (currentlyvalidForandissuedAfter).externalId(optional) — your own stable id so you can reconcile DocumentTypes against your system without keeping a separate mapping.
Validation check reference
| Validation enum | What it checks |
|---|---|
CHECK_NAME_MATCH | The name extracted from the document matches the candidate's profile name. |
CHECK_DATE_OF_BIRTH_INCLUSION | A date of birth is present and (when possible) matches the candidate's profile. |
CHECK_HOME_ADDRESS_INCLUSION | A home address is present on the document. |
CHECK_ISSUE_DATE | An issue date is present. Combine with validationSettings.issuedAfter. |
CHECK_EXPIRATION_DATE | An expiration date is present and in the future. Combine with validFor. |
CHECK_DOCUMENT_CORNER_INCLUSION | All four corners of the document are visible (no cropped photos of, say, a passport). |
Classification rules
classificationRules is a list of { type: "INCLUDES_FIELD", value: "<field label>" } entries. The value is the human-readable label of a field that should appear on the parsed document — for example "Passport number", "Certification number", "Issuing organization", "Report date". Popp uses these as positive signals when deciding whether an uploaded file matches this DocumentType. The richer your rules, the less likely Popp is to mis-classify a candidate's upload.
1a. Dry-run the DocumentType against a sample
Before you create the DocumentType for real, run it against a sample of the document you expect candidates to upload. POST /v1/documents/process is stateless — it accepts an inline documentType block plus a sample document and returns the same classification + validation result the campaign flow would produce. Use it to iterate on validation and classificationRules until you see passedValidation: true on a known-good sample, then promote the same documentType block into the create call below.
curl -X POST "https://api.joinpopp.com/v1/documents/process" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"documentUrl": "https://example.com/sample-passport.pdf",
"candidateName": "Jordan Lee",
"documentType": {
"name": "Right to Work Document",
"validation": [
"CHECK_NAME_MATCH",
"CHECK_EXPIRATION_DATE",
"CHECK_DATE_OF_BIRTH_INCLUSION"
],
"classificationRules": [
{ "type": "INCLUDES_FIELD", "value": "Passport number" },
{ "type": "INCLUDES_FIELD", "value": "Nationality" }
],
"validationSettings": { "validFor": { "value": 1, "unit": "MONTHS" } }
}
}'You can supply the sample either as documentUrl (a publicly fetchable link) or as documentBase64 (inline). Supported formats: PDF, JPEG, PNG, GIF, WebP, DOC, DOCX. candidateName is only consulted when validation includes CHECK_NAME_MATCH.
Two response shapes worth knowing — happy path:
{
"passedValidation": true,
"documentTypes": ["Right to Work Document"],
"validationErrors": []
}Classifier didn't match the sample (or validation failed):
{
"passedValidation": false,
"documentTypes": [],
"validationErrors": [
{ "error": "CHECK_EXPIRATION_DATE", "message": "Expiration date is in the past." }
],
"missingFields": ["Nationality"]
}documentTypes: []means the classifier didn't recognise the sample as this DocumentType. Loosen or correct yourclassificationRules—missingFieldslists the labels it couldn't find.validationErrorsis the per-check breakdown. Each entry names the validation enum that failed and a human-readable reason.passedValidationistrueonly when classification succeeded and every validation check passed.
Tip: Keep the same
documentTypeblock here and in the create call below — that way your dry-run is a faithful test of what the campaign will see at runtime. ThevalidationSettingsfield is the only shape difference between the two endpoints: dry-run takes it as a real JSON object (shown above), create takes it as a JSON-encoded string (Step 1b).
1b. Create the right-to-work DocumentType
curl -X POST "https://api.joinpopp.com/v1/document-types" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "Right to Work Document",
"validation": [
"CHECK_NAME_MATCH",
"CHECK_EXPIRATION_DATE",
"CHECK_DATE_OF_BIRTH_INCLUSION"
],
"classificationRules": [
{ "type": "INCLUDES_FIELD", "value": "Passport number" },
{ "type": "INCLUDES_FIELD", "value": "Nationality" }
],
"validationSettings": "{\"validFor\":{\"value\":1,\"unit\":\"MONTHS\"}}",
"externalId": "rtw-document"
}'validationSettings is a JSON-encoded string. The two recognised keys today are validFor (used with CHECK_EXPIRATION_DATE — the document must not expire within this window) and issuedAfter (used with CHECK_ISSUE_DATE — the document must have been issued within this window). Each takes { value: number, unit: "DAYS" | "MONTHS" | "YEARS" }.
Sample 201 response:
{
"id": "144967dd-af9f-4018-8372-a056af4060fc",
"organizationId": "fe5f9f7e-43af-49bb-b11a-251f347d1f6a",
"name": "Right to Work Document",
"validation": [
"CHECK_NAME_MATCH",
"CHECK_EXPIRATION_DATE",
"CHECK_DATE_OF_BIRTH_INCLUSION"
],
"classificationRules": [
{ "type": "INCLUDES_FIELD", "value": "Passport number" },
{ "type": "INCLUDES_FIELD", "value": "Nationality" }
],
"validationSettings": "{\"validFor\":{\"unit\":\"MONTHS\",\"value\":1}}",
"externalId": "rtw-document",
"isArchived": false,
"isUsedInCampaign": false,
"createdAt": "2026-06-25T10:03:38.954Z",
"updatedAt": "2026-06-25T10:03:38.954Z"
}Capture the id — you'll reference it from a campaign in Step 2.
1c. Create the certification DocumentType
curl -X POST "https://api.joinpopp.com/v1/document-types" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "BLS Certification",
"validation": ["CHECK_NAME_MATCH", "CHECK_EXPIRATION_DATE"],
"classificationRules": [
{ "type": "INCLUDES_FIELD", "value": "Certification number" },
{ "type": "INCLUDES_FIELD", "value": "Issuing organization" }
],
"validationSettings": "{\"validFor\":{\"value\":1,\"unit\":\"MONTHS\"}}",
"externalId": "bls-certification"
}'1d. Create the background-check DocumentType
curl -X POST "https://api.joinpopp.com/v1/document-types" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "Background Check",
"validation": ["CHECK_NAME_MATCH", "CHECK_ISSUE_DATE"],
"classificationRules": [
{ "type": "INCLUDES_FIELD", "value": "Report date" },
{ "type": "INCLUDES_FIELD", "value": "Jurisdiction" }
],
"validationSettings": "{\"issuedAfter\":{\"value\":6,\"unit\":\"MONTHS\"}}",
"externalId": "background-check"
}'Tip:
isUsedInCampaignflips totrueas soon as any campaign or workflow references the DocumentType. While that flag istrue, hard deletes are blocked — usePOST /v1/document-types/{id}/archiveto retire a type instead, which preserves historical references on existing campaigns.
Step 2: Attach DocumentTypes to a campaign
A campaign defines how your AI agent communicates with candidates and which screening questions it works through. To collect documents, add one DOCUMENT question per DocumentType and reference the id you captured in Step 1.
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": "SMS",
"closingMethod": "CUSTOM",
"campaignTitle": "New Hire Onboarding",
"campaignDescription": "Pre-employment onboarding flow. Collect right-to-work proof, professional certification, and a recent background check before the first shift.",
"closingMessage": "Understood - thanks for letting us know. If circumstances change, feel free to reach back out.",
"rejectionMessage": "Thanks for your interest. Onboarding cannot proceed without the requested documents.",
"customMessage": "All documents received. HR will be in touch with your first-day details.",
"company": "Acme Health",
"location": "London, UK",
"agentName": "Sam",
"agentTone": "FRIENDLY",
"campaignStatus": "LIVE",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, welcome to {{ORGANIZATION_NAME}}! Before your first shift we need to collect a few documents. Reply STOP to opt out.",
"externalId": "onb-campaign-001",
"questions": [
{
"questionType": "DOCUMENT",
"isMandatory": true,
"documentItems": [{ "documentTypeId": "144967dd-af9f-4018-8372-a056af4060fc" }]
},
{
"questionType": "DOCUMENT",
"isMandatory": true,
"documentItems": [{ "documentTypeId": "a175c69c-80b9-485e-82c8-9a1919d6612d" }]
},
{
"questionType": "DOCUMENT",
"isMandatory": false,
"documentItems": [{ "documentTypeId": "7773fe5f-efd2-4506-a621-c5138adf78c6" }]
}
]
}'The documentItems shape
documentItems shapedocumentItems is the typed way to attach DocumentTypes to a DOCUMENT question. Each entry is { "documentTypeId": "<uuid from Step 1>" }. The server:
- Resolves each id to its canonical
nameand writes it back on the campaign asdocumentTypeName, so the campaign is readable without a join. - Verifies the id belongs to your organization. Unknown or cross-org ids fail with
400. - Verifies the id is not archived. Archived ids fail with
400. - Flips
isUsedInCampaigntotrueon each referenced DocumentType.
A DOCUMENT question must carry exactly one of documentItems (recommended) or the deprecated free-text documentTypes: ["..."] — never both. See the Job Application Flow for the wider campaign field reference.
Sample response
The server echoes the resolved name on each reference:
{
"id": "631851fc-7cde-4968-af1b-fbef118def9a",
"organizationId": "fe5f9f7e-43af-49bb-b11a-251f347d1f6a",
"type": "APPLICANT_OUTREACH",
"channel": "SMS",
"closingMethod": "CUSTOM",
"campaignTitle": "New Hire Onboarding",
"campaignStatus": "LIVE",
"externalId": "onb-campaign-001",
"agent": {
"id": "9902a2b6-c1e5-40c7-8d53-db3eb590c28f",
"agentName": "Sam",
"agentTone": "FRIENDLY"
},
"questions": [
{
"questionType": "DOCUMENT",
"isMandatory": true,
"documentItems": [
{
"documentTypeId": "144967dd-af9f-4018-8372-a056af4060fc",
"documentTypeName": "Right to Work Document"
}
]
},
{
"questionType": "DOCUMENT",
"isMandatory": true,
"documentItems": [
{
"documentTypeId": "a175c69c-80b9-485e-82c8-9a1919d6612d",
"documentTypeName": "BLS Certification"
}
]
},
{
"questionType": "DOCUMENT",
"isMandatory": false,
"documentItems": [
{
"documentTypeId": "7773fe5f-efd2-4506-a621-c5138adf78c6",
"documentTypeName": "Background Check"
}
]
}
]
}Capture id — you'll pass it as campaignId when creating conversations.
Note: Order matters. The AI agent walks the
questionsarray in order, asking for each document in turn. Mandatory documents that the candidate refuses or fails close the conversation early (COMPLETED_SCREENING_FAILED). Non-mandatory documents are recorded but don't gate the result.
Step 3: Trigger a conversation to collect the documents
A campaign on its own doesn't reach out to anyone. Each candidate gets a conversation; the conversation sends the opening message, walks through the document questions, runs validation as each upload arrives, and applies the closing method.
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": "631851fc-7cde-4968-af1b-fbef118def9a",
"firstName": "Jordan",
"lastName": "Lee",
"phoneNumber": "+447911123456",
"externalId": "new-hire-001"
}'The endpoint returns 202 Accepted:
{ "message": "Conversation created" }Phone numbers must be in E.164 format (e.g. +447911123456). For EMAIL-channel campaigns, send emailAddress instead of phoneNumber. The externalId is your reference for the candidate and is echoed back in webhook events.
For batches of candidates, prefer POST /v1/conversations/bulk — up to 10,000 contacts per call.
What the candidate sees
The opening message you supplied via customTemplatedMessage (or your openingMessageTemplateId) is delivered first. As the candidate replies, the AI agent asks for each document in the order they appear on the campaign — for the onboarding flow above:
- "Could you share your right-to-work document — for example a passport?"
- "Could you upload your BLS certification?"
- "And finally, do you have a recent background check available?" (non-mandatory — the candidate can skip)
Each upload is processed through Popp's document parser. The DocumentType's classificationRules decide whether the uploaded file is the right kind of document; the validation checks decide whether it's acceptable. The agent will re-ask, accept, or politely decline based on the result.
Step 4: Receive completion + per-document outcomes
Subscribe a webhook to the conversation events you care about — at minimum CONVERSATION_COMPLETED. Each event carries the externalId you supplied so you can route the result into the right record on your side:
{
"event": "CONVERSATION_COMPLETED",
"eventId": "evt_abc123",
"eventTimestamp": "2026-06-25T10:30:00.000Z",
"data": {
"recordId": "conv_xyz789",
"organizationId": "fe5f9f7e-43af-49bb-b11a-251f347d1f6a",
"externalId": "new-hire-001",
"externalCampaignId": "onb-campaign-001",
"conversationStatus": "COMPLETED_CUSTOM_REQUEST"
}
}For per-document detail (which uploads succeeded, which failed validation, raw file URLs), fetch the conversation:
curl -X GET "https://api.joinpopp.com/v1/conversations/conv_xyz789" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORGANIZATION_ID"See Documents for the full per-document response shape and validation outcome reference.
Putting it all together
┌────────────────────────────────────┐
│ POST /v1/documents/process │ Dry-run each definition against a
│ (iterate until passedValidation) │ sample upload until it passes
└──────────┬─────────────────────────┘
│ verified documentType block
▼
┌────────────────────────────────────┐
│ POST /v1/document-types ×N │ Promote it into a real DocumentType
└──────────┬─────────────────────────┘
│ documentTypeId per type
▼
┌────────────────────────────────────┐
│ POST /v1/campaigns │ Reference the DocumentTypes
│ questions[].documentItems: [...] │ via `documentItems`
└──────────┬─────────────────────────┘
│ campaignId
▼
┌────────────────────────────────────┐
│ POST /v1/conversations │ Trigger per candidate
│ (or /bulk) │
└──────────┬─────────────────────────┘
│ AI agent walks the
│ DOCUMENT questions
▼
┌────────────────────────────────────┐
│ Webhook: CONVERSATION_COMPLETED │ Documents collected, validated,
│ + GET /v1/conversations/{id} │ ready to fetch
└────────────────────────────────────┘
Related guides
- Job Application Flow — the broader campaign walkthrough, including
TEXTquestions and other closing methods - Documents — collected-document payloads, per-document validation outcomes, and download URLs
- Understanding Conversations — conversation lifecycle, statuses, and message history
- Webhooks — receive real-time events when conversations start, complete, or need review