# Primavera API

Primavera is a B2B database of businesses: Google Maps places, in France and in several other countries, read with their websites (email addresses, phones, social networks, technologies). The API does what the web app does: turn a target into categories, find an area, count, estimate, preview, create lists of up to 50,000 rows, run jobs on them (executives, website summary, question to the website, AI filter, email verification) and download them. Same balance and same prices as the web app.

This page is written for AI agents: read it from start to end before the first call. The same text as a web page: https://primaverapp.com/docs/api. Plain text: https://primaverapp.com/docs/api.md. OpenAPI 3.1: https://primaverapp.com/api/v1/openapi.json.

Base URL: `https://primaverapp.com/api/v1`

## Rules for agents

- Authenticate with `Authorization: Bearer $PRIMAVERA_API_KEY`. Never print, log or repeat the key.
- For any target, call `POST /categories/suggest` first (100 credits). Never invent category ids.
- Lookups, counts, figures, previews and estimates are free: use them before paying.
- Every paid call needs an `Idempotency-Key` header (a new UUID per operation) and `maxCredits` in the JSON body. Send the `cost` of the estimate as `maxCredits`.
- On a refusal, read `error.code` and do what `error.hint` says. Never retry in a loop; wait for `Retry-After`.
- When a figure comes with `exact: false`, say "≈ N", never N.
- Email addresses, phone numbers and websites only come with a list: previews show whether they exist, not what they are.
- Deleting a list is final and refunds nothing: ask the user first.
- Some text fields come from the product in French (`selection.message`, `question`, reasons, area notes, the fair use sentence): translate them for the user when needed.

## Authentication

The account owner creates a key at https://primaverapp.com/dashboard/api. The key reads `pv_live_` followed by 32 letters and digits; it is shown once, at creation. An account has at most 5 active keys; a revoked key is refused at once. A key can carry a monthly credit cap: a paid call whose `maxCredits` would exceed what the cap still allows this month is refused with `key_budget_exceeded`.

Keep the key in the environment variable `PRIMAVERA_API_KEY` and send it in the `Authorization` header of every call:

```bash
curl -s https://primaverapp.com/api/v1/account \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY"
```

Bodies are JSON (`Content-Type: application/json`, 1 MB at most), field names in camelCase. Responses are JSON and never cached.

## Access

The API is open to accounts with an active Pro or Agency subscription (named `starter` and `agency` in `GET /account`; the legacy `pro` plan too). A trial, free or cancelled account gets `403 plan_required`: stop and tell the user that the API needs a Pro or Agency subscription (https://primaverapp.com/dashboard/billing). An account on hold gets `403 account_frozen`: stop and tell the user to contact Primavera support.

The API spends the same balance as the web app, at the same prices: what the API spends, the app shows, and the other way round.

## Limits

| Limit | Pro | Agency |
|---|---|---|
| Requests per minute, per account (all keys together) | 60 | 300 |
| Category suggestions per hour | 30 | 30 |
| Search reads per hour, shared with the web app of the account | 2,500 | 2,500 |
| Area and category lookups per hour, shared with the web app | 3,000 | 3,000 |

Search reads count the catalog queries a call makes: `/search/count` 1, `/search/metrics` 5, `/search/preview` 1, `/search/estimate` 1 (2 with `excludeDelivered: false`). Lookups: `/areas` 1 or more (a written place can need several), `/categories` 1 (2 outside France). Reuse the figures you already have instead of counting the same search twice.

Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix time, seconds). Over a limit: `429 rate_limited` with a `Retry-After` header in seconds; wait that long, then send the same request once. `GET /account` returns the limits of the account (`rateLimits`).

A list holds at most 50,000 rows, and one list export runs at a time per account; at most three jobs run at a time on the lists of an account; a preview returns at most 25 rows.

## Errors

Every refusal has the same shape:

```json
{
  "error": {
    "code": "target_unresolved",
    "message": "categories.request is set but no category ids, groups or concepts are given: the search would match every place of the area.",
    "hint": "Call POST /categories/suggest with this request and use the search it returns. To search every category on purpose, send no categories at all."
  }
}
```

- `code` is stable: branch on it.
- `message` says what went wrong.
- `hint` says what to do next, in one sentence written for you: follow it.
- `details` (sometimes): one entry per invalid field (`path`, `message`) for `invalid_request`; the question and its options for `clarification_needed`.

| Code | HTTP | When |
|---|---|---|
| `unauthorized` | 401 | The API key is missing, malformed, unknown or revoked. |
| `plan_required` | 403 | The account has no active Pro or Agency subscription (trial, free, cancelled). |
| `account_frozen` | 403 | The account is on hold while a payment is checked. |
| `rate_limited` | 429 | Too many requests for this account: wait for Retry-After seconds. |
| `unavailable` | 503 | Maintenance, or a dependency did not answer: retry later with the same request. |
| `internal_error` | 500 | Unexpected error: retry once with the same request. |
| `idempotency_key_required` | 400 | The Idempotency-Key header is missing or is not a UUID. |
| `max_credits_required` | 400 | maxCredits is missing from the JSON body, or is not an integer ≥ 0. |
| `idempotency_conflict` | 409 | This Idempotency-Key was used for another request, or the same request is still running. |
| `key_budget_exceeded` | 403 | maxCredits would exceed what the monthly cap of this key still allows. |
| `insufficient_credits` | 402 | The balance does not cover the call. |
| `invalid_request` | 400 | A field is missing or invalid: see "details". |
| `country_unavailable` | 400 | The country is not open yet. |
| `target_unresolved` | 400 | categories.request is set without any category: call /categories/suggest first. |
| `target_excluded` | 400 | The exclusions remove every category of the target. |
| `clarification_needed` | 422 | The request is too vague: ask the user the question in "details", then suggest again (refunded). |
| `invalid_selection` | 422 | The engine chose no usable category (refunded): describe the target more precisely. |
| `zone_unavailable` | 422 | Rarely searched area without any estimate: narrow the target or the area. |
| `fair_use_limit` | 403 | Agency: the fair use limit of this country is reached. |
| `max_credits_too_low` | 400 | maxCredits does not cover the call: the message gives the amount needed. Nothing was charged. |
| `verify_emails_unavailable` | 400 | verifyEmails over 10,000 rows or in a rarely searched area: verify after creation with POST /lists/{id}/email-check. |
| `whole_area_unavailable` | 400 | wholeArea outside a rarely searched area: send rows. |
| `empty_search` | 422 | No place matches the search. Nothing was charged. |
| `whole_country` | 422 | This country cannot be filled whole: choose a region or a city. |
| `export_in_progress` | 409 | One export at a time per account, or places are still being added to this list. |
| `included_ended` | 409 | Exports are no longer included (the Agency subscription ended): estimate again. |
| `legacy_delivery_conflict` | 409 | These places are temporarily unavailable: try another search. |
| `too_many_active_fills` | 409 | Too many lists being filled at once: wait until one is done. |
| `catalog_unavailable` | 503 | The export failed and any credit taken was refunded: retry with the same request and Idempotency-Key. |
| `refund_pending` | 500 | The export failed and its refund is on its way (within 30 minutes): do not retry now. |
| `not_found` | 404 | Unknown id, or a list, job or file of another account. |
| `invalid_name` | 400 | Empty or too long name. |
| `list_creating` | 409 | The export of the list is still running. |
| `confirmation_required` | 400 | The confirm parameter (the list id again) is missing: ask the user, then send it. |
| `job_running` | 409 | A job still runs on the list. |
| `email_run_open` | 409 | Email addresses are still being guessed for the list (progress.emailGuess). |
| `fill_in_progress` | 409 | The list still fills itself (rarely searched area). |
| `list_not_ready` | 409 | The list is not ready (export running or failed): wait until GET /lists/{id} says "ready". |
| `not_completable` | 409 | Only a list created from a search can be completed (not a merged list). |
| `list_full` | 409 | The list already holds 50,000 places: create a new list. |
| `invalid_lists` | 400 | Send 2 to 20 different list ids. |
| `empty_list` | 409 | The list has no rows. |
| `not_in_france` | 409 | Executives exist for places in France only. |
| `job_unavailable` | 409 | The job cannot run on this list as it is: check it with GET /lists/{id}. |
| `invalid_scope` | 400 | scope is not { kind: "all" }, { kind: "conforming", jobId } or { kind: "undetermined", jobId }. |
| `nothing_to_do` | 422 | No place of the list needs this job. Nothing was charged. |
| `too_many_items` | 422 | More than 50,000 places to process: narrow the scope. |
| `scope_unavailable` | 409 | The job named in scope is not a finished filter or question job of this list. |
| `parent_unavailable` | 409 | The qualification to complete is not finished. |
| `too_many_active_jobs` | 409 | Three jobs already run on the lists of the account: wait until one is done. |
| `active_job_exists` | 409 | A job of this tool already runs on this list: follow it. |
| `email_check_running` | 409 | A verification already runs on this list: follow progress.emailCheck. |
| `nothing_to_check` | 409 | Every address of the list is already verified. |
| `email_check_included` | 409 | The addresses of this list are verified automatically, for free. |

