Skip to content
firmus.shdocs

Every page's text, headings and title. Code samples are not indexed.

Resolve

Match up to 500 rows of company data to registry numbers, or say which ones it cannot.

A row carries registry_number (SIREN), establishment_number (SIRET), vat_number, name, postal_code, city, address and an optional ref, and nothing else: a column under any other name, siren for instance, makes its row invalid, so rename your columns before sending. A valid identifier is looked up exactly and the rest of the row is not read. A name is matched, then placed by postal_code, city, or the postal code inside address; any establishment there counts, open or closed, so a company that has moved is still found. A name is matched only when one of its company's establishments is in that postal code or city. Without a location, or only in the right département, it comes back ambiguous with its candidates, however exact; outside any département the company has had an establishment in, not_found. A ceased company is matched, with legal_entity.status set to ceased.

Every row has a confidence between 0 and 1: 1 on an identifier match, the company's on a name match, the best candidate's on an ambiguous row, 0 on not_found and invalid. A name row is matched when its best candidate reaches 0.8 and leads the next company by at least 0.15: candidates at 0.95 and 0.8 are a match on the first, candidates at 0.9 and 0.8 are ambiguous. Otherwise the row is ambiguous, with up to three candidates at 0.5 or above, each with its own confidence, or not_found when none reaches 0.5: below that floor there is no guess.

Those thresholds were measured, in November 2026, on 399 rows sampled from the French registry and made deliberately messy: typos, legal forms in the name, trade names, former names, moved head offices, homonyms, ceased companies, identifiers, and 74 rows that must match nothing. Each name row's expected company was the only one bearing that name at that postal code or city. On that set every matched row was the right company: 204 of 204 name rows, and every identifier row, SIRETs of closed establishments included. Every row that had to be refused was refused. 72.9 % of the name rows naming a real company were matched outright; the rest came back ambiguous or not_found. By kind of row: a name with a postal code or an address, a legal form added to the name, a trade name, a moved head office or a ceased company, 88 % to 97 %; a name other companies bear in other cities, 83 %; a name with one letter wrong, 50 %; a former name, 4 %, because only current names are searched. A name with no location, or placed only in the right département, is not matched even though matching it would raise that rate, so send a postal code or city with every name.

A trade name is matched on the establishment that bears it at the postal code or city given, and establishment_number names that establishment: an open one before a closed one, so a closed establishment is named only when the company has no open one there. This holds for every name, so a row naming a franchisor can match a franchisee whose sign bears that name, when the franchisor has no establishment at the place. A name with a slip in it, a letter dropped, doubled, swapped or wrong, is read as misspelled only when no company's name holds all of its words as written, and never when the name is under six characters. Then, at the postal code or city given, the words the two names do not share are compared: one letter off is allowed when they are under ten characters, two from ten, none when a word is missing on one side, and the name scores 1 − edits / their length — "frures" for "freres" scores 0.83 and can match, "martin" for "martel" is not a slip. Before such a row is matched, the place is checked for another company as near the name: if there is one, the row comes back ambiguous with it among the candidates; if the check runs out of time, ambiguous with partial: true. A one-word name with a slip takes the slowest search, and is the one most often answered partial.

What the measurement leaves out is the known failure: two companies bearing the same name at the same place, because one's legal name is the other's trade name there or the right one only has a closed establishment there. Resolve can match the wrong one at confidence 1.0. Such rows have two right answers, so the set excluded them, and its 100 % is not a promise: a high confidence on a name row does not rule that case out. Nor does it cover a name read as misspelled when another company at the same place is one or two letters from it: the set holds no such row. Send an identifier whenever you hold one, keep matched_on with each answer so you can tell later which rows rest on a name, review ambiguous rows rather than taking the first candidate, and send a partial row again rather than recording its answer.

results holds one entry per row sent, in the order sent; ref is echoed for your own bookkeeping. A row that cannot be read is answered invalid in its place, with a message saying why. The batch is refused with 400 invalid_request only when it is not a batch: not JSON, no rows array, an empty one, or more than 500 rows.

A batch spends at most 10 seconds searching names. A row reached after that is not searched: it comes back not_found with partial: true and a message saying so, and belongs in your next batch. Identifier rows are answered whatever the budget. On a row that was searched, partial: true means part of its search ran out of time, so its not_found or ambiguous is not conclusive. One batch runs at a time per organization, and each server answers one at a time, so even batches sent one after another can meet 429 concurrency_limited: retry after Retry-After seconds. For a file larger than one batch, submit a file job instead: it takes the whole file, is never refused for a running batch, and gives the file back with the matches appended as columns.

A batch is one call, however many rows it holds: on Free, one of the month's 1 000 lookups. Its rows are counted separately, on every plan, against the month's agent rows, resolve and enrich together: 1 000 on Free, 25 000 on Pro, 250 000 on Scale, reset at the start of each calendar month, UTC. Each row looked up — matched, ambiguous or not_found — is one row, and a row repeated in the batch counts each time; invalid rows and rows the budget left unsearched are not counted. The rows your unfinished file jobs were admitted for and have not processed yet count as used. The allowance is checked before the batch runs, so a batch admitted with rows left can take you past it by up to its own size, and the next batch is refused with 402 quota_exceeded; rate limits shows the rows header member to watch. Call it from a server, with a secret key: a publishable fk_pub key is refused, with 403 public_key_forbidden when it holds read. The key travels as an Authorization header with the read scope; authentication covers both.

