> ## Documentation Index
> Fetch the complete documentation index at: https://docs.minerva.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Async Person Search: End-to-End Flow

> The complete async person-search flow: submit a query, poll until the segment materializes, review size and cost, unlock once, then page every PID

<div style={{ display: 'none' }} aria-hidden="false">
  ## Quick Answer

  **How do I run an async person search from start to finish?** Four calls:
  `POST /search-jobs` to submit, `GET /search-jobs/{segment_id}` to poll until
  `completed`, `POST /search-jobs/{segment_id}/results` once to unlock and bill
  the segment, then `GET /search-jobs/{segment_id}/results` to page the rest.

  **Common questions this page answers:**

  * When should I use async person search instead of the synchronous endpoint?
  * How do I know when my search is done?
  * How much will unlocking cost, and how do I check before I commit?
  * How do I retrieve more than 100 PIDs?
  * Why did I get a `409`?

  **What you need:** A user-associated API key and a natural-language audience
  description of 500 characters or fewer.

  **What you get back:** Every Minerva PID matching the query, paged 100 at a
  time, billed once for the net-new PIDs.
</div>

## Overview

Synchronous [Person Search](/api-reference/person-search) returns up to 100
PIDs immediately and stops there. Async Person Search instead materializes the
**complete** result set into a reusable segment, tells you exactly how large it
is, and only charges you when you explicitly unlock it.

That trade — waiting, in exchange for the full audience and a look at the
price before you pay it — is the whole reason the async flow exists.

|                        | Synchronous Person Search       | Async Person Search                       |
| ---------------------- | ------------------------------- | ----------------------------------------- |
| Endpoint               | `POST /person-search/v0/search` | `POST /person-search/v0/search-jobs`      |
| Latency                | Seconds, one call               | Background job, poll for completion       |
| Results                | Up to 100 PIDs, one page only   | The entire match set, paged 100 at a time |
| See size before paying | No                              | Yes — `total_count` at completion         |
| Billing                | Per request                     | Once, on unlock, deduplicated per org     |
| Reusable               | `search_id` re-fetch            | Persistent segment                        |
| API key                | Org or user key                 | **User-associated key only**              |

<Note>
  Use synchronous search to preview or prototype an audience. Switch to async
  once you want the whole thing.
</Note>

## Before you start