A refusal that comes from the product keeps the product's code (for example `too_many_active_jobs`, `not_in_france`); its `hint` still says what to do.

## Money: paid calls and prices

### Prices

| What | Price |
|---|---|
| Category suggestion (`POST /categories/suggest`) | 100 credits per call, on every plan (Agency pays it from its AI credits). Refunded when the engine returns no selection. |
| Lookups, count, figures, preview, estimate | Free. |
| List export | 1 credit per place never delivered to the account before. Agency: included, without credits, under the fair use limit (10 % of the places of a country over 30 days, 2 million at most; lifted for an account after checking its company). |
| List "with email" | 1 credit per place delivered (included in Agency) plus 1 credit per address for its verification, reserved at creation; a place whose address proves undeliverable is removed and refunded. |
| Executives (France only) | 2 credits per place with at least one executive found, nothing otherwise. |
| Website summary, question to the website, AI filter | 1 credit per row analysed, even when the answer is negative or undetermined. No website, unreachable website: free. |
| Email verification (`verifyEmails` at creation, or `POST /lists/{id}/email-check`) | 1 credit per address that gets a verdict; the rest of the reservation is returned. |
| Rows in JSON, CSV and Excel files, renaming, merging, deleting | Free. |

A place already delivered to the account is never billed again: searches leave such places out by default (`excludeDelivered: true`). Deleting a list or removing rows refunds nothing.

### Paid calls

Every paid call needs:

- an `Idempotency-Key` header holding a UUID: a new one per operation (`uuidgen`, or `python3 -c "import uuid; print(uuid.uuid4())"`). Keep it until the operation succeeds;
- `maxCredits` in the JSON body: an integer from 0 to 1,000,000,000, the most credits the call may spend. It is never exceeded. Send the `cost` returned by the matching estimate. Missing: `400 max_credits_required`, nothing runs.

Retrying is safe:

- the same request with the same `Idempotency-Key` returns the saved result (header `Idempotent-Replayed: true`) and charges nothing more: after a timeout or a lost response, always retry with the SAME key;
- the same key with a different request: `409 idempotency_conflict`;
- the same request still running: `409 idempotency_conflict` with `Retry-After: 5`; a request interrupted on our side is taken up again after 10 minutes;
- a refused or failed call can be sent again with the same key, except a refunded suggestion: its `hint` asks for a NEW key.

Paid responses carry `credits`: `bound` (your `maxCredits`), `charged` (what the call debited: 0 on a refund; on a replay, flagged by the header `Idempotent-Replayed: true`, the body is the original response, so `charged` repeats what the ORIGINAL call debited, and nothing is debited again) and `balance` (after the call). Not enough credits: `402 insufficient_credits`, nothing is charged.

## The search object

`POST /categories/suggest` returns a ready search in its `search` field. Send it as is to `/search/*` and `POST /lists`; edit it only to narrow it.

```json
{
  "country": "fr",
  "categories": {
    "request": "independent car repair shops",
    "groups": [
      { "label": "Garages automobiles", "ids": [9592, 9593] },
      { "label": "Carrosserie et pneus", "ids": [9601] }
    ]
  },
  "excludedCategoryIds": [],
  "area": { "type": "commune", "codes": ["69123"] },
  "filters": { "excludeServicePoints": true, "networks": { "mode": "exclude" } },
  "sort": "default",
  "excludeDelivered": true
}
```

| Field | Meaning |
|---|---|
| `country` | ISO code, lower case (default `fr`). One country per search. A closed country: `country_unavailable`. |
| `categories.request` | The target as written: title of the search and default list name. |
| `categories.groups` | Groups of category ids chosen by the engine (at most 8). Remove a group to narrow the target. |
| `categories.ids` | Single categories added by hand (ids from `GET /categories`). |
| `categories.mode` | `primary_or_generic` (default): the main category of a place is in the target, or a secondary one when the main one is generic; `primary`: main category only; `any`: main or secondary (broader). |
| `excludedCategoryIds` | Categories removed from the target. |
| `area` | Take it from `GET /areas` (field `area`): a region, département, commune, postal code or a radius. Absent: the whole country. |
| `filters` | See the table below. |
| `sort` | `default`, `reviews_desc`, `rating_desc`, `name_asc`: order of previews and lists. |
| `excludeDelivered` | Default `true`: places already delivered to the account are neither counted nor billed again. |

Two refusals protect you from paying for the wrong places:

- `categories.request` without any category (no groups, no ids): `400 target_unresolved`. Call `/categories/suggest`.
- Exclusions that remove every category of the target: `400 target_excluded`.

A search with no categories at all means every category of the area: send that only on purpose.

### Filters

| Filter | Type | Meaning |
|---|---|---|
| `hasWebsite` | boolean | true: places with a website; false: without. |
| `websiteReachable` | boolean | true: the website answered when last read. |
| `hasPhone` | boolean | true: places with a phone number. |
| `emailStatus` | string[] | Places with an email address: send ["deliverable","catch_all","unverified"] (all three). Prefer withEmail on /search/estimate and POST /lists. |
| `ownEmailOnly` | boolean | Only places whose email address is not shared with other places (an address common to a whole network leads to the network, not to this place). |
| `ratingMin / ratingMax` | number 1–5 | Google rating bounds. |
| `reviewsMin / reviewsMax` | integer | Number of Google reviews bounds. |
| `includeClosed` | boolean | Include permanently closed places (default false). |
| `socials` | string[] | Every listed social network is linked from the website: facebook, instagram, linkedin, youtube, twitter, tiktok, pinterest, threads. |
| `technologies` | string[] | At least one of these website technologies (WordPress, Wix, Shopify…). |
| `pixels` | string[] | At least one of these advertising pixels (Meta Pixel, Google Ads…). |
| `hasContactForm` | boolean | The website has a contact form. |
| `nameContains` | string | Part of the name (accents and case ignored), 2 to 100 characters. |
| `hotelStarsMin` | integer 1–5 | Hotels: minimum stars. |
| `hideHiddenCategories` | boolean | Hide categories nobody prospects (bus stops, car parks…). Default true. |
| `networks` | object | { "mode": "exclude" \| "only", "ids"?: [...] }: brands and networks (ids from GET /networks). Without ids, "exclude" removes every confirmed network ("independent" businesses). |
| `excludeServicePoints` | boolean | Remove service points (parcel lockers, ATMs, charging stations): nobody to talk to. Default true. |
| `naf` | string[] | France only: NAF rev. 2 activity codes of any level ("56", "56.10A"). Keeps places whose company is known in the French registry. |
| `headcountMin / headcountMax` | integer | France only: company headcount bounds (INSEE brackets fully inside the bounds). |
| `legalForms` | string[] | France only: INSEE legal category codes ("5", "57", "5710"). |
| `companyAgeMin / companyAgeMax` | integer | France only: company created at least / less than N years ago. |
| `companyCategories` | string[] | France only: "PME", "ETI", "GE" (INSEE company size category). |
| `onePer` | object | France only: { "by": "entreprise" \| "groupe", "keep"?: "siege" \| "classement" }: one place per company or group (head office first, or best ranked). Places without a known company all stay. |

## Walkthrough

The full path, from a target to a downloaded list. The examples use `curl` and `jq`; responses are shortened.