POST/v1/{country_code}/resolveScope read

Parameters

ParameterInTypeRequiredExample
country_codepathstringYesfr

Request

bash
curl -X POST -H "Authorization: Bearer $FIRMUS_KEY" \
  "https://api.firmus.sh/v1/fr/resolve"

Fields

  • resultsobject[]
    • []objectoptional
      • refstringnullable
      • statusstring
      • confidencenumber (float)
      • matched_onstring[]
        • []stringoptional
      • legal_entityobjectnullable
        • registry_numberstring
        • namestringnullable
        • head_office_citystringnullable
        • activity_codestringnullable
        • statusstring
        • partially_disclosedbooleanoptional
      • establishment_numberstringnullable
      • candidatesobject[]
        • []objectoptional
          • legal_entityobject
            • registry_numberstring
            • namestring
            • head_office_citystring
            • activity_codestring
            • statusstring
            • partially_disclosedbooleanoptional
          • establishment_numberstringnullable
          • confidencenumber (float)
          • matched_onstring[]
            • []stringoptional
      • partialboolean
      • messagestringnullable
  • countsobject
    • matchedinteger
    • ambiguousinteger
    • not_foundinteger
    • invalidinteger

Responses

Identifies the company behind each row of a batch of up to 500. A row carries an identifier (registry_number, establishment_number, vat_number), which is looked up exactly, or a name with an optional postal_code, city and address, which is matched against the search index. Each row is answered in the order sent with a status: `matched` (one company, its confidence at or above the threshold and clear of the runner-up), `ambiguous` (up to three candidates, none of them a safe answer), `not_found`, or `invalid` (the row cannot be read; `message` says why). Below the threshold the answer is never a guess. `matched_on` names the input fields the match rests on. A ceased company is matched and carries `status: ceased`. `partial` is true when part of the search behind a row ran out of time, so a not_found or ambiguous answer is not conclusive and the row can be sent again. A batch searches names for at most 10 seconds; rows reached after that come back unsearched, partial, to be sent again. One batch runs at a time per organization and per server: a second one is answered 429 `concurrency_limited` with Retry-After. Work larger than one batch answers belongs to file jobs, which are not available yet. The batch is one call; each row looked up is one agent row against the plan's monthly allowance, shared with enrich (Free 1 000, Pro 25 000, Scale 250 000), refused with 402 `quota_exceeded` once spent: invalid rows and rows left unsearched are not counted. A publishable (fk_pub) key is refused with 403 `public_key_forbidden`.

{
  "results": [
    {
      "ref": "1",
      "status": "matched",
      "confidence": 1,
      "matched_on": [
        "name",
        "postal_code"
      ],
      "legal_entity": {
        "registry_number": "552032534",
        "name": "DANONE",
        "head_office_city": "PARIS",
        "activity_code": "70.10Z",
        "status": "active",
        "partially_disclosed": false
      },
      "establishment_number": "55203253400703",
      "candidates": [],
      "partial": false,
      "message": null
    },
    {
      "ref": "2",
      "status": "ambiguous",
      "confidence": 1,
      "matched_on": [],
      "legal_entity": null,
      "establishment_number": null,
      "candidates": [
        {
          "legal_entity": {
            "registry_number": "100000017",
            "name": "BOULANGERIE MARTIN",
            "head_office_city": "LYON",
            "activity_code": "10.71C",
            "status": "active",
            "partially_disclosed": false
          },
          "establishment_number": "10000001700015",
          "confidence": 1,
          "matched_on": [
            "name",
            "city"
          ]
        },
        {
          "legal_entity": {
            "registry_number": "100000025",
            "name": "BOULANGERIE MARTIN",
            "head_office_city": "LYON 7EME",
            "activity_code": "10.71C",
            "status": "active",
            "partially_disclosed": false
          },
          "establishment_number": "10000002500018",
          "confidence": 1,
          "matched_on": [
            "name",
            "city"
          ]
        }
      ],
      "partial": false,
      "message": null
    },
    {
      "ref": "3",
      "status": "not_found",
      "confidence": 0,
      "matched_on": [],
      "legal_entity": null,
      "establishment_number": null,
      "candidates": [],
      "partial": false,
      "message": null
    },
    {
      "ref": "4",
      "status": "invalid",
      "confidence": 0,
      "matched_on": [],
      "legal_entity": null,
      "establishment_number": null,
      "candidates": [],
      "partial": false,
      "message": "vat_number is not a valid French VAT number (FR, a 2-character key matching the SIREN, the SIREN)."
    },
    {
      "ref": "5",
      "status": "matched",
      "confidence": 1,
      "matched_on": [
        "establishment_number"
      ],
      "legal_entity": {
        "registry_number": "552032534",
        "name": "DANONE",
        "head_office_city": "PARIS",
        "activity_code": "70.10Z",
        "status": "active",
        "partially_disclosed": false
      },
      "establishment_number": "55203253400711",
      "candidates": [],
      "partial": false,
      "message": null
    }
  ],
  "counts": {
    "matched": 2,
    "ambiguous": 1,
    "not_found": 1,
    "invalid": 1
  }
}