10,000 결과 상한 (Sirene, VAT 키 및 GDPR) 을 초과하는 프랑스의 공식 회사 등록부 수출

작성자

카테고리:

← 피드로
DEV Community · Baron Sigma · 2026-09-27 개발(SW)

France publishes its entire company register as open data. That covers INSEE’s Sirene directory (every company and establishment, with SIREN/SIRET numbers, activity code, headcount band and address) and INPI’s RNE (directors, legal information). Both are released under the Licence Ouverte / Etalab 2.0, which allows free reuse, including commercial use, as long as you credit the source.

The easiest way in is the API Recherche d’entreprises, run by DINUM (the French government’s digital agency). It’s free, it needs no key, and it powers the official directory at annuaire-entreprises.data.gouv.fr. If you want a B2B lead list (“all certified energy-renovation contractors in Gironde”), a CRM clean-up or a market-sizing count, it’s the obvious place to start.

It has a few surprises, though. Here’s what I learned building a bulk exporter on top of it.

How the API works (in 30 seconds)

One endpoint, https://recherche-entreprises.api.gouv.fr/search, with filters as query parameters:

GET /search?activite_principale=62.01Z&departement=35&tranche_effectif_salarie=11&per_page=25&page=1

Enter fullscreen mode Exit fullscreen mode

The most useful filters:

  • activite_principale: NAF/APE activity code (e.g. 62.01Z software, 43.22A plumbing, 56.10A restaurants)
  • section_activite_principale: broad NAF section (F = construction, J = information & communication…)
  • departement, code_postal, region: location of the establishments
  • tranche_effectif_salarie: INSEE headcount band (11 = 10–19, 12 = 20–49…)
  • label flags such as est_rge, est_qualiopi, est_bio, est_ess

Each result is a legal entity (SIREN) with its head office, the establishments that matched your filters, its directors, and a finances object with the latest published revenue (ca) and net income (resultat_net) when the company has filed accounts.

The published rate limit is 7 requests per second, and the administration reserves the right to lower it if the servers are overloaded. Stay under it.

Gotcha #1: the hard 10,000-result cap

per_page maxes out at 25, and page × per_page can’t exceed 10,000. Ask for page 401 and you get an error message saying the total is restricted to 10,000 results. total_results itself also stops at 10,000. So when you see exactly 10,000, read it as “10,000 or more”.

That’s fine for “software companies in Rennes”. It’s not fine for “all restaurants in France” (NAF 56.10A), which is far above the cap.

The fix: split the query recursively until every slice fits

The idea is simple:

  1. Ask for the count (per_page=1).
  2. If it’s under 10,000, paginate normally.
  3. Otherwise add a narrower filter (first département, then headcount band, then postal code) and repeat for each value.
  4. Deduplicate on SIREN at the end.

Deduplication matters because location filters apply to establishments. A company with sites in three départements will show up in three slices.

A stripped-down version in Python:

import time
import requests

API = "https://recherche-entreprises.api.gouv.fr/search"
CAP = 10_000
DEPARTEMENTS = [f"{i:02d}" for i in range(1, 96) if i != 20] + ["2A", "2B"] + 
               [str(i) for i in range(971, 977)]
HEADCOUNT = ["NN", "00", "01", "02", "03", "11", "12", "21", "22",
             "31", "32", "41", "42", "51", "52", "53"]

def get(params):
    time.sleep(0.2)  # stay well under 7 req/s
    r = requests.get(API, params=params, timeout=30)
    r.raise_for_status()
    return r.json()

def count(params):
    return get({**params, "per_page": 1})["total_results"]

def fetch_all(params):
    page, out = 1, []
    while True:
        data = get({**params, "per_page": 25, "page": page})
        out += data["results"]
        if page >= data["total_pages"]:
            return out
        page += 1

def export(params, splits=(("departement", DEPARTEMENTS),
                           ("tranche_effectif_salarie", HEADCOUNT))):
    if count(params) < CAP or not splits:
        # if we run out of split dimensions, we accept a truncated slice
        return fetch_all(params)
    (key, values), rest = splits[0], splits[1:]
    results = []
    for v in values:
        results += export({**params, key: v}, rest)
    return results

rows = export({"activite_principale": "56.10A"})
unique = {r["siren"]: r for r in rows}
print(len(unique), "companies")

Enter fullscreen mode Exit fullscreen mode

