# Certify a supplier file: 200 SIRENs checked, signed, verifiable offline > An agent cleaning a French customer or supplier file before the e-invoicing switchover gets 200 sourced verdicts in one paid call — and a signed batch whose lines the buyer of the file can verify without trusting the agent that produced it. Published 2026-08-14 · Updated 2026-08-15 · HTML version: https://invoket.com/blog/certify-a-supplier-file-before-the-e-invoicing-switchover --- An agent asked to clean a French customer or supplier file before the e-invoicing switchover has two jobs, not one. The first is to check every line against the official registers. The second is harder and usually skipped: to hand the cleaned file to somebody else — a client, an auditor, the finance team downstream — in a form they can **check without trusting the agent that produced it**. [`POST /certify/batch`](/docs/api/certify-batch) does both in one paid call: up to 200 lines keyed by SIREN or SIRET, one sourced verdict each, and an Ed25519-signed Merkle root that makes any single line verifiable offline with a [published script](/certify/verify_certificate.py). Before anything else, the thing that kills this kind of product: a "certified file" **certifies nothing about email deliverability**, nothing about presence in the French e-invoicing directory (AIFE), and it never resolves a company name to a SIREN. It certifies existence, administrative state and gazetted legal events, as of dated snapshots. Everything below is inside that boundary. ## Who is actually under pressure, and when From **1 September 2027**, small and micro enterprises in France must **issue** their invoices electronically. That is the wave worth building for. The larger cohort ahead of it is already tooled up and already has a platform vendor doing this work; the businesses hitting the 2027 date generally do not, and they are the ones sitting on a customer and supplier file that has never been checked against the register. ## The failure mode is a valid SIREN belonging to somebody else A SIREN is nine digits closed by a Luhn check, and the checksum is good at its job: it catches **every** single-digit substitution and every adjacent transposition except `09`↔`90`. Which is precisely the problem. The errors it catches never reach your file. The ones that survive in it are the ones no offline validator can catch, because the result is a **perfectly valid SIREN**: - a value copied from the wrong row, or from the wrong document; - the SIREN of another entity in the same group, or of a head office where the invoice is owed by a subsidiary; - a company that has since been struck off, with its identifier still sitting in your file; - the rare `09`↔`90` swap the checksum lets through. None of those lines will fail. They will succeed, quietly, against a real and often active company that is not the one you meant — and the invoice will go to them. The only way to catch it is to compare the name **you** hold against the name the register **publishes**. That is what the optional `expected_name` field on each line does, and it is why the endpoint takes a name it does not use as a lookup key: there is no search by name here, only a check against one. The comparison [folds typography and not words](/docs/api/certify-batch#what-the-name-comparison-folds) — case, accents and punctuation are ignored, a substituted word or a legal form added or dropped is not — so an apostrophe in your file costs you nothing, while the wrong company still comes back as a `review`. ## What one call returns Each line comes back as a complete, sourced answer built from two blocks: | Block | Question | Source | |------------|-----------------------------------------------------|---------------| | `identity` | Does it exist, and is it still active? | INSEE Sirene | | `events` | Insolvency, deregistration — what has been gazetted? | BODACC | Plus one verdict per line — `ok`, `review`, `stop` or `insufficient_coverage` — from the same decision table as [`GET /preflight/supplier`](/docs/api/preflight-supplier), with every reason attached to the block it came from. The verdict is exactly the maximum severity among the reasons, so you can recompute it from the blocks rather than take it on trust. The profile runs fully offline against local snapshots, which is why 200 lines come back in one synchronous response — **269 ms** measured on 200 heavy subjects, **434–545 ms** on 200 varied real ones. Indicative measurements on the production store, not an SLA. ## A real batch of four lines Captured against production on 2026-08-14, four lines carrying the four outcomes the product has: | `ref` | Submitted | Register publishes | Issue | Verdict | |----------|------------------------------------|--------------------|--------------|----------| | `L-0001` | `395030844` + `SANOFI` | `SANOFI` | `certified` | `ok` | | `L-0002` | `662042449` + `BNP PARIBAS FACTOR` | `BNP PARIBAS` | `certified` | `review` | | `L-0003` | `321875205` | `SAN MARINA` | `certified` | `stop` | | `L-0004` | `000000000` | — | `unresolved` | `null` | `L-0003` is what the file was cleaned for: an insolvency proceeding gazetted against SAN MARINA, returned as a `stop` with the reason attached to the block and the source that published it. `L-0002` is the interesting one. The name on that row, `BNP PARIBAS FACTOR`, is a **different legal entity** from the one that SIREN designates — a real and common shape of error, and one no checksum can see. It also picks up two further reasons: a signalling notice from April 2026 under a year old, and an **undetermined** open-insolvency flag, which counts as a coverage gap rather than a finding. Maximum severity wins, so the verdict is `review`. That is deliberate. A mismatch is never a `stop`: the SIREN exists and may still be the right one — a trade name, a renamed entity, a group parent. The endpoint flags the discrepancy and refuses to decide which company you meant. But it is never a silent `ok` either, because the failure mode being targeted is precisely the one that reads well. ### A recent notice is not automatically a signal `L-0001` returns `ok` **although its timeline carries a sale notice dated 2026-05-22**, among 42 notices in total. That is not an oversight, and it is the detail most likely to be got wrong by anyone building this themselves. Only *signalling* families count — court judgments, deregistrations, notices to creditors. The `sale` family does not, because none of its roughly 1.5 million labels says the **direction** of the operation: the indexed SIREN can be the buyer as easily as the seller. An active company publishes several a year. Treating every recent notice as a risk signal produces a detector of ordinary business activity, and a file where the healthiest counterparties look worst. ## Submitted, not found — and why that is the honest way round The price is counted on the lines you **submit**, before the service knows which of them exist. That is not a rounding convenience; it is the product. *"This SIREN is not in the register"* is precisely the answer being bought, and it is the answer a stale file produces most often. To put a number on how little "well-formed" tells you: we ran 200 randomly generated, Luhn-valid SIRENs through the endpoint. **142 were not in the register at all.** Of the 58 that were, 30 came back `stop` — 28 ceased legal units and 10 carrying a BODACC deregistration notice. Twenty-eight returned `ok`. Random SIRENs are not a customer file, and the proportions in yours will differ; the point is narrower and holds either way — **passing a checksum predicts almost nothing about being real and active**, which is the whole reason a register call is unavoidable. Those lines are served and billed. A line whose key is **malformed** is the opposite case: it rejects the entire batch with a `400` listing every offending line by index and reference, before any work, and **nothing is settled**. Half a certified file would be worse than none. ```json { "code": "INVALID_SUBJECTS", "error": "1/5 subjects have a malformed key: nothing was certified, nothing is billed", "rejected": [ { "code": "INVALID_CHECKSUM", "index": 3, "ref": "row-4", "reason": "siren failed its checksum (Luhn): likely a typo" } ] } ``` The distinction is the one that runs through the whole platform: a request we cannot answer is a `4xx` and is free; an answer you did not want to hear is a `200` and is billed. ## The part that is new: an answer you can pass on Every other paid endpoint gives you an answer you have to be trusted about. If your client asks *"how do I know you actually checked this supplier against Sirene on 14 August?"*, the honest reply has so far been "you can call the API yourself". An x402 settlement does not fix this. It proves **a payment happened** — it says nothing about what came back. The receipt and the content are different claims. This endpoint closes that gap. The service canonicalises each record ([JCS, RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)), hashes it into a leaf, and builds a Merkle root over the batch. The gateway then signs that root with [Ed25519](https://www.rfc-editor.org/rfc/rfc8032) using a key the service never holds, and writes exactly three fields — `alg`, `key_id`, `signature`. It reads one string at one fixed path; it never interprets a business block or reads a verdict. The separation matters twice over. It keeps the microservice zero-secret and the gateway ignorant of the business domain — and, because the signature covers a root rather than a document, it makes a **single line** independently checkable. ## Verifying one line, without the other 199 and without us Your buyer receives one record — not the file — plus the batch attestation, and runs the [published verifier](/certify/verify_certificate.py): Python standard library only, no network, no dependency on Invoket, Ed25519 implemented in the script itself. They install nothing and trust nobody. ```sh curl -s https://api.invoket.com/.well-known/certificate-keys > keys.json python3 verify_certificate.py record.json --key keys.json --proof proof.json ``` It runs five checks, and **the split between them is the whole point**: | # | Check | Proves | |---|-----------------------------------------------------------------|------------| | 1 | The attestation is present and of a known version | coherence | | 2 | The attested scope, canonicalised and hashed, equals the published digest | coherence | | 3 | On a batch: the Merkle tree of the records rebuilds the root | coherence | | 4 | The inclusion path lifts one record alone to the batch root | coherence | | 5 | The **Ed25519 signature** on that root | **origin** | Checks 1 to 4 prove a response is **internally coherent**. Only check 5 proves it **came from us**. Digests are pure computation, so an intermediary can fabricate a wholly coherent file — a plausible verdict, a matching fingerprint, a tree that closes. What it cannot fabricate is the signature. Which is why running without `--key` makes the script announce, on every run, that the signature was *not* checked — and why it will not go and fetch the key itself. A verifier that reaches out to the network is no longer verifiable offline. This works because a record **is** a response — `{ data, provenance }` plus its fingerprint, in the exact shape of the standalone endpoints — so it survives extraction unmodified and the verifier accepts it as is. Three design details make it hold: - **`ref` and the line's rank are inside the signed scope**, alongside the verdict itself. Your own reference is what ties a proof back to your file, and two records of one batch are never interchangeable. - **The service holds no key.** It computes the root; the gateway signs it. So a compromised key cannot forge a past verdict that was never issued, and the root stays reproducible by anyone. - **An odd level promotes its last digest rather than duplicating it.** Duplication is the known malleability of the Bitcoin tree, where two distinct batches can share a root. One word of care: what is produced is a **signed attestation**, not a certificate in the eIDAS sense — no qualified timestamp, no regulated status. The JSON field is called `certificate`; the legal meaning is not implied. ## What it does not certify Beyond the three exclusions at the top, the scope is narrow on purpose, and the response states its own limits in every batch: - **Two blocks, not eight.** The `e-invoicing-fr` profile answers `identity` and `events`. VAT, Peppol reachability, the control chain and sanctions screening are not in it — so an `ok` here is an `ok` **on those two blocks**, and such a proof must never be passed off as a complete counterparty check. That is [`GET /preflight/supplier`](/docs/api/preflight-supplier), one at a time. - **Not an e-invoicing readiness check.** Peppol reachability — a different network and a different directory — is [`GET /company/peppol`](/docs/api/company-peppol). - **An attestation is a dated verdict, not a present truth.** Each record carries the `as_of` of its sources and the attestation its `issued_at`. It does not expire on its own; it says what it was rendered against. - **A verdict is a decision table, not an opinion.** Deterministic, over sourced and dated facts. Not a credit score, not a file grade, not a recommendation to act. - **Each line's timeline is capped at 10 notices**, against 100 on [`GET /company/events`](/docs/api/company-events) — but the flags that drive the verdict come from the **full** history, so no verdict depends on the cap. ## Wiring it up `POST` the file, get a `402` quoting the exact amount for *your* N lines, sign, replay. The [quickstart](/docs/quickstart) has the loop in full; the live [`/catalog`](/docs/for-agents) is the single source of truth for the base and per-unit amounts, and for any active trial. The cap is 200 lines per call — beyond it the gateway returns a `400` **before issuing a challenge**, so an oversized batch never costs anything. Need one line rather than a file? The two blocks are [`GET /company/resolve`](/docs/api/company-resolve) and [`GET /company/events`](/docs/api/company-events); the five-block counterparty view of a single SIREN is [`GET /company/report`](/docs/api/company-report). And where this endpoint covers the *counterparty* of the French reform, the *document* side is [`POST /invoice/validate`](/docs/api/invoice-validate) and [`POST /invoice/generate`](/docs/api/invoice-generate). Full field reference, error codes and the verifier's five checks: [`POST /certify/batch`](/docs/api/certify-batch).