### 1. Check the account

```bash
curl -s https://primaverapp.com/api/v1/account \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY"
```

```json
{
  "plan": "starter",
  "planName": "Pro",
  "credits": { "balance": 9400 },
  "exportsIncluded": false,
  "rateLimits": { "requestsPerMinute": 60, "suggestionsPerHour": 30, "searchReadsPerHour": 2500, "lookupsPerHour": 3000 }
}
```

### 2. Find the area

```bash
curl -s -G https://primaverapp.com/api/v1/areas \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  --data-urlencode "q=Lyon"
```

```json
{
  "areas": [
    { "level": "commune", "code": "69123", "name": "Lyon", "country": "fr", "population": 522250, "parentCode": "69", "area": { "type": "commune", "codes": ["69123"] } }
  ],
  "usual": null,
  "match": { "status": "resolved", "area": { "type": "commune", "codes": ["69123"] }, "label": "Lyon", "country": "fr" }
}
```

Use `match.area` when `match.status` is `resolved`; when it is `ambiguous` or `elsewhere`, ask the user which candidate they mean.

### 3. Turn the target into categories (100 credits)

```bash
KEY=$(uuidgen)
curl -s https://primaverapp.com/api/v1/categories/suggest \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"request": "independent car repair shops", "area": {"type": "commune", "codes": ["69123"]}, "maxCredits": 100}' > suggestion.json
jq '.search' suggestion.json > search.json
```

```json
{
  "selection": {
    "groups": [
      { "label": "Garages automobiles", "kind": "coeur", "ids": [9592, 9593], "count": 412, "exact": true }
    ],
    "excluded": [{ "id": 9610, "label": "Concessionnaire automobile", "reason": "vend des véhicules" }],
    "cannotKnow": [],
    "message": "J'ai retenu les garages automobiles, sans les enseignes.",
    "question": null,
    "addable": [{ "label": "Carrosserie", "ids": [9601], "reason": null, "count": 58, "exact": true }]
  },
  "search": { "country": "fr", "categories": { "request": "independent car repair shops", "groups": [ … ] }, "area": { … }, "filters": { … }, "excludeDelivered": true },
  "count": { "count": 412, "exact": true },
  "networksExcluded": true,
  "credits": { "bound": 100, "charged": 100, "balance": 9300 }
}
```

Tell the user what was chosen (`selection.message`, groups and their counts). `addable` lists close categories left out: add one to `search.categories.groups` if the user wants it. If the response was lost, send the same request with the same `$KEY`: you get the saved selection without paying twice.

A request too vague to choose categories is refused and refunded:

```json
{
  "error": {
    "code": "clarification_needed",
    "message": "The request needs a clarification before categories can be chosen: \"Quels laboratoires ?\" (options: Laboratoires d'analyses médicales | Laboratoires de recherche). You were not charged.",
    "hint": "Ask the user this question (details: \"question\" and \"question.options\"), then send the request again, made more precise with the answer, under a NEW Idempotency-Key.",
    "details": [
      { "path": "question", "message": "Quels laboratoires ?" },
      { "path": "question.options.0", "message": "Laboratoires d'analyses médicales" },
      { "path": "question.options.1", "message": "Laboratoires de recherche" }
    ]
  }
}
```

### 4. Figures and estimate (free)

```bash
curl -s https://primaverapp.com/api/v1/search/metrics \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --slurpfile s search.json '{search: $s[0]}')"

curl -s https://primaverapp.com/api/v1/search/estimate \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --slurpfile s search.json '{search: $s[0], rows: 300}')"
```

```json
{ "total": { "count": 412, "exact": true }, "withEmail": { "count": 236, "exact": true }, "withPhone": { "count": 398, "exact": true }, "withWebsite": { "count": 301, "exact": true }, "emailCandidates": { "count": 41, "exact": true }, "estimatedEmails": { "count": 248, "exact": true, "rate": 0.3 }, "excludedDelivered": 0 }
```

```json
{ "kind": "export", "rows": 300, "available": 412, "exact": true, "cost": 300, "billable": 412, "credits": 9300, "after": 9000, "enough": true, "emailOnly": false, "included": false }
```

`cost` is the most the list can cost: send it as `maxCredits`. With `"withEmail": true`, the estimate is that of a list "with email" (rows are email addresses, the verification is included in `cost`). In a rarely searched area, the estimate has `kind: "zone"`: figures are approximate (say "≈"), and the list fills after its creation.

### 5. Preview (free)

```bash
curl -s https://primaverapp.com/api/v1/search/preview \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --slurpfile s search.json '{search: $s[0], limit: 10}')"
```

```json
{ "rows": [ { "id": 18234567, "name": "Garage du Rhône", "city": "Lyon", "postalCode": "69007", "category": "Garage automobile", "rating": 4.6, "reviews": 128, "website": true, "email": true, "phone": true } ], "limit": 10, "offset": 0 }
```

### 6. Create the list (paid)

```bash
LIST_KEY=$(uuidgen)
curl -s https://primaverapp.com/api/v1/lists \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $LIST_KEY" \
  -d "$(jq -n --slurpfile s search.json '{name: "Independent garages, Lyon", search: $s[0], rows: 300, maxCredits: 300}')"
```

```json
{
  "list": { "id": "8c1f…", "name": "Independent garages, Lyon", "status": "ready", "rows": 300, "requestedRows": 300, "creditsSpent": 296, "withEmail": false, "warning": null, "error": null },
  "alreadyDelivered": 4,
  "credits": { "bound": 300, "charged": 296, "balance": 9004 }
}
```

- `201`: the list is ready. `202`: it goes on filling in the background (more than 10,000 rows, guessed addresses of a list "with email", rarely searched area): follow it (step 7), never send it again under a new key.
- `alreadyDelivered`: places of the list already delivered to the account before, not billed again.
- After a timeout or a lost response, send the very same request with the same `$LIST_KEY`: you get either the original response (header `Idempotent-Replayed: true`, same `charged` as the first time), or, if that response was not saved, the list as it is now with `replayed: true` and `charged: 0`. In both cases nothing is debited again.
- `"withEmail": true`: a list "with email" (`rows` is a number of addresses; found addresses first, then guessed and checked ones). `maxCredits` must cover the estimate made with `"withEmail": true` (export, guessed addresses and verification), otherwise `400 max_credits_too_low`.
- `"verifyEmails": true`: the addresses the list delivers are verified (1 credit per address, reserved at creation, unused credits returned). Send `maxCredits` ≥ the estimate `cost` + `rows`. Up to 10,000 rows, not in a rarely searched area; otherwise verify later (step 10).
- Rarely searched area (estimate `kind: "zone"`): send `rows`, or `"wholeArea": true` for the whole area, and `maxCredits` ≥ `zoneEstimate.creditCap`. The answer is `202` with `fill`: the list fills while the area is updated (minutes to hours).

### 7. Follow the list

```bash
curl -s https://primaverapp.com/api/v1/lists/$LIST_ID \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY"
```

```json
{
  "list": {
    "id": "8c1f…", "status": "ready", "rows": 300,
    "progress": { "export": null, "fill": null, "emailGuess": null, "emailCheck": { "mode": "on_request", "unverified": 212, "running": null } },
    "contents": { "website": 221, "email": 212, "phone": 290, "executive": 0, "emailVerdicts": { "deliverable": 0, "risky": 0, "undeliverable": 0, "noResult": 0, "unchecked": 212 } },
    "jobs": []
  }
}
```

`LIST_ID` is `list.id` of the creation response. Poll every 10 seconds or more while `status` is `creating` or `filling`: `progress` tells how far the export, the filling, the guessed addresses and the email verification went. `progress.emailCheck.unverified` is what verifying the addresses would cost (step 10).

### 8. Read the rows (free)

```bash
curl -s -G https://primaverapp.com/api/v1/lists/$LIST_ID/items \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  --data-urlencode "limit=1000"
```

```json
{ "columns": [ { "key": "name", "label": "Nom" }, { "key": "email", "label": "Email" }, { "key": "email_status", "label": "Statut email" } ], "items": [ { "placeId": 18234567, "name": "Garage du Rhône", "email": "contact@garagedurhone.fr", "email_status": "non vérifiée" } ], "nextCursor": "…" }
```

