# API Bridge reference (tools.epsimo.org)

Derived from https://tools.epsimo.org/llms-full.txt (generated from the live OpenAPI document and cost table). When a detail here and the live file disagree, the live file wins.

## Contents

1. [Basics: auth, credits, limits](#basics)
2. [Errors](#errors)
3. [Caching and provenance](#caching-and-provenance)
4. [qualify](#qualify-post-qualify) (4 credits, 1 cached)
5. [enrich](#enrich-post-enrich) (8 credits, 3 cached)
6. [question](#question-post-question) (3 credits)
7. [scrape](#scrape-post-scrape) (2 credits)
8. [email-verify](#email-verify-post-email-verify) (2 credits)
9. [entreprise](#entreprise-post-entreprise) (1 credit)
10. [entreprise-geo](#entreprise-geo-post-entreprisegeo) (1 credit)
11. [usage](#usage-get-usage) (free) and [health](#health-get-health) (free)
12. [Registry codes and response fields](#registry-codes-and-response-fields)

## Basics

- Base URL: `https://tools.epsimo.org`. JSON in, JSON out.
- Auth: `x-token: $EPSIMO_API_KEY` header on every call (keys look like `ab_live_...`). The MCP endpoint also accepts `Authorization: Bearer`. Never put the key in a URL or query string.
- Free key with 100 free credits per month: https://tools.epsimo.org/#signup. A key must have accepted https://tools.epsimo.org/legal/terms, otherwise metered calls return 403.
- Price: EUR 0.04 per credit. One-time packs: 1,000 credits = 40 EUR, 5,000 = 200 EUR, 10,000 = 400 EUR (Stripe checkout at https://tools.epsimo.org/#pricing). Free credits are spent before purchased ones.
- Only successful calls are billed.
- Monthly quota per UTC month: free plan 10,000 credits, pro 100,000.
- Rate limits: free keys 60 requests/minute, pro keys 300/minute, anonymous 30/minute per IP.

| Function id | Endpoint | Credits | MCP tool |
| --- | --- | --- | --- |
| `qualify` | `POST /qualify` | 4 (1 cached) | `qualify_company` |
| `enrich` | `POST /enrich` | 8 (3 cached) | `enrich_company` |
| `question` | `POST /question` | 3 | `ask_website` |
| `scrape` | `POST /scrape` | 2 | `scrape_page` |
| `email-verify` | `POST /email-verify` | 2 | `find_email` |
| `entreprise` | `POST /entreprise` | 1 | `search_french_companies` |
| `entreprise-geo` | `POST /entreprise/geo` | 1 | `search_companies_near` |
| `usage` | `GET /usage` | free | `get_usage` |
| `health` | `GET /health` | free, no key | none |

## Errors

HTTP errors have the body `{"status": <int>, "error_message": "<text>"}`.

| HTTP | Meaning | What to do |
| --- | --- | --- |
| 402 | `Insufficient credits: route costs N, available M` | Stop. Tell the user the balance and point to https://tools.epsimo.org/#pricing. |
| 403 | `Invalid or missing token`, `Forbidden for this key`, or `Terms of service not accepted` | Stop. Check `EPSIMO_API_KEY` is set and the terms are accepted. |
| 422 | Validation failed (FastAPI `detail` list naming the field) | Fix the field and resend. |
| 429 | `Rate limit exceeded` (`Retry-After: 60`) or `Monthly quota reached` (`Retry-After` = seconds until next UTC month) | Wait `Retry-After`, retry once. For the monthly quota, stop and tell the user. |
| 502 | French registry unreachable (`/entreprise*`), or `Entreprise API error: ...` | Not billed. Retry later. |
| 503 | French registry busy (upstream rate limit), with `Retry-After`; or `Service temporarily unavailable` | Not billed, 0 credits. Wait `Retry-After`, retry once. |

Business failures of `/qualify`, `/question`, `/scrape` and `/enrich` come back as HTTP 200 with a body `status` other than 200 (400 invalid URL, 422 site could not be read, 502 when `/enrich` found nothing). They are not billed. `/email-verify` uses a string `status`: `valid` (billed), `not_found`, `suppressed`, `error` (free).

Unknown request fields are silently ignored: a misspelled filter returns unfiltered results and still costs credits. `scripts/epsimo.py` refuses unknown fields for this reason.

## Caching and provenance

- `/qualify` and the company sections of `/enrich` are cached 7 days per normalised URL. A hit has `cached: true` and costs the cached price. `refresh: true` recomputes at full cost.
- `/entreprise`, `/entreprise/geo` and the registry part of `/enrich` are cached 60 minutes.
- French registry: API Recherche d'Entreprises (DINUM), Licence Ouverte 2.0. Cite it with registry data.
- Qualification, answers and summaries are generated by a language model from website text: estimates, not facts.
- Emails are inferred from published address patterns plus MX/SPF/DMARC checks. Mailboxes are never probed (no SMTP). Opted-out domains and persons are never returned.
- Personal email addresses found in page text are redacted.

## qualify (POST /qualify)

Industry, target audience, market (B2B/B2C) and a one-sentence value proposition from a company website. Use `enrich` instead if you also need the registry record or email domain.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `url` | string | yes | - | Company website URL. |
| `refresh` | boolean | no | `false` | Bypass the 7-day cache (full cost). |

```json
{"url": "https://acme.fr"}
```

Response: `value_proposition`, `industry`, `target_audience`, `market`, `cached`, `status`, `error_message`. Body `status` 422 = site unreadable, 400 = invalid URL (not billed). LLM estimate.

## enrich (POST /enrich)

Registry record, qualification, email domain and, with `first_name` + `last_name`, the likely contact email, in one call. Cheaper to call the single function when you need only one section.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `url` | string | yes | Company website URL. |
| `first_name` | string | no | With `last_name`, enables the `contact` section. |
| `last_name` | string | no | With `first_name`, enables the `contact` section. |
| `siren` | string | no | 9 digits (`^\d{9}$`); exact registry match. |
| `company_name` | string | no | Registry search term (default: derived from the URL). |
| `refresh` | boolean | no | Bypass the 7-day cache (full cost). |

Response sections: `registry` (French registry record, same shape as an `entreprise` result), `qualification`, `email_domain` (`domain`, `provider`, ...), `contact` (`email`, `confidence`, ...), `errors` (why a section is null), `cached`, `source`, `status`. Sections fail independently. Body `status: 502` = nothing found, not billed. Without `siren`, the registry match comes from `company_name` or the domain name: check `registry.nom_complet` matches the site before relying on it.

## question (POST /question)

A short answer grounded in the website text (offer, pricing, customers, positioning, leadership if published).

| Field | Type | Required |
| --- | --- | --- |
| `url` | string | yes |
| `question` | string | yes |

Response: `answer`, `status`, `error_message`. The model only reads the page text: if the answer is not on the page it says so or fails (body `status` 422, not billed).

## scrape (POST /scrape)

Readable main text of one page (direct fetch, headless-browser fallback).

| Field | Type | Required |
| --- | --- | --- |
| `url` | string | yes |

Response: `text`, `length`, `method` (`direct` or browser), `elapsed_s`, `status`. Personal emails redacted. For whole sites as Markdown use the `ai-website-content-markdown-scraper` actor.

## email-verify (POST /email-verify)

Most likely professional address of a named person at a company whose website you know. Pattern-inferred, never probed.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `url` | string | yes | Company website (gives the email domain). |
| `first_name` | string | yes | |
| `last_name` | string | yes | |

```json
{
  "domain_used": "acme.fr",
  "email": "jean.dupont@acme.fr",
  "confidence": "high",
  "pattern": "{f}.{l}",
  "verification": "pattern_inferred",
  "candidates": [{"email": "jean.dupont@acme.fr", "pattern": "{f}.{l}", "score": 0.908}],
  "domain_checks": {"mx": ["mx1.mail.ovh.net"], "provider": "OVHcloud", "spf": true, "dmarc": true, "disposable": false},
  "generic_emails": ["contact@acme.fr"],
  "cached": false,
  "status": "valid"
}
```

`status: "valid"` means a candidate was produced, not that the mailbox exists. Report `confidence` and `verification: pattern_inferred` with the address. `suppressed` = opted out, never retry around it.

## entreprise (POST /entreprise)

Search 10M+ French companies by name, activity, size, headcount, revenue, location or director.

Establishment-level filters (`departement`, `code_postal`, `code_commune`, `region`, `epci`) match any office (établissement), not the head office. A company headquartered in Courbevoie (92) is returned for `departement: "06"` when it has one office in Nice. The offices that matched are in `matching_etablissements`; the HQ is `siege`. Report the matching office as the location.

Main fields:

| Field | Type | Notes |
| --- | --- | --- |
| `q` | string | Free text: company name, address, director or elected official. |
| `activite_principale` | string | NAF/APE code(s), comma-separated, e.g. `69.20Z`. Legal unit only. |
| `section_activite_principale` | string | Section letter(s) A-U (see codes below). |
| `categorie_entreprise` | string | `PME`, `ETI`, `GE`, comma-separated. |
| `tranche_effectif_salarie` | string | Headcount bracket code(s), comma-separated (see codes below). |
| `etat_administratif` | string | `A` active, `C` ceased. |
| `departement` | string | 2-3 chars, comma-separated (`06`, `2A`, `971`). Establishment filter. |
| `code_postal` | string | 5 digits, comma-separated. Establishment filter. |
| `code_commune` | string | INSEE commune code(s). Establishment filter. |
| `region` | string | 2-digit region code(s). Establishment filter. |
| `epci` | string | EPCI SIREN(s). Establishment filter. |
| `nature_juridique` | string | Legal category code(s). |
| `ca_min`, `ca_max` | integer | Revenue in EUR. |
| `resultat_net_min`, `resultat_net_max` | integer | Net income in EUR. |
| `nom_personne`, `prenoms_personne` | string | Director or elected official name. |
| `type_personne` | string | `dirigeant` or `elu`. |
| `date_naissance_personne_min`, `_max` | string | `YYYY-MM-DD`. |
| `page` | integer | Default 1. |
| `per_page` | integer | 1-25, default 10. |
| `limite_matching_etablissements` | integer | 1-100 matched offices per result. |
| `page_etablissements` | integer | Sub-page of `matching_etablissements`. |
| `minimal` | boolean | Lean results; add back with `include`. |
| `include` | string | With `minimal`: `complements,dirigeants,finances,matching_etablissements,siege,score`. |
| `sort_by_size` | boolean | Sort by number of establishments. |

Other accepted fields: `code_collectivite_territoriale`, `id_convention_collective`, `id_finess`, `id_rge`, `id_uai`, and the booleans `convention_collective_renseignee`, `egapro_renseignee`, `est_achats_responsables`, `est_alim_confiance`, `est_association`, `est_bio`, `est_collectivite_territoriale`, `est_entrepreneur_individuel`, `est_entrepreneur_spectacle`, `est_ess`, `est_finess`, `est_organisme_formation`, `est_patrimoine_vivant`, `est_qualiopi`, `est_rge`, `est_siae`, `est_service_public`, `est_l100_3`, `est_societe_mission`, `est_uai`.

```json
{"activite_principale": "69.20Z", "departement": "06", "tranche_effectif_salarie": "11,12,21,22,31,32,41,42,51,52,53", "etat_administratif": "A", "per_page": 25}
```

Response: `results` (list), `total_results`, `page`, `per_page`, `total_pages`, `source`. Each page costs 1 credit: `total_pages` x 1 credit to fetch everything, so compute it before paging through.

## entreprise-geo (POST /entreprise/geo)

French companies with an office within a radius of a GPS point.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `lat` | number | yes | - | Latitude. |
| `long` | number | yes | - | Longitude. |
| `radius` | number | no | 5 | km, 0-50. |
| `activite_principale` | string | no | - | NAF code(s). |
| `section_activite_principale` | string | no | - | Section letter(s). |
| `limite_matching_etablissements`, `minimal`, `include`, `page`, `per_page`, `page_etablissements`, `sort_by_size` | | no | | As in `entreprise`. |

```json
{"lat": 43.7031, "long": 7.2661, "radius": 3, "activite_principale": "69.20Z"}
```

Same response and same HQ caveat as `entreprise`: the office near the point is in `matching_etablissements`, `siege` can be far away.

## usage (GET /usage)

Free. No body. Response: `principal` (key prefix), `month`, `free_balance`, `purchased_balance`, `monthly_quota`, `credits_consumed`, `routes` (requests and credits per route this UTC month). Call it before a batch and after it; do not poll it in a loop (rate limited).

Available credits for a batch = `free_balance + purchased_balance`, also capped by `monthly_quota - credits_consumed`.

## health (GET /health)

Free, no key. `{"status": "healthy", "timestamp": <unix seconds>}`.

## Registry codes and response fields

### `tranche_effectif_salarie` (INSEE headcount brackets)

| Code | Employees | Code | Employees |
| --- | --- | --- | --- |
| `NN` | unknown / non-employer | `31` | 200-249 |
| `00` | 0 | `32` | 250-499 |
| `01` | 1-2 | `41` | 500-999 |
| `02` | 3-5 | `42` | 1,000-1,999 |
| `03` | 6-9 | `51` | 2,000-4,999 |
| `11` | 10-19 | `52` | 5,000-9,999 |
| `12` | 20-49 | `53` | 10,000+ |
| `21` | 50-99 | | |
| `22` | 100-199 | | |

"At least 10 employees" = `11,12,21,22,31,32,41,42,51,52,53`. "At least 50" = `21,22,31,32,41,42,51,52,53`. The bracket is the legal unit's declared headcount (companies with `NN` are excluded by such a filter).

### `categorie_entreprise`

`PME` small and medium (SME), `ETI` mid-size, `GE` large.

### `section_activite_principale`

A Agriculture, B Extractive, C Manufacturing, D Energy, E Water, F Construction, G Commerce, H Transport, I Hospitality, J IT, K Finance, L Real estate, M Scientific and technical (includes accounting, legal, consulting), N Admin support, O Public admin, P Education, Q Health, R Arts, S Other services, T Households, U Extra-territorial.

### Common NAF codes

`69.20Z` accounting (experts-comptables), `69.10Z` legal, `70.22Z` business consulting, `62.01Z` software development, `62.02A` IT consulting, `73.11Z` advertising agencies, `71.11Z` architects, `86.21Z` general practitioners, `56.10A` traditional restaurants, `68.31Z` real estate agencies. Use the exact code with the dot.

### Result fields (`entreprise`, `entreprise-geo`, `enrich.registry`)

| Path | Meaning |
| --- | --- |
| `nom_complet` | Display name (with brand and acronym in parentheses). |
| `siren` | 9-digit company id. Public page: `https://annuaire-entreprises.data.gouv.fr/entreprise/<siren>`. |
| `siege.libelle_commune`, `siege.code_postal`, `siege.adresse` | Head office. |
| `matching_etablissements[]` | Offices that matched the filter: `libelle_commune`, `code_postal`, `adresse`, `siret`, `activite_principale`, `etat_administratif` (`A` active, `F` closed). |
| `activite_principale`, `libelle_activite_principale` | NAF code and label. |
| `categorie_entreprise` | PME / ETI / GE. |
| `tranche_effectif_salarie` | Headcount bracket code (table above). |
| `etat_administratif` | `A` active, `C` ceased. |
| `date_creation` | `YYYY-MM-DD`. |
| `finances.<year>.ca`, `finances.<year>.resultat_net` | Revenue and net income in EUR per fiscal year. Often missing for small companies. A `ca` of `0` next to a non-zero `resultat_net` usually means revenue was not published: show it as "not disclosed" or flag it, not as zero revenue. |
| `dirigeants[]` | `nom`, `prenoms`, `qualite` (role, e.g. `Président de SAS`, `Gérant`, `Directeur Général`), `type_dirigeant` (`personne physique` or `personne morale`; a legal entity uses `denomination` / `siren`). |
