API

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

Plain text for AI agents: /docs/api.md · OpenAPI 3.1: /api/v1/openapi.json

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:

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

LimitProAgency
Requests per minute, per account (all keys together)60300
Category suggestions per hour3030
Search reads per hour, shared with the web app of the account2,5002,500
Area and category lookups per hour, shared with the web app3,0003,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:

{
  "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.
CodeHTTPWhen
unauthorized401The API key is missing, malformed, unknown or revoked.
plan_required403The account has no active Pro or Agency subscription (trial, free, cancelled).
account_frozen403The account is on hold while a payment is checked.
rate_limited429Too many requests for this account: wait for Retry-After seconds.
unavailable503Maintenance, or a dependency did not answer: retry later with the same request.
internal_error500Unexpected error: retry once with the same request.
idempotency_key_required400The Idempotency-Key header is missing or is not a UUID.
max_credits_required400maxCredits is missing from the JSON body, or is not an integer ≥ 0.
idempotency_conflict409This Idempotency-Key was used for another request, or the same request is still running.
key_budget_exceeded403maxCredits would exceed what the monthly cap of this key still allows.
insufficient_credits402The balance does not cover the call.
invalid_request400A field is missing or invalid: see "details".
country_unavailable400The country is not open yet.
target_unresolved400categories.request is set without any category: call /categories/suggest first.
target_excluded400The exclusions remove every category of the target.
clarification_needed422The request is too vague: ask the user the question in "details", then suggest again (refunded).
invalid_selection422The engine chose no usable category (refunded): describe the target more precisely.
zone_unavailable422Rarely searched area without any estimate: narrow the target or the area.
fair_use_limit403Agency: the fair use limit of this country is reached.
max_credits_too_low400maxCredits does not cover the call: the message gives the amount needed. Nothing was charged.
verify_emails_unavailable400verifyEmails over 10,000 rows or in a rarely searched area: verify after creation with POST /lists/{id}/email-check.
whole_area_unavailable400wholeArea outside a rarely searched area: send rows.
empty_search422No place matches the search. Nothing was charged.
whole_country422This country cannot be filled whole: choose a region or a city.
export_in_progress409One export at a time per account, or places are still being added to this list.
included_ended409Exports are no longer included (the Agency subscription ended): estimate again.
legacy_delivery_conflict409These places are temporarily unavailable: try another search.
too_many_active_fills409Too many lists being filled at once: wait until one is done.
catalog_unavailable503The export failed and any credit taken was refunded: retry with the same request and Idempotency-Key.
refund_pending500The export failed and its refund is on its way (within 30 minutes): do not retry now.
not_found404Unknown id, or a list, job or file of another account.
invalid_name400Empty or too long name.
list_creating409The export of the list is still running.
confirmation_required400The confirm parameter (the list id again) is missing: ask the user, then send it.
job_running409A job still runs on the list.
email_run_open409Email addresses are still being guessed for the list (progress.emailGuess).
fill_in_progress409The list still fills itself (rarely searched area).
list_not_ready409The list is not ready (export running or failed): wait until GET /lists/{id} says "ready".
not_completable409Only a list created from a search can be completed (not a merged list).
list_full409The list already holds 50,000 places: create a new list.
invalid_lists400Send 2 to 20 different list ids.
empty_list409The list has no rows.
not_in_france409Executives exist for places in France only.
job_unavailable409The job cannot run on this list as it is: check it with GET /lists/{id}.
invalid_scope400scope is not { kind: "all" }, { kind: "conforming", jobId } or { kind: "undetermined", jobId }.
nothing_to_do422No place of the list needs this job. Nothing was charged.
too_many_items422More than 50,000 places to process: narrow the scope.
scope_unavailable409The job named in scope is not a finished filter or question job of this list.
parent_unavailable409The qualification to complete is not finished.
too_many_active_jobs409Three jobs already run on the lists of the account: wait until one is done.
active_job_exists409A job of this tool already runs on this list: follow it.
email_check_running409A verification already runs on this list: follow progress.emailCheck.
nothing_to_check409Every address of the list is already verified.
email_check_included409The 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

WhatPrice
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, estimateFree.
List export1 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 filter1 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, deletingFree.

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.

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.

{
  "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
}
FieldMeaning
countryISO code, lower case (default fr). One country per search. A closed country: country_unavailable.
categories.requestThe target as written: title of the search and default list name.
categories.groupsGroups of category ids chosen by the engine (at most 8). Remove a group to narrow the target.
categories.idsSingle categories added by hand (ids from GET /categories).
categories.modeprimary_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).
excludedCategoryIdsCategories removed from the target.
areaTake it from GET /areas (field area): a region, département, commune, postal code or a radius. Absent: the whole country.
filtersSee the table below.
sortdefault, reviews_desc, rating_desc, name_asc: order of previews and lists.
excludeDeliveredDefault 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