Send `cursor` = `nextCursor` to read the next page, until `nextCursor` is `null`; `columns=name,email,phone` keeps only these columns. Labels and values are those of the CSV file, in French.

### 9. Download a file (free)

```bash
curl -s https://primaverapp.com/api/v1/lists/$LIST_ID/files \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"format": "xlsx"}'

curl -s https://primaverapp.com/api/v1/lists/$LIST_ID/files/$FILE_ID \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY"
```

`FILE_ID` is `file.id` of the first response. Poll the file until its `status` is `ready`: `url` is then a download link valid 120 seconds (call again for a new one). A file stays available 7 days; `stale` means: prepare it again.

### 10. Enrich the list (paid)

```bash
curl -s https://primaverapp.com/api/v1/lists/$LIST_ID/jobs/estimate \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tool": "summary"}'

JOB_KEY=$(uuidgen)
curl -s https://primaverapp.com/api/v1/lists/$LIST_ID/jobs \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $JOB_KEY" \
  -d '{"tool": "summary", "maxCredits": 300}'

curl -s https://primaverapp.com/api/v1/jobs/$JOB_ID \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY"
```

The estimate gives `estimate.maxCredits`: send it as `maxCredits`. The job answers `202` and runs in the background; each place is billed on its result, never beyond `maxCredits`. `JOB_ID` is `job.id` of that answer; poll `GET /jobs/{id}` until `status` is `done`, then read the results in the rows (step 8). Tools: `executives` (France only), `summary`, `question` (with `question`), `filter` (with `criterion`; `GET /jobs/{id}` then gives `verdicts`, and a `question` with `scope: { "kind": "undetermined", "jobId": … }` reads the websites of the undetermined places).

To verify the email addresses of a ready list:

```bash
curl -s https://primaverapp.com/api/v1/lists/$LIST_ID/email-check \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"maxCredits": 212}'
```

`maxCredits` is the number of addresses you accept to verify: `progress.emailCheck.unverified` of step 7. 1 credit per address that gets a verdict; the rest is returned when the check ends. Verdicts land in `email_status` (`délivrable`, `risqué`, `indélivrable`, `sans résultat`) and in `contents.emailVerdicts`.

## Reference