<Steps>
  <Step title="Use a user-associated API key">
    Organization-only API keys cannot create or read async person-search jobs
    and receive `401 Unauthorized` on every endpoint in this flow. If you get a
    `401` with a key that works elsewhere, this is why.
  </Step>

  <Step title="Have credits available">
    Unlocking bills the segment's net-new PIDs against the
    `platform_records_under_management` metric. Insufficient credits fail the
    unlock with `402` and return no PIDs.
  </Step>

  <Step title="Write the query as an audience description">
    Describe shared attributes ("Software engineers in Austin who previously
    worked at a public technology company"), not a specific named person. See
    [Writing good queries](/api-reference/person-search#writing-good-queries)
    for the attribute categories the parser handles well — they are identical
    for sync and async.
  </Step>
</Steps>

## The flow at a glance

```
1. POST   /person-search/v0/search-jobs                        → segment_id, 202
2. GET    /person-search/v0/search-jobs/{segment_id}           → poll until status = completed
3.        (read total_count + estimated_billable_count)        → decide whether to unlock
4. POST   /person-search/v0/search-jobs/{segment_id}/results   → unlocks + BILLS once, returns page 1
5. GET    /person-search/v0/search-jobs/{segment_id}/results   → pages 2..n, free
```

Steps 1, 2, 3 and 5 never bill. **Step 4 is the only call that charges you.**

***

## Step 1 — Submit the search

`POST /person-search/v0/search-jobs` accepts the query and returns immediately
with `202 Accepted`. Parsing and materialization continue in the background.

```bash theme={null}
curl --request POST \
  --url 'https://api.minerva.io/person-search/v0/search-jobs' \
  --header 'x-api-key: <api-key>' \
  --header 'Content-Type: application/json' \
  --data '{
    "query": "Software engineers in Austin who previously worked at a public technology company"
  }'
```

```json 202 theme={null}
{
  "segment_id": "8c2a6f71-3d41-4fc5-a2b6-1f53a5c81d77",
  "status": "created",
  "created_at": "2026-07-21T20:00:00.000000Z"
}
```

Store the `segment_id`. It is the identifier for the job, the segment, and
every subsequent call in this flow. The submit response contains **no PIDs** —
that is the key difference from synchronous search.

<Card title="Full endpoint reference" icon="arrow-right" href="/api-reference/person-search-job-submit">
  Request body, headers, and the complete error table for the submit call.
</Card>

***

## Step 2 — Poll until the segment is built

`GET /person-search/v0/search-jobs/{segment_id}` reports progress. Poll every
2–5 seconds until the status is terminal. Polling never unlocks PIDs and never
bills.

```bash theme={null}
curl --request GET \
  --url 'https://api.minerva.io/person-search/v0/search-jobs/8c2a6f71-3d41-4fc5-a2b6-1f53a5c81d77' \
  --header 'x-api-key: <api-key>'
```

The job walks forward through these states:

| Status           | Meaning                                               | Terminal? |
| ---------------- | ----------------------------------------------------- | --------- |
| `created`        | Accepted and waiting to begin                         | No        |
| `initiated`      | Processing has started                                | No        |
| `parsing_search` | The natural-language query is being parsed            | No        |
| `building_query` | Structured search filters are being compiled          | No        |
| `querying`       | Matching PIDs are being materialized into the segment | No        |
| `completed`      | Full segment materialization is complete              | **Yes**   |
| `failed`         | Terminal processing failure                           | **Yes**   |
| `cancelled`      | Processing was cancelled                              | **Yes**   |

<Warning>
  Stop polling on `failed` and `cancelled`, not just on `completed`. A retry
  loop that only checks for `completed` will spin forever on a failed job.
</Warning>

If the query could not be fully parsed, the status response carries
`error_criteria` describing which criteria failed and `suggested_search` with a
rephrased query worth trying instead.

<Card title="Full endpoint reference" icon="arrow-right" href="/api-reference/person-search-job-status">
  Every status field, the criteria tree shape, and completed-job statistics.
</Card>

***

## Step 3 — Review size and cost before unlocking

This is the step that makes the async flow worth using. Once `status` is
`completed`, the status response tells you exactly what you are about to buy:

| Field                      | What it tells you                                                |
| -------------------------- | ---------------------------------------------------------------- |
| `total_count`              | Exact number of PIDs in the segment                              |
| `estimated_billable_count` | Upper bound on what unlock will charge, **before** deduplication |
| `billable_count`           | Exact amount charged. `null` until you unlock                    |
| `results_unlocked`         | Whether this materialization has already been unlocked           |
| `stats`                    | Contact coverage and demographic distributions for the segment   |

`estimated_billable_count` normally equals `total_count`. The actual charge can
come in lower, because PIDs your organization already unlocked this billing
year are deduplicated and not charged twice.

<Warning>
  The estimate covers the **entire segment**, not one page. Unlocking a
  50,000-PID segment with `limit=10` still considers all 50,000 PIDs for
  billing. `limit` controls the response size only — it is not a spending cap.
</Warning>

The `stats` block also lets you qualify the audience before paying for it —
email, phone, and address coverage, plus age, gender, income, and location
distributions. If coverage is too thin for your campaign, abandon the segment
here and refine the query. Nothing has been charged yet.

***

## Step 4 — Unlock the segment (this bills)

`POST /person-search/v0/search-jobs/{segment_id}/results` unlocks the full
materialization, charges the net-new PIDs once, and returns your first page.

```bash theme={null}
curl --request POST \
  --url 'https://api.minerva.io/person-search/v0/search-jobs/8c2a6f71-3d41-4fc5-a2b6-1f53a5c81d77/results?limit=100&offset=0' \
  --header 'x-api-key: <api-key>'
```

```json 200 theme={null}
{
  "segment_id": "8c2a6f71-3d41-4fc5-a2b6-1f53a5c81d77",
  "pids": ["p-a1b2c3d4e5f6", "p-b2c3d4e5f6a7", "p-c3d4e5f6a7b8"],
  "total": 1284,
  "returned": 3,
  "limit": 100,
  "offset": 0,
  "billable_count": 1207,
  "unlocked_at": "2026-07-21T20:02:10.000000Z"
}
```

`billable_count` is the exact charge — here 1,207 net-new PIDs out of a 1,284
PID segment, because 77 were already under management.

Unlock is idempotent. Repeating the `POST` does not charge the same
materialization again.

***

## Step 5 — Page through the remaining PIDs

After unlock, use `GET` on the same path for every subsequent page. `GET` is
side-effect-free and never bills.

```bash theme={null}
curl --request GET \
  --url 'https://api.minerva.io/person-search/v0/search-jobs/8c2a6f71-3d41-4fc5-a2b6-1f53a5c81d77/results?limit=100&offset=100' \
  --header 'x-api-key: <api-key>'
```

Each response returns at most **100 PIDs**. Start at `offset=0` and advance by
`returned` until `offset + returned >= total`. The 100-PID cap applies to the
HTTP response only, not to the segment — `total` reports the full count and you
can page all of it.

<Card title="Full endpoint reference" icon="arrow-right" href="/api-reference/person-search-job-results">
  POST vs GET semantics, pagination parameters, and the complete error table.
</Card>

***

## Complete working example

The whole flow, end to end, with a cost check before the unlock:

```python theme={null}
import time
import requests

BASE_URL = "https://api.minerva.io"
HEADERS = {"x-api-key": "<api-key>"}
MAX_SPEND = 5000  # refuse to unlock a segment larger than this

# --- Step 1: submit ------------------------------------------------------
response = requests.post(
    f"{BASE_URL}/person-search/v0/search-jobs",
    headers=HEADERS,
    json={"query": "Software engineers in Austin who previously worked at a public technology company"},
)
response.raise_for_status()
segment_id = response.json()["segment_id"]

# --- Step 2: poll until terminal -----------------------------------------
TERMINAL = {"completed", "failed", "cancelled"}
while True:
    response = requests.get(
        f"{BASE_URL}/person-search/v0/search-jobs/{segment_id}",
        headers=HEADERS,
    )
    response.raise_for_status()
    job = response.json()
    if job["status"] in TERMINAL:
        break
    time.sleep(3)

if job["status"] != "completed":
    raise RuntimeError(f"Search {segment_id} ended as {job['status']}")

# --- Step 3: check size and cost before committing -----------------------
print(f"{job['total_count']} PIDs, up to {job['estimated_billable_count']} billable")
if job["estimated_billable_count"] > MAX_SPEND:
    raise SystemExit("Segment too large — refine the query and resubmit")

# --- Step 4: unlock once (this bills) ------------------------------------
response = requests.post(
    f"{BASE_URL}/person-search/v0/search-jobs/{segment_id}/results",
    headers=HEADERS,
    params={"limit": 100, "offset": 0},
)
response.raise_for_status()
page = response.json()
pids = list(page["pids"])
print(f"Charged for {page['billable_count']} net-new PIDs")

# --- Step 5: page the rest (free) ----------------------------------------
offset = page["returned"]
while offset < page["total"]:
    response = requests.get(
        f"{BASE_URL}/person-search/v0/search-jobs/{segment_id}/results",
        headers=HEADERS,
        params={"limit": 100, "offset": offset},
    )
    response.raise_for_status()
    page = response.json()
    pids.extend(page["pids"])
    offset += page["returned"]

print(f"Retrieved {len(pids)} PIDs")
```

***

## Troubleshooting

| Symptom                                                   | Cause                                                                  | Fix                                                                               |
| --------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `401` on submit, with a key that works on other endpoints | The API key has no associated user                                     | Use a user-associated key; org-only keys cannot use this flow                     |
| `409` on `POST .../results`                               | Materialization is not finished, or an unlock is already in progress   | Poll status until `completed`, then retry                                         |
| `409` on `GET .../results`                                | You paged before unlocking                                             | Call `POST .../results` once first                                                |
| `402` on `POST .../results`                               | Not enough credits for the segment's net-new PIDs                      | Check `estimated_billable_count` first, or refine the query to shrink the segment |
| `404` on status or results                                | Unknown or deleted `segment_id`, or it belongs to another organization | Confirm the `segment_id` and that the key is for the owning org                   |
| `422` on submit                                           | `query` is empty or over 500 characters                                | Shorten the query                                                                 |
| Job never leaves `querying`                               | Polling loop only checks for `completed`                               | Treat `failed` and `cancelled` as terminal too                                    |
| Billed more than the page you requested                   | `limit` sizes the response, not the charge                             | Unlock considers every PID in the segment; check the count in step 3              |

***

## What to do with the PIDs

Async person search returns Minerva PIDs only, with no person attributes
attached. Take them somewhere next:

<CardGroup cols={2}>
  <Card title="Enrich v2" href="/api-reference/enrich-v2">
    Fetch full profile data — demographics, work history, contact channels —
    for the PIDs you unlocked.
  </Card>

  <Card title="Add Members to Segment" href="/api-reference/segments-members-add">
    Persist the results into a segment you can track and activate.
  </Card>

  <Card title="Person Search Usage" href="/api-reference/person-search-usage">
    Review daily delivery limits and consumption history.
  </Card>

  <Card title="Contact Append / Prospect" href="/api-reference/person-search-contact-append">
    A separate async flow for ABM contacts at target companies.
  </Card>
</CardGroup>

## Endpoint reference

* [1. Submit Search](/api-reference/person-search-job-submit) — `POST /person-search/v0/search-jobs`
* [2. Poll Status](/api-reference/person-search-job-status) — `GET /person-search/v0/search-jobs/{segment_id}`
* [3. Unlock & Page Results](/api-reference/person-search-job-results) — `POST` and `GET /person-search/v0/search-jobs/{segment_id}/results`