FilterTypeMeaning
hasWebsitebooleantrue: places with a website; false: without.
websiteReachablebooleantrue: the website answered when last read.
hasPhonebooleantrue: places with a phone number.
emailStatusstring[]Places with an email address: send ["deliverable","catch_all","unverified"] (all three). Prefer withEmail on /search/estimate and POST /lists.
ownEmailOnlybooleanOnly 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 / ratingMaxnumber 1–5Google rating bounds.
reviewsMin / reviewsMaxintegerNumber of Google reviews bounds.
includeClosedbooleanInclude permanently closed places (default false).
socialsstring[]Every listed social network is linked from the website: facebook, instagram, linkedin, youtube, twitter, tiktok, pinterest, threads.
technologiesstring[]At least one of these website technologies (WordPress, Wix, Shopify…).
pixelsstring[]At least one of these advertising pixels (Meta Pixel, Google Ads…).
hasContactFormbooleanThe website has a contact form.
nameContainsstringPart of the name (accents and case ignored), 2 to 100 characters.
hotelStarsMininteger 1–5Hotels: minimum stars.
hideHiddenCategoriesbooleanHide categories nobody prospects (bus stops, car parks…). Default true.
networksobject{ "mode": "exclude" | "only", "ids"?: [...] }: brands and networks (ids from GET /networks). Without ids, "exclude" removes every confirmed network ("independent" businesses).
excludeServicePointsbooleanRemove service points (parcel lockers, ATMs, charging stations): nobody to talk to. Default true.
nafstring[]France only: NAF rev. 2 activity codes of any level ("56", "56.10A"). Keeps places whose company is known in the French registry.
headcountMin / headcountMaxintegerFrance only: company headcount bounds (INSEE brackets fully inside the bounds).
legalFormsstring[]France only: INSEE legal category codes ("5", "57", "5710").
companyAgeMin / companyAgeMaxintegerFrance only: company created at least / less than N years ago.
companyCategoriesstring[]France only: "PME", "ETI", "GE" (INSEE company size category).
onePerobjectFrance 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

curl -s https://primaverapp.com/api/v1/account \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY"
{
  "plan": "starter",
  "planName": "Pro",
  "credits": { "balance": 9400 },
  "exportsIncluded": false,
  "rateLimits": { "requestsPerMinute": 60, "suggestionsPerHour": 30, "searchReadsPerHour": 2500, "lookupsPerHour": 3000 }
}

2. Find the area