In production you’ll want retries with backoff on HTTP 429 and 5xx, a third split level (postal code) for very dense slices, and a log line whenever a slice is still at the cap.

Gotcha #2: the VAT number isn’t in the data, but you can compute it

The French intra-EU VAT number is FR + a 2-digit key + the 9-digit SIREN, where:

key = (12 + 3 × (SIREN mod 97)) mod 97

Enter fullscreen mode Exit fullscreen mode

def vat_fr(siren: str) -> str:
    key = (12 + 3 * (int(siren) % 97)) % 97
    return f"FR{key:02d}{siren}"

vat_fr("512803552")  # 'FR77512803552'

Enter fullscreen mode Exit fullscreen mode

A correctly computed number doesn’t prove the company is actually VAT-registered. If that matters (invoicing, KYC), check it against the EU’s VIES service.

Gotcha #3: revenue: 0 usually means “confidential”, not “zero”

Many small companies file their accounts with a confidentiality option. You’ll then see a revenue of 0, or no finances object at all. Don’t filter on revenue > 0 and think you’ve removed dormant companies. You’ve mostly removed discreet ones. Also note that financial filters only match companies that publish their accounts.

Gotcha #4: public doesn’t mean GDPR-free

Directors’ names are personal data. So are sole traders’ company names, which are usually the person’s own name. The data is published legally, but whoever exports it becomes a data controller. For B2B prospecting in France, the CNIL expects:

  • a purpose related to the person’s professional role,
  • clear information about where you got their details,
  • an easy opt-out that you honour.

Practical defaults I’d recommend:

  • Exclude sole traders when you only need companies.
  • Keep only corporate directors (a holding company that is “Président de SAS” isn’t a person).
  • Never export birth dates, even when the source has them.

Also note that companies which opted out of publication (non-diffusibles) aren’t in the API at all, and emails and phone numbers are not part of the official register.

Gotcha #5 (the good kind): label filters

The label flags are the most underrated part of the API:

  • RGE: contractors certified for energy renovation work
  • Qualiopi: certified training providers
  • Bio: organic operators
  • ESS: social and solidarity economy
  • plus Entreprise du Patrimoine Vivant, société à mission, performing-arts licence holders…

Combine them with a NAF section and a département and you get very targeted lists. When I checked while writing this, est_rge=true&section_activite_principale=F&departement=33 (RGE construction companies in Gironde) returned about a thousand companies, a list a local supplier could actually use.

Don’t want to maintain this? Run it on Apify

If a one-off script is enough, the code above will get you there. If you’d rather not maintain splitting, retries, deduplication and field mapping, I packaged all of it as an Apify Actor: French Company Leads.

It adds human-readable labels (NAF, legal form, headcount, département and region names), the computed VAT number, GPS coordinates, financials, and the GDPR switches described above. It also has an enrichment mode for existing SIREN/SIRET lists.

Example input (RGE-certified construction companies in Gironde, companies only, no natural-person directors):

{
  "mode": "search",
  "nafSections": ["F"],
  "departments": ["33"],
  "certifications": ["rge"],
  "activeOnly": true,
  "soleTraders": "exclude",
  "directorsMode": "legalEntitiesOnly",
  "outputLevel": "company",
  "splitLargeQueries": true,
  "maxResults": 500
}

Enter fullscreen mode Exit fullscreen mode

Enriching a list you already have:

{
  "mode": "enrich",
  "sirenList": ["552081317", "443061841"]
}

Enter fullscreen mode Exit fullscreen mode

Or from Python with the official client:

from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("spherical_distinction/french-company-leads").call(run_input={
    "mode": "search",
    "nafCodes": ["62.01Z"],
    "departments": ["35"],
    "headcountRanges": ["11", "12", "21"],
    "maxResults": 200,
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["siren"], item["name"], item["vatNumber"])

Enter fullscreen mode Exit fullscreen mode

Pricing is per result: $3 per 1,000 companies on the Free and Starter plans, less on higher plans, with no platform usage fees on top. Identifiers not found in enrich mode are free. Apify’s free plan includes monthly credit, which is enough to test it on a real list. Results export to CSV/Excel/JSON or go straight to Google Sheets, Make, Zapier or n8n.

Data source: API Recherche d’entreprises (DINUM), built on INSEE Sirene and INPI RNE, Licence Ouverte 2.0. This project isn’t affiliated with INSEE, INPI or DINUM.

What would you add? I’m especially interested in which filters or fields people working with Sirene data miss most.

원문에서 계속 ↗