Each route with its inputs, its output and its own refusals. The [common refusals](#errors) apply to every route; every paid route can also answer `idempotency_key_required`, `max_credits_required`, `idempotency_conflict`, `key_budget_exceeded`, `insufficient_credits`. Paths are relative to the base URL.

### Account

Plan, balance, fair use and rate limits.

#### GET /account

**Plan, credit balance, fair use and rate limits.** Free. Read it first: the balance tells what the account can afford; Agency exports new places without credits under the fair use limit of each country.

**Free.**

Response 200:

| Field | Type | Description |
|---|---|---|
| `plan` | "starter" \| "pro" \| "agency" | 'starter' (Pro), 'pro' (legacy Pro 25,000) or 'agency'. |
| `planName` | string | — |
| `credits` | object | — |
| `credits.balance` | number or null | Credits available now. |
| `exportsIncluded` | boolean | Agency: new places are exported without credits, under the fair use limit of each country. |
| `fairUse` | object[] | Countries where the account received places in the last 30 days (Agency only; empty otherwise). |
| `fairUse[].country` | string | — |
| `fairUse[].used` | integer | New places exported in this country over the last 30 days. |
| `fairUse[].cap` | integer or null | Fair use limit of this country; null: no limit. |
| `fairUse[].freesAt` | string or null | When room frees up, once the limit is reached (ISO date). |
| `fairUseUnlimited` | boolean | True when the fair use limit was lifted for this account. |
| `rateLimits` | object | — |
| `rateLimits.requestsPerMinute` | integer | — |
| `rateLimits.suggestionsPerHour` | integer | — |
| `rateLimits.searchReadsPerHour` | integer | Catalog reads per hour by count, metrics (5 each), preview and estimate, shared with the web app. |
| `rateLimits.lookupsPerHour` | integer | Area and category lookups per hour, shared with the web app. |

### Suggestion

Turn a target into categories: the entry point of every search (100 credits).

#### POST /categories/suggest

**Turn a target into categories (entry point of every search).** The entry point for ANY target: call it before choosing categories, with the target in plain words ("independent car repair shops") and the area. Our engine picks the Google Maps categories in groups, counts them in the area, and returns a search ready to use. Costs 100 credits (Idempotency-Key header and maxCredits ≥ 100 required), refunded if the engine returns no selection. Send the very same request again with the same Idempotency-Key to get the saved selection without paying twice. A request too vague to choose categories is refused with 422 clarification_needed (refunded): its details carry the question to ask the user and its options; no search is returned until categories are chosen.

**Paid**: `Idempotency-Key` header and `maxCredits` required.

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `request` | string | yes | The target as the user would say it, WITHOUT the place: "independent car repair shops", "restaurants that could use a booking tool". What the client sells works too. (2–600 characters) |
| `country` | country code | no | Country to search (default: that of "search", else fr). |
| `area` | area (from `GET /areas`) | no | Area to search, as returned by GET /areas (field "area"). Default: that of "search", else the whole country. |
| `search` | [search object](#the-search-object) | no | A search to refine: its area, filters, exclusions and groups are kept (the groups become the previous selection). |
| `previous` | object or null | no | The selection to update (default: the groups of "search"); null starts afresh. |
| `maxCredits` | integer | yes | Must be at least 100: the price of a suggestion. (≥ 100) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `selection` | object | — |
| `selection.groups` | object[] | — |
| `selection.groups[].label` | string | — |
| `selection.groups[].kind` | "coeur" \| "voisin" \| "acheteur" | 'coeur': the target itself; 'voisin': close trades kept by prudence; 'acheteur': buyers of what the client sells. |
| `selection.groups[].ids` | integer[] | — |
| `selection.groups[].count` | integer or null | — |
| `selection.groups[].exact` | boolean | — |
| `selection.excluded` | object[] | Categories considered and left out, with the reason. |
| `selection.excluded[].id` | integer | — |
| `selection.excluded[].label` | string | — |
| `selection.excluded[].reason` | string | — |
| `selection.cannotKnow` | string[] | Parts of the request that the data cannot tell (size, revenue…). |
| `selection.message` | string | What was chosen, in one or two sentences (French). |
| `selection.question` | object or null | When two readings change the target: ask the user, then suggest again with the answer in "request". |
| `selection.question.text` | string | — |
| `selection.question.options` | string[] | — |
| `selection.addable` | object[] | Close categories left out, each counted by what it would add to the search. |
| `selection.addable[].label` | string | — |
| `selection.addable[].ids` | integer[] | — |
| `selection.addable[].reason` | string or null | — |
| `selection.addable[].count` | integer or null | — |
| `selection.addable[].exact` | boolean | — |
| `search` | [search object](#the-search-object) | The search ready to use (same shape as the "search" input): send it to /search/* and POST /lists. |
| `count` | object or null | Places of the search (already delivered ones left out by default); null when the count did not answer. |
| `count.count` | integer | — |
| `count.exact` | boolean | False when the catalog answered with an estimate (very large searches): say "≈ N". |
| `zone` | string | Rarely searched area: the count is an estimate (French note). |
| `networksExcluded` | true | "Independent" was read in the request: known brands and networks are filtered out (free filter set in search). |
| `credits` | [credits](#money-paid-calls-and-prices) | — |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.
- `target_unresolved` (400): categories.request is set without any category: call /categories/suggest first.
- `target_excluded` (400): The exclusions remove every category of the target.
- `clarification_needed` (422): The request is too vague: ask the user the question in "details", then suggest again (refunded).
- `invalid_selection` (422): The engine chose no usable category (refunded): describe the target more precisely.

### Lookups

Areas, single categories and brands, to adjust a search. Free.

#### GET /areas

**Find an area (region, département, commune, postal code, radius).** Free. Use the "area" field of a result (or of match when resolved) as search.area or as the area of /categories/suggest.

**Free.**

Query parameters:

| Field | Type | Required | Description |
|---|---|---|---|
| `q` | string | yes | A place as written: "Lyon", "31000", "Gironde", "within 30 km of Nantes", "the South-West". (2–120 characters) |
| `country` | country code | no | Search this country only (ISO alpha-2, lower case). Default: every open country. |
| `level` | "region" \| "departement" \| "commune" | no | Only this level of area. |
| `limit` | integer | no | Areas returned (default 10, at most 20). (1 to 20) |
| `radiusKm` | number | no | A radius around the place, in km (1 to 200). (1 to 200) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `areas` | object[] | — |
| `areas[].level` | "region" \| "departement" \| "commune" | — |
| `areas[].code` | string | — |
| `areas[].name` | string | — |
| `areas[].country` | string | — |
| `areas[].population` | number or null | — |
| `areas[].parentCode` | string or null | — |
| `areas[].area` | area (from `GET /areas`) | Put this in search.area as is. |
| `usual` | object or null | A usual place name ("the South-West", "PACA") covering several areas. |
| `usual.name` | string | — |
| `usual.label` | string | — |
| `usual.country` | string | — |
| `usual.area` | area (from `GET /areas`) | — |
| `match` | object | The text read as a written place: "resolved" gives the area to use; "ambiguous" or "elsewhere" lists candidates to choose from. |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.

#### GET /categories

**Look up a single category by name.** Free. To build a target, call POST /categories/suggest instead; use this only to check or add one category (search.categories.ids).

**Free.**

Query parameters:

| Field | Type | Required | Description |
|---|---|---|---|
| `q` | string | no | Category name, French or English (empty: the most frequent categories). (at most 100 characters) |
| `country` | country code | no | Counts are those of this country (default fr). |
| `limit` | integer | no | Categories returned (default 20, at most 50). (1 to 50) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `categories` | object[] | — |
| `categories[].id` | integer | — |
| `categories[].label` | string | French label, as shown in the web app. |
| `categories[].labelEn` | string | — |
| `categories[].count` | integer or null | Places of this category in the country. |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.

#### GET /networks

**Look up a brand or network.** Free. Use the id in search.filters.networks ({ mode: "only" | "exclude", ids }). Without ids, mode "exclude" removes every known network.

**Free.**

Query parameters:

| Field | Type | Required | Description |
|---|---|---|---|
| `q` | string | yes | Brand or network name ("Century 21", "McDonald's"). (2–60 characters) |
| `limit` | integer | no | Networks returned (default 10, at most 50). (1 to 50) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `networks` | object[] | — |
| `networks[].id` | string | Use in search.filters.networks.ids. |
| `networks[].name` | string | — |
| `networks[].kind` | "franchise" \| "service_point" | — |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".

### Search

Count, figures, sample rows and cost of a search. Free.

#### POST /search/count

**Count the places of a search.** Free. Places already delivered to the account are left out unless search.excludeDelivered is false.

**Free.**

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `search` | [search object](#the-search-object) | yes | The search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed. |

Response 200:

| Field | Type | Description |
|---|---|---|
| `count` | integer | — |
| `exact` | boolean | — |
| `excludeDelivered` | boolean | — |
| `zone` | string | Rarely searched area: the count is an estimate (French note). |
| `hint` | string | — |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.
- `target_unresolved` (400): categories.request is set without any category: call /categories/suggest first.
- `target_excluded` (400): The exclusions remove every category of the target.
- `zone_unavailable` (422): Rarely searched area without any estimate: narrow the target or the area.

#### POST /search/metrics

**Places with email, phone and website.** Free. The figures of the search card in one call, and the estimated number of email addresses.

**Free.**

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `search` | [search object](#the-search-object) | yes | The search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed. |

Response 200:

| Field | Type | Description |
|---|---|---|
| `total` | object or null | — |
| `total.count` | integer | — |
| `total.exact` | boolean | False when the catalog answered with an estimate (very large searches): say "≈ N". |
| `withEmail` | object or null | Places with an email address already found. |
| `withEmail.count` | integer | — |
| `withEmail.exact` | boolean | False when the catalog answered with an estimate (very large searches): say "≈ N". |
| `withPhone` | object or null | — |
| `withPhone.count` | integer | — |
| `withPhone.exact` | boolean | False when the catalog answered with an estimate (very large searches): say "≈ N". |
| `withWebsite` | object or null | — |
| `withWebsite.count` | integer | — |
| `withWebsite.exact` | boolean | False when the catalog answered with an estimate (very large searches): say "≈ N". |
| `emailCandidates` | object or null | Places without an address but with their own website, where one can be guessed and checked. |
| `emailCandidates.count` | integer | — |
| `emailCandidates.exact` | boolean | False when the catalog answered with an estimate (very large searches): say "≈ N". |
| `estimatedEmails` | object or null | "≈ N email addresses": found addresses plus rate × candidates. |
| `estimatedEmails.count` | integer | — |
| `estimatedEmails.exact` | boolean | False when the catalog answered with an estimate (very large searches): say "≈ N". |
| `estimatedEmails.rate` | number | — |
| `excludedDelivered` | integer | Places already delivered to the account, left out of these figures. |
| `zone` | string | — |
| `hint` | string | — |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.
- `target_unresolved` (400): categories.request is set without any category: call /categories/suggest first.
- `target_excluded` (400): The exclusions remove every category of the target.
- `zone_unavailable` (422): Rarely searched area without any estimate: narrow the target or the area.

#### POST /search/preview

**Sample rows of a search, without contact details.** Free. Up to 25 rows; contact details are booleans (they only come with a list).

**Free.**

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `search` | [search object](#the-search-object) | yes | The search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed. |
| `limit` | integer | no | Rows returned (default 10, at most 25). (1 to 25) |
| `offset` | integer | no | Rows to skip (default 0). (0 to 10,000) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `rows` | object[] | — |
| `rows[].id` | number | — |
| `rows[].name` | string | — |
| `rows[].city` | string or null | — |
| `rows[].postalCode` | string or null | — |
| `rows[].category` | string or null | — |
| `rows[].rating` | number or null | — |
| `rows[].reviews` | number or null | — |
| `rows[].website` | boolean | Has a website (the address itself only comes with a list). |
| `rows[].email` | boolean | Has an email address (the address itself only comes with a list). |
| `rows[].phone` | boolean | Has a phone number (the number itself only comes with a list). |
| `limit` | integer | — |
| `offset` | integer | — |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.
- `target_unresolved` (400): categories.request is set without any category: call /categories/suggest first.
- `target_excluded` (400): The exclusions remove every category of the target.
- `zone_unavailable` (422): Rarely searched area without any estimate: narrow the target or the area.

#### POST /search/estimate

**Rows, cost and balance of a list.** Free. Up to 50000 rows. Send "cost" as maxCredits to POST /lists. In a rarely searched area, returns the estimate of the area (kind "zone"): create the list, it fills afterwards.

**Free.**

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `search` | [search object](#the-search-object) | yes | The search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed. |
| `rows` | integer | no | Rows wanted in the list (default 500, at most 50000). (1 to 50,000) |
| `withEmail` | boolean | no | True: a list of places WITH an email address (found addresses first, then guessed and checked ones, each address checked at 1 credit). False: the email filter is removed. Absent: the search as is. |

Response 200:

When `kind` is `"export"`:

| Field | Type | Description |
|---|---|---|
| `kind` | "export" | — |
| `rows` | integer | Rows the list will hold. |
| `available` | integer | Places the search shows. |
| `exact` | boolean | False: cost is the theoretical maximum (one row, one credit). |
| `cost` | integer | Credits at most: send it as maxCredits to POST /lists. |
| `billable` | integer | Places never delivered to the account in the whole search. |
| `credits` | number or null | Balance now. |
| `after` | number or null | — |
| `enough` | boolean or null | — |
| `emailOnly` | boolean | A list "with email": rows are email addresses, cost is a ceiling. |
| `included` | boolean | Agency: new places cost nothing (fair use). |
| `fairUseRoom` | integer | — |
| `verification` | integer | Credits at most reserved to check the addresses found (included in cost). |

When `kind` is `"zone"`:

| Field | Type | Description |
|---|---|---|
| `kind` | "zone" | — |
| `zoneEstimate` | object | — |
| `zoneEstimate.approxTotal` | number | — |
| `zoneEstimate.approxWithWebsite` | number | — |
| `zoneEstimate.approxWithPhone` | number | — |
| `zoneEstimate.approxEmails` | number or null | — |
| `zoneEstimate.creditCap` | number | Credits at most to fill the list with the whole area. |
| `zoneEstimate.delay` | "minutes" \| "hour" \| "hours" | How long the list takes to fill. |
| `zoneEstimate.narrowedFrom` | number or null | — |
| `zoneEstimate.categoryIds` | number[] | — |
| `cached` | boolean | — |
| `credits` | number or null | — |
| `hint` | string | — |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.
- `target_unresolved` (400): categories.request is set without any category: call /categories/suggest first.
- `target_excluded` (400): The exclusions remove every category of the target.
- `zone_unavailable` (422): Rarely searched area without any estimate: narrow the target or the area.
- `fair_use_limit` (403): Agency: the fair use limit of this country is reached.

### Lists

Create, complete, read and download lists.

#### GET /lists

**The lists of the account.** Free. Newest first, page by page (cursor).

**Free.**

Query parameters:

| Field | Type | Required | Description |
|---|---|---|---|
| `cursor` | string | no | nextCursor of the previous page. (at most 200 characters) |
| `limit` | integer | no | (1 to 100, default 50) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `lists` | ([list object](#the-list-object))[] | Newest first. |
| `nextCursor` | string or null | Send it as cursor for the next page; null at the end. |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".

#### POST /lists

**Create a list from a search.** Exports the places of the search into a new list (contact details included), up to 50,000 rows. Price: 1 credit per place never delivered to the account (Agency: included under fair use); withEmail: the export, the guessed addresses and their verification; verifyEmails: plus 1 credit per address verified. maxCredits is never exceeded: send the cost of POST /search/estimate (with the same withEmail), and with verifyEmails at least that cost plus rows. One export at a time per account. In a rarely searched area (estimate kind "zone"), the list fills while the area is updated: send rows or wholeArea: true, and maxCredits ≥ the creditCap of the estimate. A replay with the same Idempotency-Key returns the list as it is now (replayed: true), without charging again.

**Paid**: `Idempotency-Key` header and `maxCredits` required.

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | List name, as shown in "My lists". (1–120 characters) |
| `search` | [search object](#the-search-object) | yes | The search, as returned by /categories/suggest (then narrowed with /areas, filters…). |
| `rows` | integer | no | Rows wanted (at most 50000). With withEmail: email addresses wanted. Required unless wholeArea is true. (1 to 50,000) |
| `withEmail` | boolean | no | true = the "With email" choice of the app: places with an email address only; found addresses are delivered first, others are guessed on the websites then checked (1 credit per valid address), and the verification of the addresses is reserved at creation (1 credit per address, unused credits returned). |
| `verifyEmails` | boolean | no | true = the "Verify emails" box of the app: the addresses this list delivers are verified (deliverable, risky, undeliverable), 1 credit per address, reserved at creation and inside maxCredits (send maxCredits ≥ export cost + rows); unused credits are returned. Up to 10000 rows; not for rarely searched areas; included in withEmail lists. |
| `wholeArea` | boolean | no | Rarely searched area only (estimate returned kind "zone"): fill the list with the whole area instead of a number of rows. |
| `maxCredits` | integer | yes | The most credits this call may spend (required on every paid call, with an Idempotency-Key header). Never exceeded. (0 to 1,000,000,000) |

Response 201:

| Field | Type | Description |
|---|---|---|
| `list` | [list object](#the-list-object) | — |
| `alreadyDelivered` | integer | Places of the search already delivered to the account: in the list, never billed again. Absent on a replay. |
| `verificationReserved` | integer | verifyEmails or withEmail: credits reserved to verify the addresses (unused ones are returned). |
| `fill` | object | Rarely searched area: the list fills while the area is updated. |
| `fill.mode` | "zone" \| "count" | — |
| `fill.capCredits` | integer | Credits set aside at most to fill the list. |
| `replayed` | true | The same request was already made with this Idempotency-Key: the list as it is now, nothing charged by this call. |
| `hint` | string | What to do next. |
| `credits` | [credits](#money-paid-calls-and-prices) | — |

Response 202: The list goes on filling in the background (export over 10,000 rows, guessed addresses of a list "with email", rarely searched area). Follow the list with GET /lists/{id} until status is "ready" (or "failed"). Do not send the request again under a new Idempotency-Key.

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `country_unavailable` (400): The country is not open yet.
- `target_unresolved` (400): categories.request is set without any category: call /categories/suggest first.
- `target_excluded` (400): The exclusions remove every category of the target.
- `max_credits_too_low` (400): maxCredits does not cover the call: the message gives the amount needed. Nothing was charged.
- `verify_emails_unavailable` (400): verifyEmails over 10,000 rows or in a rarely searched area: verify after creation with POST /lists/{id}/email-check.
- `whole_area_unavailable` (400): wholeArea outside a rarely searched area: send rows.
- `empty_search` (422): No place matches the search. Nothing was charged.
- `zone_unavailable` (422): The rarely searched area cannot be estimated or filled as asked.
- `whole_country` (422): This country cannot be filled whole: choose a region or a city.
- `fair_use_limit` (403): Agency: the fair use limit of this country is reached.
- `export_in_progress` (409): One export at a time per account, or places are still being added to this list.
- `included_ended` (409): Exports are no longer included (the Agency subscription ended): estimate again.
- `legacy_delivery_conflict` (409): These places are temporarily unavailable: try another search.
- `too_many_active_fills` (409): Too many lists being filled at once: wait until one is done.
- `catalog_unavailable` (503): The export failed and any credit taken was refunded: retry with the same request and Idempotency-Key.
- `refund_pending` (500): The export failed and its refund is on its way (within 30 minutes): do not retry now.

#### GET /lists/{id}

**Status, progress and contents of a list.** Free. Status, rows, credits spent, progress of the export, of the filling, of the guessed addresses and of the email verification (progress.emailCheck.unverified = what POST /lists/{id}/email-check would cost), contents (rows with a website, an email, a phone, an executive; email verdicts) and the 20 latest jobs. Poll it every 10 seconds or more.

**Free.**

Response 200:

| Field | Type | Description |
|---|---|---|
| `list` | object | — |
| `list.id` | string | — |
| `list.name` | string | — |
| `list.status` | "creating" \| "ready" \| "filling" \| "failed" | creating: the export runs; ready: rows can be read; filling: a rarely searched area is being updated and the list still grows (rows can be read); failed: nothing was billed or it was refunded. |
| `list.rows` | integer | Rows in the list now. |
| `list.requestedRows` | integer or null | — |
| `list.creditsSpent` | integer | Export credits spent on this list (jobs and verification apart). |
| `list.withEmail` | boolean | — |
| `list.source` | string | export, merge, fill… |
| `list.createdAt` | string | — |
| `list.warning` | string or null | What the list says about its rows (places left out and why). |
| `list.error` | string or null | failed lists only. |
| `list.country` | string | — |
| `list.progress` | object | — |
| `list.progress.export` | object or null | A background export or completion still adding rows. |
| `list.progress.export.status` | string | — |
| `list.progress.export.delivered` | integer | — |
| `list.progress.export.requested` | integer | — |
| `list.progress.export.kind` | "create" \| "complete" | — |
| `list.progress.fill` | object or null | Rarely searched area: the order that fills the list. |
| `list.progress.fill.status` | string | — |
| `list.progress.fill.mode` | "zone" \| "count" | — |
| `list.progress.fill.active` | boolean | — |
| `list.progress.fill.capCredits` | integer | — |
| `list.progress.fill.billedCredits` | integer | — |
| `list.progress.fill.reservedCredits` | integer | — |
| `list.progress.fill.included` | boolean | — |
| `list.progress.emailGuess` | object or null | "With email" list: guessing of the missing addresses. |
| `list.progress.emailGuess.status` | string | — |
| `list.progress.emailGuess.targetRows` | integer | — |
| `list.progress.emailGuess.deliveredRows` | integer | — |
| `list.progress.emailGuess.billedCredits` | integer | — |
| `list.progress.emailGuess.stopReason` | string or null | — |
| `list.progress.emailGuess.finishedAt` | string or null | — |
| `list.progress.emailCheck` | object | Verification of the addresses: unverified = what POST /lists/{id}/email-check would cost (1 credit each). |
| `list.progress.emailCheck.mode` | string | — |
| `list.progress.emailCheck.unverified` | integer or null | — |
| `list.progress.emailCheck.running` | object or null | — |
| `list.contents` | object or null | Rows with a website, an email address, a phone, an executive found; verdicts of the email verification. |
| `list.contents.website` | integer | — |
| `list.contents.email` | integer | — |
| `list.contents.phone` | integer | — |
| `list.contents.executive` | integer | — |
| `list.contents.emailVerdicts` | object | — |
| `list.contents.emailVerdicts.deliverable` | integer | — |
| `list.contents.emailVerdicts.risky` | integer | — |
| `list.contents.emailVerdicts.undeliverable` | integer | — |
| `list.contents.emailVerdicts.noResult` | integer | — |
| `list.contents.emailVerdicts.unchecked` | integer | — |
| `list.jobs` | ([job object](#the-job-object))[] | The 20 latest jobs of the list. |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.

#### PATCH /lists/{id}

**Rename a list.** Free.

**Free.**

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | List name, as shown in "My lists". (1–120 characters) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `list` | [list object](#the-list-object) | — |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.
- `invalid_request` (400): A field is missing or invalid: see "details".
- `invalid_name` (400): Empty or too long name.
- `list_creating` (409): The export of the list is still running.

#### DELETE /lists/{id}

**Delete a list.** Free, final, refunds nothing; places stay delivered (never billed again). Ask the user first, then send the list id again as confirm (DELETE /lists/{id}?confirm={id}). Refused while the list is being created or worked on.

**Free.**

Query parameters:

| Field | Type | Required | Description |
|---|---|---|---|
| `confirm` | string | yes | The id of the list again: deleting cannot be undone and refunds nothing. Ask the user first. |

Response 204: empty body.

Refusals of this route (plus the [common ones](#errors)):

- `confirmation_required` (400): The confirm parameter (the list id again) is missing: ask the user, then send it.
- `not_found` (404): Unknown id, or a list, job or file of another account.
- `list_creating` (409): The export of the list is still running.
- `job_running` (409): A job still runs on the list.
- `export_in_progress` (409): One export at a time per account, or places are still being added to this list.
- `email_run_open` (409): Email addresses are still being guessed for the list (progress.emailGuess).
- `fill_in_progress` (409): The list still fills itself (rarely searched area).

#### GET /lists/{id}/items

**The rows of a list, contact details included.** Free. Up to 1,000 rows per page; send nextCursor back as cursor until it is null. Each row: placeId, then one field per column key (text as in the CSV file, French labels and values, numbers as numbers, null when empty); columns lists the keys. email_status holds the verdict of the email verification (délivrable, risqué, indélivrable, sans résultat, en attente, non vérifiée). Readable once the list is "ready" (or "filling").

**Free.**

Query parameters:

| Field | Type | Required | Description |
|---|---|---|---|
| `cursor` | string | no | nextCursor of the previous page. (at most 200 characters) |
| `limit` | integer | no | (1 to 1,000, default 100) |
| `columns` | string | no | Comma-separated column keys; default: every column of the CSV. (at most 2000 characters) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `columns` | object[] | — |
| `columns[].key` | string | — |
| `columns[].label` | string | — |
| `items` | object[] | One object per row: placeId, then one field per column key (text as in the CSV, numbers as numbers, null when empty). |
| `items[].placeId` | integer | — |
| `nextCursor` | string or null | — |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.
- `invalid_request` (400): A field is missing or invalid: see "details".
- `list_not_ready` (409): The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".

#### POST /lists/{id}/complete

**Add the places of the original search not yet in the list.** Priced as an export (1 credit per place never delivered; Agency: included). maxCredits is never exceeded.

**Paid**: `Idempotency-Key` header and `maxCredits` required.

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `rows` | integer | yes | Places to add from the original search (places already in the list are skipped). (1 to 50,000) |
| `maxCredits` | integer | yes | The most credits this call may spend (required on every paid call, with an Idempotency-Key header). Never exceeded. (0 to 1,000,000,000) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `completion` | object | — |
| `completion.added` | integer | Places added by this call. |
| `completion.creditsSpent` | integer | — |
| `completion.rows` | integer | Rows in the list now. |
| `completion.inProgress` | boolean | True: places are still being added in the background. |
| `list` | [list object](#the-list-object) | — |
| `hint` | string | What to do next. |
| `credits` | [credits](#money-paid-calls-and-prices) | — |

Response 202: Places are still being added in the background. Follow the list with GET /lists/{id} until status is "ready" (or "failed").

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.
- `invalid_request` (400): A field is missing or invalid: see "details".
- `not_completable` (409): Only a list created from a search can be completed (not a merged list).
- `list_not_ready` (409): The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".
- `job_running` (409): A job still runs on the list.
- `email_run_open` (409): Email addresses are still being guessed for the list (progress.emailGuess).
- `fill_in_progress` (409): The list still fills itself (rarely searched area).
- `list_full` (409): The list already holds 50,000 places: create a new list.
- `export_in_progress` (409): One export at a time per account, or places are still being added to this list.
- `included_ended` (409): Exports are no longer included (the Agency subscription ended): estimate again.
- `fair_use_limit` (403): Agency: the fair use limit of this country is reached.

#### POST /lists/merge

**Merge lists into a new one.** Merges 2 to 20 ready lists into a new list without duplicates. Nothing is billed again.

**Free**, `Idempotency-Key` header required.

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `listIds` | string[] | yes | 2 to 20 ready lists of the account. Free: nothing is billed again. (at most 20 items) |
| `name` | string | yes | List name, as shown in "My lists". (1–120 characters) |
| `maxCredits` | 0 | no | Free call: 0 when given. |

Response 201:

| Field | Type | Description |
|---|---|---|
| `list` | [list object](#the-list-object) | — |

Refusals of this route (plus the [common ones](#errors)):

- `invalid_request` (400): A field is missing or invalid: see "details".
- `invalid_lists` (400): Send 2 to 20 different list ids.
- `not_found` (404): Unknown id, or a list, job or file of another account.
- `list_not_ready` (409): The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".

#### POST /lists/{id}/files

**Prepare a CSV or Excel file of a list.** A CSV holds every column; an Excel file the columns you choose. Then poll GET /lists/{id}/files/{fileId} until the file is ready. A file stays available 7 days.

**Free**, `Idempotency-Key` header required.

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `format` | "csv" \| "xlsx" | yes | — |
| `columns` | string[] | no | xlsx only: column keys (see GET /lists/{id}/items, "columns"). A CSV always holds every column. (at most 100 items) |
| `maxCredits` | 0 | no | Free call: 0 when given. |

Response 200:

| Field | Type | Description |
|---|---|---|
| `file` | [file object](#the-file-object) | — |
| `expiresInSeconds` | 120 | Ready file: the download link expires after 120 seconds. |
| `hint` | string | What to do next. |

Response 202: The file is being prepared: poll GET /lists/{id}/files/{fileId}.

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.
- `invalid_request` (400): A field is missing or invalid: see "details".
- `list_not_ready` (409): The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".
- `empty_list` (409): The list has no rows.

#### GET /lists/{id}/files/{fileId}

**Status of a file, then its download link.** Free. Once status is "ready", url is a download link valid 120 seconds (call again for a new one). "stale": prepare the file again.

**Free.**

Response 200:

| Field | Type | Description |
|---|---|---|
| `file` | [file object](#the-file-object) | — |
| `expiresInSeconds` | 120 | Ready file: the download link expires after 120 seconds. |
| `hint` | string | What to do next. |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.

### Jobs

Executives, website summary, question to the website, AI filter, email verification.

#### POST /lists/{id}/jobs/estimate

**Places a job would process, and its maxCredits.** Free. Send the same tool, question, criterion and scope as the job; send estimate.maxCredits as maxCredits.

**Free.**

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `tool` | "executives" \| "summary" \| "question" \| "filter" | yes | executives: company executives (France only); summary: 3-sentence summary of the website; question: a yes/no question asked to the website; filter: a criterion judged on the data we hold (no website read). |
| `question` | string | no | tool "question" only. (5–300 characters) |
| `criterion` | string | no | tool "filter" only. (3–300 characters) |
| `scope` | object | no | Places the job runs on: the whole list (default), the places a finished qualification judged conforming, or those it could not decide. |

Response 200:

| Field | Type | Description |
|---|---|---|
| `estimate` | object | — |
| `estimate.tool` | "executives" \| "summary" \| "question" \| "filter" | — |
| `estimate.eligible` | integer | Places the job would process. |
| `estimate.unitCredits` | integer | — |
| `estimate.maxCredits` | integer | Most credits the job can spend (eligible × unitCredits): send it as maxCredits. |
| `estimate.balance` | integer | — |
| `estimate.enough` | boolean | The balance covers at least one place. |
| `estimate.coversAll` | boolean | — |
| `estimate.duration` | string or null | — |
| `estimate.activeJob` | object or null | A job of this tool already running on the list. |
| `estimate.activeJob.id` | string | — |
| `estimate.activeJob.status` | string | — |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.
- `invalid_request` (400): A field is missing or invalid: see "details".
- `not_in_france` (409): Executives exist for places in France only.
- `list_not_ready` (409): The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".
- `job_unavailable` (409): The job cannot run on this list as it is: check it with GET /lists/{id}.

#### POST /lists/{id}/jobs

**Run executives, website summary, question to the website or AI filter.** Runs in the background (202). Nothing is charged at launch: each place is billed on its result (executives: 2 credits per place with at least one executive found, France only; summary, question, filter: 1 credit per row analysed, even when negative or undetermined), never beyond maxCredits nor the estimate. Results land in GET /lists/{id}/items. A replay with the same Idempotency-Key returns the job already started.

**Paid**: `Idempotency-Key` header and `maxCredits` required.

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `tool` | "executives" \| "summary" \| "question" \| "filter" | yes | executives: company executives (France only); summary: 3-sentence summary of the website; question: a yes/no question asked to the website; filter: a criterion judged on the data we hold (no website read). |
| `question` | string | no | tool "question" only. (5–300 characters) |
| `criterion` | string | no | tool "filter" only. (3–300 characters) |
| `scope` | object | no | Places the job runs on: the whole list (default), the places a finished qualification judged conforming, or those it could not decide. |
| `maxCredits` | integer | yes | The most credits this call may spend (required on every paid call, with an Idempotency-Key header). Never exceeded. (0 to 1,000,000,000) |

Response 202:

| Field | Type | Description |
|---|---|---|
| `job` | [job object](#the-job-object) | — |
| `hint` | string | What to do next. |
| `credits` | [credits](#money-paid-calls-and-prices) | — |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.
- `invalid_request` (400): A field is missing or invalid: see "details".
- `not_in_france` (409): Executives exist for places in France only.
- `list_not_ready` (409): The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".
- `job_unavailable` (409): The job cannot run on this list as it is: check it with GET /lists/{id}.
- `max_credits_too_low` (400): maxCredits does not cover the call: the message gives the amount needed. Nothing was charged.
- `invalid_scope` (400): scope is not { kind: "all" }, { kind: "conforming", jobId } or { kind: "undetermined", jobId }.
- `nothing_to_do` (422): No place of the list needs this job. Nothing was charged.
- `too_many_items` (422): More than 50,000 places to process: narrow the scope.
- `scope_unavailable` (409): The job named in scope is not a finished filter or question job of this list.
- `parent_unavailable` (409): The qualification to complete is not finished.
- `too_many_active_jobs` (409): Three jobs already run on the lists of the account: wait until one is done.
- `active_job_exists` (409): A job of this tool already runs on this list: follow it.
- `email_run_open` (409): Email addresses are still being guessed for the list (progress.emailGuess).

#### GET /jobs/{id}

**Progress of a job.** Free. Status, places done and billed, credits spent; for a finished filter or question job, the places per verdict.

**Free.**

Response 200:

| Field | Type | Description |
|---|---|---|
| `job` | [job object](#the-job-object) | — |
| `verdicts` | object | A finished filter or question job: places per verdict. "undetermined" ones can go to a question to the website (scope "undetermined"). |
| `verdicts.conforming` | integer | — |
| `verdicts.notConforming` | integer | — |
| `verdicts.undetermined` | integer | — |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.

#### POST /lists/{id}/email-check

**Verify the email addresses of a list.** 1 credit per address that gets a verdict. maxCredits is the number of addresses you accept to verify: send progress.emailCheck.unverified of GET /lists/{id}; if there are more, the call is refused with the number (nothing charged). The credits are reserved now; those without a verdict are returned when the check ends. Verdicts land in GET /lists/{id}/items (email_status) and in contents.emailVerdicts.

**Paid**: `Idempotency-Key` header and `maxCredits` required.

JSON body:

| Field | Type | Required | Description |
|---|---|---|---|
| `maxCredits` | integer | yes | The most credits this call may spend (required on every paid call, with an Idempotency-Key header). Never exceeded. (0 to 1,000,000,000) |

Response 200:

| Field | Type | Description |
|---|---|---|
| `emailCheck` | object | — |
| `emailCheck.requestId` | string or null | — |
| `emailCheck.reserved` | integer | Credits debited now, one per address to check; unused ones are returned when the check ends. |
| `emailCheck.rows` | integer | — |
| `replayed` | true | The same request was already made with this Idempotency-Key: nothing charged by this call. |
| `hint` | string | What to do next. |
| `credits` | [credits](#money-paid-calls-and-prices) | — |

Refusals of this route (plus the [common ones](#errors)):

- `not_found` (404): Unknown id, or a list, job or file of another account.
- `invalid_request` (400): A field is missing or invalid: see "details".
- `max_credits_too_low` (400): maxCredits does not cover the call: the message gives the amount needed. Nothing was charged.
- `email_check_running` (409): A verification already runs on this list: follow progress.emailCheck.
- `nothing_to_check` (409): Every address of the list is already verified.
- `email_check_included` (409): The addresses of this list are verified automatically, for free.
- `export_in_progress` (409): One export at a time per account, or places are still being added to this list.
- `list_not_ready` (409): The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".

### Objects

The objects that several routes return.

#### The list object

| Field | Type | Description |
|---|---|---|
| `id` | string | — |
| `name` | string | — |
| `status` | "creating" \| "ready" \| "filling" \| "failed" | creating: the export runs; ready: rows can be read; filling: a rarely searched area is being updated and the list still grows (rows can be read); failed: nothing was billed or it was refunded. |
| `rows` | integer | Rows in the list now. |
| `requestedRows` | integer or null | — |
| `creditsSpent` | integer | Export credits spent on this list (jobs and verification apart). |
| `withEmail` | boolean | — |
| `source` | string | export, merge, fill… |
| `createdAt` | string | — |
| `warning` | string or null | What the list says about its rows (places left out and why). |
| `error` | string or null | failed lists only. |

#### The job object

| Field | Type | Description |
|---|---|---|
| `id` | string | — |
| `listId` | string | — |
| `tool` | string | executives, summary, question, filter (export: a past download). |
| `status` | "queued" \| "running" \| "done" \| "failed" \| "cancelled" \| "insufficient_credits" \| string | — |
| `eligible` | integer | — |
| `done` | integer | — |
| `billed` | integer | — |
| `errors` | integer | — |
| `creditsSpent` | integer | — |
| `maxCredits` | integer or null | — |
| `stopReason` | string or null | — |
| `parentJobId` | string or null | — |
| `createdAt` | string | — |
| `finishedAt` | string or null | — |

#### The file object

| Field | Type | Description |
|---|---|---|
| `id` | string | — |
| `format` | "csv" \| "xlsx" | — |
| `status` | "queued" \| "running" \| "ready" \| "failed" \| "stale" | — |
| `rowsDone` | integer | — |
| `rowsTotal` | integer | — |
| `readyAt` | string or null | — |
| `expiresAt` | string or null | — |
| `url` | string or null | GET /lists/{id}/files/{fileId} only: a signed download link valid 120 seconds, when status is ready. |
| `error` | string or null | — |