curl -s -G https://primaverapp.com/api/v1/areas \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  --data-urlencode "q=Lyon"
{
  "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)

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
{
  "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:

{
  "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)

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}')"
{ "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 }
{ "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)

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}')"
{ "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)

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}')"
{
  "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

curl -s https://primaverapp.com/api/v1/lists/$LIST_ID \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY"
{
  "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)

curl -s -G https://primaverapp.com/api/v1/lists/$LIST_ID/items \
  -H "Authorization: Bearer $PRIMAVERA_API_KEY" \
  --data-urlencode "limit=1000"
{ "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)

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)

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:

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 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:

FieldTypeDescription
plan"starter" | "pro" | "agency"'starter' (Pro), 'pro' (legacy Pro 25,000) or 'agency'.
planNamestring—
creditsobject—
credits.balancenumber or nullCredits available now.
exportsIncludedbooleanAgency: new places are exported without credits, under the fair use limit of each country.
fairUseobject[]Countries where the account received places in the last 30 days (Agency only; empty otherwise).
fairUse[].countrystring—
fairUse[].usedintegerNew places exported in this country over the last 30 days.
fairUse[].capinteger or nullFair use limit of this country; null: no limit.
fairUse[].freesAtstring or nullWhen room frees up, once the limit is reached (ISO date).
fairUseUnlimitedbooleanTrue when the fair use limit was lifted for this account.
rateLimitsobject—
rateLimits.requestsPerMinuteinteger—
rateLimits.suggestionsPerHourinteger—
rateLimits.searchReadsPerHourintegerCatalog reads per hour by count, metrics (5 each), preview and estimate, shared with the web app.
rateLimits.lookupsPerHourintegerArea 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:

FieldTypeRequiredDescription
requeststringyesThe 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)
countrycountry codenoCountry to search (default: that of "search", else fr).
areaarea (from GET /areas)noArea to search, as returned by GET /areas (field "area"). Default: that of "search", else the whole country.
searchsearch objectnoA search to refine: its area, filters, exclusions and groups are kept (the groups become the previous selection).
previousobject or nullnoThe selection to update (default: the groups of "search"); null starts afresh.
maxCreditsintegeryesMust be at least 100: the price of a suggestion. (≥ 100)

Response 200:

FieldTypeDescription
selectionobject—
selection.groupsobject[]—
selection.groups[].labelstring—
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[].idsinteger[]—
selection.groups[].countinteger or null—
selection.groups[].exactboolean—
selection.excludedobject[]Categories considered and left out, with the reason.
selection.excluded[].idinteger—
selection.excluded[].labelstring—
selection.excluded[].reasonstring—
selection.cannotKnowstring[]Parts of the request that the data cannot tell (size, revenue…).
selection.messagestringWhat was chosen, in one or two sentences (French).
selection.questionobject or nullWhen two readings change the target: ask the user, then suggest again with the answer in "request".
selection.question.textstring—
selection.question.optionsstring[]—
selection.addableobject[]Close categories left out, each counted by what it would add to the search.
selection.addable[].labelstring—
selection.addable[].idsinteger[]—
selection.addable[].reasonstring or null—
selection.addable[].countinteger or null—
selection.addable[].exactboolean—
searchsearch objectThe search ready to use (same shape as the "search" input): send it to /search/* and POST /lists.
countobject or nullPlaces of the search (already delivered ones left out by default); null when the count did not answer.
count.countinteger—
count.exactbooleanFalse when the catalog answered with an estimate (very large searches): say "≈ N".
zonestringRarely searched area: the count is an estimate (French note).
networksExcludedtrue"Independent" was read in the request: known brands and networks are filtered out (free filter set in search).
creditscredits—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
qstringyesA place as written: "Lyon", "31000", "Gironde", "within 30 km of Nantes", "the South-West". (2–120 characters)
countrycountry codenoSearch this country only (ISO alpha-2, lower case). Default: every open country.
level"region" | "departement" | "commune"noOnly this level of area.
limitintegernoAreas returned (default 10, at most 20). (1 to 20)
radiusKmnumbernoA radius around the place, in km (1 to 200). (1 to 200)

Response 200:

FieldTypeDescription
areasobject[]—
areas[].level"region" | "departement" | "commune"—
areas[].codestring—
areas[].namestring—
areas[].countrystring—
areas[].populationnumber or null—
areas[].parentCodestring or null—
areas[].areaarea (from GET /areas)Put this in search.area as is.
usualobject or nullA usual place name ("the South-West", "PACA") covering several areas.
usual.namestring—
usual.labelstring—
usual.countrystring—
usual.areaarea (from GET /areas)—
matchobjectThe 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):

  • 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:

FieldTypeRequiredDescription
qstringnoCategory name, French or English (empty: the most frequent categories). (at most 100 characters)
countrycountry codenoCounts are those of this country (default fr).
limitintegernoCategories returned (default 20, at most 50). (1 to 50)

Response 200:

FieldTypeDescription
categoriesobject[]—
categories[].idinteger—
categories[].labelstringFrench label, as shown in the web app.
categories[].labelEnstring—
categories[].countinteger or nullPlaces of this category in the country.

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
qstringyesBrand or network name ("Century 21", "McDonald's"). (2–60 characters)
limitintegernoNetworks returned (default 10, at most 50). (1 to 50)

Response 200:

FieldTypeDescription
networksobject[]—
networks[].idstringUse in search.filters.networks.ids.
networks[].namestring—
networks[].kind"franchise" | "service_point"—

Refusals of this route (plus the common ones):

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

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:

FieldTypeRequiredDescription
searchsearch objectyesThe search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed.

Response 200:

FieldTypeDescription
countinteger—
exactboolean—
excludeDeliveredboolean—
zonestringRarely searched area: the count is an estimate (French note).
hintstring—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
searchsearch objectyesThe search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed.

Response 200:

FieldTypeDescription
totalobject or null—
total.countinteger—
total.exactbooleanFalse when the catalog answered with an estimate (very large searches): say "≈ N".
withEmailobject or nullPlaces with an email address already found.
withEmail.countinteger—
withEmail.exactbooleanFalse when the catalog answered with an estimate (very large searches): say "≈ N".
withPhoneobject or null—
withPhone.countinteger—
withPhone.exactbooleanFalse when the catalog answered with an estimate (very large searches): say "≈ N".
withWebsiteobject or null—
withWebsite.countinteger—
withWebsite.exactbooleanFalse when the catalog answered with an estimate (very large searches): say "≈ N".
emailCandidatesobject or nullPlaces without an address but with their own website, where one can be guessed and checked.
emailCandidates.countinteger—
emailCandidates.exactbooleanFalse when the catalog answered with an estimate (very large searches): say "≈ N".
estimatedEmailsobject or null"≈ N email addresses": found addresses plus rate × candidates.
estimatedEmails.countinteger—
estimatedEmails.exactbooleanFalse when the catalog answered with an estimate (very large searches): say "≈ N".
estimatedEmails.ratenumber—
excludedDeliveredintegerPlaces already delivered to the account, left out of these figures.
zonestring—
hintstring—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
searchsearch objectyesThe search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed.
limitintegernoRows returned (default 10, at most 25). (1 to 25)
offsetintegernoRows to skip (default 0). (0 to 10,000)

Response 200:

FieldTypeDescription
rowsobject[]—
rows[].idnumber—
rows[].namestring—
rows[].citystring or null—
rows[].postalCodestring or null—
rows[].categorystring or null—
rows[].ratingnumber or null—
rows[].reviewsnumber or null—
rows[].websitebooleanHas a website (the address itself only comes with a list).
rows[].emailbooleanHas an email address (the address itself only comes with a list).
rows[].phonebooleanHas a phone number (the number itself only comes with a list).
limitinteger—
offsetinteger—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
searchsearch objectyesThe search, as returned by POST /categories/suggest (field "search"). Send it back as is, edited if needed.
rowsintegernoRows wanted in the list (default 500, at most 50000). (1 to 50,000)
withEmailbooleannoTrue: 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":

FieldTypeDescription
kind"export"—
rowsintegerRows the list will hold.
availableintegerPlaces the search shows.
exactbooleanFalse: cost is the theoretical maximum (one row, one credit).
costintegerCredits at most: send it as maxCredits to POST /lists.
billableintegerPlaces never delivered to the account in the whole search.
creditsnumber or nullBalance now.
afternumber or null—
enoughboolean or null—
emailOnlybooleanA list "with email": rows are email addresses, cost is a ceiling.
includedbooleanAgency: new places cost nothing (fair use).
fairUseRoominteger—
verificationintegerCredits at most reserved to check the addresses found (included in cost).

When kind is "zone":

FieldTypeDescription
kind"zone"—
zoneEstimateobject—
zoneEstimate.approxTotalnumber—
zoneEstimate.approxWithWebsitenumber—
zoneEstimate.approxWithPhonenumber—
zoneEstimate.approxEmailsnumber or null—
zoneEstimate.creditCapnumberCredits at most to fill the list with the whole area.
zoneEstimate.delay"minutes" | "hour" | "hours"How long the list takes to fill.
zoneEstimate.narrowedFromnumber or null—
zoneEstimate.categoryIdsnumber[]—
cachedboolean—
creditsnumber or null—
hintstring—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
cursorstringnonextCursor of the previous page. (at most 200 characters)
limitintegerno(1 to 100, default 50)

Response 200:

FieldTypeDescription
lists(list object)[]Newest first.
nextCursorstring or nullSend it as cursor for the next page; null at the end.

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
namestringyesList name, as shown in "My lists". (1–120 characters)
searchsearch objectyesThe search, as returned by /categories/suggest (then narrowed with /areas, filters…).
rowsintegernoRows wanted (at most 50000). With withEmail: email addresses wanted. Required unless wholeArea is true. (1 to 50,000)
withEmailbooleannotrue = 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).
verifyEmailsbooleannotrue = 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.
wholeAreabooleannoRarely searched area only (estimate returned kind "zone"): fill the list with the whole area instead of a number of rows.
maxCreditsintegeryesThe 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:

FieldTypeDescription
listlist object—
alreadyDeliveredintegerPlaces of the search already delivered to the account: in the list, never billed again. Absent on a replay.
verificationReservedintegerverifyEmails or withEmail: credits reserved to verify the addresses (unused ones are returned).
fillobjectRarely searched area: the list fills while the area is updated.
fill.mode"zone" | "count"—
fill.capCreditsintegerCredits set aside at most to fill the list.
replayedtrueThe same request was already made with this Idempotency-Key: the list as it is now, nothing charged by this call.
hintstringWhat to do next.
creditscredits—

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):

  • 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:

FieldTypeDescription
listobject—
list.idstring—
list.namestring—
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.rowsintegerRows in the list now.
list.requestedRowsinteger or null—
list.creditsSpentintegerExport credits spent on this list (jobs and verification apart).
list.withEmailboolean—
list.sourcestringexport, merge, fill…
list.createdAtstring—
list.warningstring or nullWhat the list says about its rows (places left out and why).
list.errorstring or nullfailed lists only.
list.countrystring—
list.progressobject—
list.progress.exportobject or nullA background export or completion still adding rows.
list.progress.export.statusstring—
list.progress.export.deliveredinteger—
list.progress.export.requestedinteger—
list.progress.export.kind"create" | "complete"—
list.progress.fillobject or nullRarely searched area: the order that fills the list.
list.progress.fill.statusstring—
list.progress.fill.mode"zone" | "count"—
list.progress.fill.activeboolean—
list.progress.fill.capCreditsinteger—
list.progress.fill.billedCreditsinteger—
list.progress.fill.reservedCreditsinteger—
list.progress.fill.includedboolean—
list.progress.emailGuessobject or null"With email" list: guessing of the missing addresses.
list.progress.emailGuess.statusstring—
list.progress.emailGuess.targetRowsinteger—
list.progress.emailGuess.deliveredRowsinteger—
list.progress.emailGuess.billedCreditsinteger—
list.progress.emailGuess.stopReasonstring or null—
list.progress.emailGuess.finishedAtstring or null—
list.progress.emailCheckobjectVerification of the addresses: unverified = what POST /lists/{id}/email-check would cost (1 credit each).
list.progress.emailCheck.modestring—
list.progress.emailCheck.unverifiedinteger or null—
list.progress.emailCheck.runningobject or null—
list.contentsobject or nullRows with a website, an email address, a phone, an executive found; verdicts of the email verification.
list.contents.websiteinteger—
list.contents.emailinteger—
list.contents.phoneinteger—
list.contents.executiveinteger—
list.contents.emailVerdictsobject—
list.contents.emailVerdicts.deliverableinteger—
list.contents.emailVerdicts.riskyinteger—
list.contents.emailVerdicts.undeliverableinteger—
list.contents.emailVerdicts.noResultinteger—
list.contents.emailVerdicts.uncheckedinteger—
list.jobs(job object)[]The 20 latest jobs of the list.

Refusals of this route (plus the common ones):

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

PATCH /lists/{id}

Rename a list. Free.

Free.

JSON body:

FieldTypeRequiredDescription
namestringyesList name, as shown in "My lists". (1–120 characters)

Response 200:

FieldTypeDescription
listlist object—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
confirmstringyesThe 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):

  • 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:

FieldTypeRequiredDescription
cursorstringnonextCursor of the previous page. (at most 200 characters)
limitintegerno(1 to 1,000, default 100)
columnsstringnoComma-separated column keys; default: every column of the CSV. (at most 2000 characters)

Response 200:

FieldTypeDescription
columnsobject[]—
columns[].keystring—
columns[].labelstring—
itemsobject[]One object per row: placeId, then one field per column key (text as in the CSV, numbers as numbers, null when empty).
items[].placeIdinteger—
nextCursorstring or null—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
rowsintegeryesPlaces to add from the original search (places already in the list are skipped). (1 to 50,000)
maxCreditsintegeryesThe 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:

FieldTypeDescription
completionobject—
completion.addedintegerPlaces added by this call.
completion.creditsSpentinteger—
completion.rowsintegerRows in the list now.
completion.inProgressbooleanTrue: places are still being added in the background.
listlist object—
hintstringWhat to do next.
creditscredits—

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):

  • 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:

FieldTypeRequiredDescription
listIdsstring[]yes2 to 20 ready lists of the account. Free: nothing is billed again. (at most 20 items)
namestringyesList name, as shown in "My lists". (1–120 characters)
maxCredits0noFree call: 0 when given.

Response 201:

FieldTypeDescription
listlist object—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
format"csv" | "xlsx"yes—
columnsstring[]noxlsx only: column keys (see GET /lists/{id}/items, "columns"). A CSV always holds every column. (at most 100 items)
maxCredits0noFree call: 0 when given.

Response 200:

FieldTypeDescription
filefile object—
expiresInSeconds120Ready file: the download link expires after 120 seconds.
hintstringWhat to do next.

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

Refusals of this route (plus the common ones):

  • 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:

FieldTypeDescription
filefile object—
expiresInSeconds120Ready file: the download link expires after 120 seconds.
hintstringWhat to do next.

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
tool"executives" | "summary" | "question" | "filter"yesexecutives: 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).
questionstringnotool "question" only. (5–300 characters)
criterionstringnotool "filter" only. (3–300 characters)
scopeobjectnoPlaces the job runs on: the whole list (default), the places a finished qualification judged conforming, or those it could not decide.

Response 200:

FieldTypeDescription
estimateobject—
estimate.tool"executives" | "summary" | "question" | "filter"—
estimate.eligibleintegerPlaces the job would process.
estimate.unitCreditsinteger—
estimate.maxCreditsintegerMost credits the job can spend (eligible × unitCredits): send it as maxCredits.
estimate.balanceinteger—
estimate.enoughbooleanThe balance covers at least one place.
estimate.coversAllboolean—
estimate.durationstring or null—
estimate.activeJobobject or nullA job of this tool already running on the list.
estimate.activeJob.idstring—
estimate.activeJob.statusstring—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
tool"executives" | "summary" | "question" | "filter"yesexecutives: 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).
questionstringnotool "question" only. (5–300 characters)
criterionstringnotool "filter" only. (3–300 characters)
scopeobjectnoPlaces the job runs on: the whole list (default), the places a finished qualification judged conforming, or those it could not decide.
maxCreditsintegeryesThe 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:

FieldTypeDescription
jobjob object—
hintstringWhat to do next.
creditscredits—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeDescription
jobjob object—
verdictsobjectA finished filter or question job: places per verdict. "undetermined" ones can go to a question to the website (scope "undetermined").
verdicts.conforminginteger—
verdicts.notConforminginteger—
verdicts.undeterminedinteger—

Refusals of this route (plus the common ones):

  • 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:

FieldTypeRequiredDescription
maxCreditsintegeryesThe 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:

FieldTypeDescription
emailCheckobject—
emailCheck.requestIdstring or null—
emailCheck.reservedintegerCredits debited now, one per address to check; unused ones are returned when the check ends.
emailCheck.rowsinteger—
replayedtrueThe same request was already made with this Idempotency-Key: nothing charged by this call.
hintstringWhat to do next.
creditscredits—

Refusals of this route (plus the common ones):

  • 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

FieldTypeDescription
idstring—
namestring—
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.
rowsintegerRows in the list now.
requestedRowsinteger or null—
creditsSpentintegerExport credits spent on this list (jobs and verification apart).
withEmailboolean—
sourcestringexport, merge, fill…
createdAtstring—
warningstring or nullWhat the list says about its rows (places left out and why).
errorstring or nullfailed lists only.

The job object

FieldTypeDescription
idstring—
listIdstring—
toolstringexecutives, summary, question, filter (export: a past download).
status"queued" | "running" | "done" | "failed" | "cancelled" | "insufficient_credits" | string—
eligibleinteger—
doneinteger—
billedinteger—
errorsinteger—
creditsSpentinteger—
maxCreditsinteger or null—
stopReasonstring or null—
parentJobIdstring or null—
createdAtstring—
finishedAtstring or null—

The file object

FieldTypeDescription
idstring—
format"csv" | "xlsx"—
status"queued" | "running" | "ready" | "failed" | "stale"—
rowsDoneinteger—
rowsTotalinteger—
readyAtstring or null—
expiresAtstring or null—
urlstring or nullGET /lists/{id}/files/{fileId} only: a signed download link valid 120 seconds, when status is ready.
errorstring or null—