Skip to content
firmus.shdocs

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

Submit a job

Resolve, enrich, or both, a whole CSV file in the background, and get it back with Firmus's columns appended.

A job takes a whole file — a CRM export, a spreadsheet — and answers every row of it in the background, with the same matching as resolve and the same fields as enrich. What comes back is your file, every line and column kept, with Firmus's columns appended. Use a job for a file larger than one 500-row batch, or when you want a CSV back rather than JSON; use /resolve and /enrich when the answer has to arrive inside the request. A job does not hold the batch slot: it is never refused with 429 concurrency_limited, and your own /resolve and /enrich calls are not refused because it is running.

operation is resolve, enrich (for rows that already carry a SIREN) or resolve_then_enrich. Served for fr only; any other country is a 404.

Submit a file

Keep the key out of your shell history:

read -rs FIRMUS_KEY && export FIRMUS_KEY

A semicolon-separated export with French headers, the way Excel writes one in a French locale, sent as a multipart file:

cat > crm_export.csv <<'EOF'
Société;Code postal;Ville;SIREN;Notes
Danone;75009;Paris;;customer
;;;855200887;prospect
Boulangerie Martin;;Lyon;;"duplicate; café owner"
Nobody Corp;13001;Marseille;;
;;;123456789;bad siren
EOF

JOB_URL=$(curl -s -o job.json -w '%header{location}' \
  -H "Authorization: Bearer $FIRMUS_KEY" \
  -F operation=resolve_then_enrich \
  -F file=@crm_export.csv \
  https://api.firmus.sh/v1/fr/jobs)
cat job.json

The answer is 202 Accepted, with the job's URL in Location (which the block keeps in $JOB_URL) and the job in the body. Nothing has been processed yet:

{
  "id": "job_Spec11111111111111111111",
  "status": "queued",
  "operation": "resolve_then_enrich",
  "filename": "crm_export.csv",
  "mapping": {
    "registry_number": "SIREN",
    "name": "Société",
    "postal_code": "Code postal",
    "city": "Ville"
  },
  "mapping_detected": true,
  "fields": ["name", "status", "activity", "workforce", "head_office_address"],
  "progress": { "total_rows": 5, "processed_rows": 0, "partial_rows": 0 },
  "counts": {
    "resolve": { "matched": 0, "ambiguous": 0, "not_found": 0, "invalid": 0 },
    "enrich": { "enriched": 0, "ambiguous": 0, "not_found": 0, "invalid": 0, "not_processed": 0 }
  },
  "agent_rows": { "resolve": 0, "enrich": 0 },
  "error": null,
  "result_url": null,
  "download_url": null,
  "created_at": "2026-11-01T09:00:00Z",
  "started_at": null,
  "finished_at": null,
  "expires_at": "2026-11-08T09:00:00Z"
}

The same job can be sent as JSON instead: the file's content as the string csv (with filename to name the result after it), or the rows as a rows array of objects, whose keys become the columns in order of first appearance. Send exactly one of file, csv and rows.

curl -H "Authorization: Bearer $FIRMUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"operation": "enrich", "csv": "siren\n552032534\n", "fields": ["identity"]}' \
  https://api.firmus.sh/v1/fr/jobs

fields and event_kinds choose the enrich columns exactly as on /enrich, and are refused on a resolve job. In JSON they are arrays; in a multipart form, one comma-separated value (-F fields=identity,head_office). Without fields an enriching job appends enrich's default five: name, status, activity, workforce, head_office_address.

The file is read as it is written. The delimiter — comma, semicolon or tab — is taken from the header line. UTF-8 is read with or without a byte-order mark, and a file that is not valid UTF-8 is read as Windows-1252, which is what Excel writes in Western Europe; the five bytes Windows-1252 leaves undefined (0x81, 0x8D, 0x8F, 0x90, 0x9D) become U+FFFD, the replacement character �, rather than refusing the file. Blank lines are dropped and a short line is padded with empty cells; a line with more cells than the header has columns is refused with its line number, since the appended columns would land under the wrong headers.

The column mapping

mapping says which column feeds which resolve field: name, postal_code, city, address, registry_number (SIREN), establishment_number (SIRET), vat_number and ref, as { "field": "column header" } — a JSON object, or a JSON string in a multipart form (-F 'mapping={"name": "Account Name"}'). Each line is then answered as a resolve row made of its mapped, non-blank cells. Every other column is carried through to the result untouched, so unlike on /resolve, a column such as Notes does not make its row invalid.

Leave mapping out and it is detected from the headers, as above: accents, case and punctuation are ignored, so Code postal and code_postal read alike, and English CRM spellings (Account Name, Billing City, Zip) are recognised as well as French ones (Raison sociale, Code postal, Ville) and the field names themselves. The first header that matches a field wins. The answer always says what was used, with mapping_detected telling you whether it was detected; check it before you trust a result, and send mapping for the column the detection missed.

A sent mapping is checked: each header must be a column of the file, matched exactly and then case- and accent-insensitively. A resolve or resolve_then_enrich job needs at least one of registry_number, establishment_number, vat_number or name mapped; an enrich job needs registry_number. Otherwise the job is refused with 400 invalid_request, and message lists what was detected.

Limits and agent rows

PlanRows per jobBytes per job
Free1 0002 MB
Pro25 00020 MB
Scale100 00050 MB

A megabyte here is 1 000 000 bytes. A file over either figure is refused with 400 invalid_request before a job exists: This file has more than 1000 rows, the most a job on the free plan holds. Split it, or upgrade the plan.

The size is checked from the request's Content-Length before the body is read: a body more than 64 KiB (65 536 bytes) over the plan's bytes is refused with 400 invalid_request, whether it carries a multipart file, a csv string or a rows array. A body sent without a Content-Length — a chunked upload — is refused with 411 length_required, so send the file with its length; curl -F and curl -d do.

The rows a job processes are counted against the month's agent rows, shared with /resolve and /enrich, exactly as those endpoints count them: a resolve row looked up, an enrich row enriched or not_found. invalid rows, enrich rows read only in part and, on the enrich step, the rows resolve did not match are not counted, and a row retried inside the job is counted once. A resolve row marked firmus_partial was searched, so it is counted, and counted again when you submit it a second time; an enrich row marked so is not. agent_rows on the job shows what it has counted so far.

Unlike a batch, a job is checked up front, for the whole file: it may spend every row once per step — rows × 2 for resolve_then_enrich — and is admitted only if that fits in what is left this month, counting as spent what your organization's unfinished jobs were admitted for and have not processed yet. So on Free, whose month holds 1 000 agent rows, a resolve_then_enrich job holds at most 500 rows, and only while nothing else has been spent that month. A file that does not fit is refused, and nothing is created:

{
  "error": {
    "status": 402,
    "code": "quota_exceeded",
    "message": "This job may spend up to 1200 agent rows (600 rows × 2 steps), and this organization has 1000 left this month. Send a smaller file, or wait for the allowance to reset at the start of the next calendar month, UTC.",
    "documentation_url": "https://docs.firmus.sh/reference/errors#quota_exceeded"
  }
}

When unfinished jobs hold rows, the message says how many. Retry-After counts the seconds to the start of the next calendar month, UTC.

Submitting is one call — on Free, one of the month's 1 000 lookups. Reading the job and downloading its result are not calls; the plan's requests per second still apply to both.

Wait for it, then download

The job is queued, then running, then succeeded or failed. progress.processed_rows moves in steps of up to 500 rows, and a job can stay queued while others are worked on. progress.partial_rows counts the rows written without a conclusive answer, the ones the result marks firmus_partial (below); each is also counted under its status in counts, so the not_found and ambiguous counts include them. Read it with GET /v1/{country_code}/jobs/{id}, and leave a few seconds between reads. This loop stops when the job is final, and also on any refusal, which curl -f turns into an empty $JOB rather than a loop that never ends:

while JOB=$(curl -sf -H "Authorization: Bearer $FIRMUS_KEY" "$JOB_URL") &&
  ! printf '%s' "$JOB" | grep -Eq '"status":"(succeeded|failed)"'; do
  sleep 5
done
printf '%s\n' "$JOB"

Once it has finished, succeeded or failed, the job carries two links to the same CSV. result_url is GET …/jobs/{id}/result and needs your key; download_url needs none and is what you can hand to a person or open in a browser. Anyone holding download_url can download the file until expires_at, so treat it as you would the file:

curl -s -H "Authorization: Bearer $FIRMUS_KEY" -o crm_export-firmus.csv "$JOB_URL/result"

Asked for while the job is still queued or running, the result is 409 job_not_finished; retrying on it is safe, since every job finishes, but reading the job tells you how far it has got.

A failed job serves a result too: the first progress.processed_rows lines of your file, answered, and nothing after them — only the header line if it stopped before its first 500. Those rows were counted, and they are the ones delivered. Its error gives the count (The job stopped on an internal error (…) after 1500 of 4000 rows. Its result holds those rows; submit the rest again.), so submit the lines after them as a new job.

On Free, reading a job is still refused with 402 quota_exceeded once the month's 1 000 lookups are spent, though reading it does not spend one. download_url takes no key and is not refused that way, so keep it from the first read that shows the job finished.

The result

Every line and column of your file, in order, in its own delimiter, and always behind a UTF-8 byte-order mark, whatever your file had, so that Excel reads accents in company names as UTF-8 rather than in its own code page. Then the appended columns, all prefixed firmus_ so that they never collide with yours:

StepColumns
resolvefirmus_resolve_status, firmus_resolve_confidence, firmus_registry_number, firmus_establishment_number, firmus_matched_name, firmus_matched_on, firmus_candidates, firmus_resolve_message
enrichfirmus_enrich_status, then one or more columns per selected field, then firmus_data_updated_at and firmus_enrich_message
lastfirmus_partial, then, on a job that enriches, firmus_partially_disclosed and firmus_sources; a job submitted before a column was added keeps the columns it was created with, without it

The confidence is written with three decimals. firmus_matched_on lists the input fields the match rests on, separated by a vertical bar; firmus_candidates, filled on an ambiguous row, gives each candidate as <SIREN> <name> (<confidence>), separated by a vertical bar between spaces.

The enrich columns flatten each field: legal_form becomes firmus_legal_form_code and firmus_legal_form_label; activity becomes firmus_activity_code, _scheme and _label; workforce and size_category each a _code, _label and _year; head_office_address becomes firmus_head_office_address, _postal_code, _city and _departement; head_office_coordinates becomes firmus_head_office_latitude and _longitude; events is one cell, <kind> <date> per event, a (bulk) suffix on a mass-operation event, separated by ; . collective_proceeding becomes firmus_collective_proceeding_open (true or false), _procedure, _stage and _opened_on; last_accounts_filed becomes firmus_last_accounts_published_on and firmus_last_accounts_closing_on; last_business_transfer becomes firmus_last_business_transfer_direction and firmus_last_business_transfer_on. The notice links, updated_on and the counterparties are not written to the file: read them from /enrich for the rows that need them. name, registered_on, status, ceased_on and active_establishments are one column each, firmus_ and the field's name. Labels are the registry's, in French. firmus_data_updated_at is the registry's timestamp for the company's record, not an import time; cite it rather than the date of your job.

firmus_partially_disclosed is true when something on the row is masked: the company is partially disclosed by the registry, or its head office is and was read, because you selected a head-office field or events. It is false otherwise. On a true row an empty name, street address, département or coordinate may be withheld by law rather than missing, and the restriction is yours to honour wherever the file goes next; it is the /enrich row's partially_disclosed. firmus_sources names where the row's values come from, separated by ; : insee_sirene on every enriched row, then, when a legal field was selected, bodacc and the publication date of the latest notice behind those fields — insee_sirene; bodacc 2026-06-02 — or bodacc alone when no notice is, or when the legal fields were not read in time on a row whose firmus_partial is true. It is the /enrich row's sources, flattened; Sirene's own date is firmus_data_updated_at. Both columns are empty on a row that was not enriched.

Both come after firmus_partial, at the end of the line, so every earlier column keeps its position. A job submitted before they were added keeps the columns it was created with, without them.

firmus_partial is true when a step's answer for the row is not conclusive, and false otherwise. A batch spends at most 10 seconds searching names, as on /resolve. A row the budget did not reach is carried into the next batch, and a row whose search ran out of time partway is searched once more, with a fresh 10 seconds, before it is written; an enrich read the budget cut short is tried again. A row still cut short after that is written with the answer it got and firmus_partial set to true: its not_found or ambiguous may only mean "not settled in time", not that the company is absent or the match unclear. Do not record those rows as misses; select them on firmus_partial and submit them again as a new job.

In resolve_then_enrich, a row resolve did not match goes through enrich with its own status, uncounted, as it does on /enrich. An empty cell is written empty.

A cell a spreadsheet would run as a formula — one starting with =, +, -, @, a tab or a carriage return — is written with a leading apostrophe, which a spreadsheet shows as text and does not display. That applies to every cell, your own echoed back as well as the registry's, so a program reading the result back strips one leading ' from such a cell to recover the value. A number is left alone: -12.5 stays -12.5.

Retention

A job, its file and its result are kept seven days from submission, until expires_at. From that instant the job, its result and its download_url are all 404, the same answer as an id that never existed or a job of another organization. Download what you need before then.

Errors

  • 400 invalid_request — the body cannot become a job: no source or more than one, an unknown operation, a mapping to a column the file does not have, nothing to resolve from, an enrich job without a SIREN column, an unknown field, an unreadable CSV, a header with no rows, or more rows or bytes than the plan allows. message says which.
  • 411 length_required — the body has no Content-Length, as a chunked upload does.
  • 402 quota_exceeded — the file does not fit in the month's agent rows, as above, or on Free the month's lookups are spent.
  • 403 public_key_forbidden — a publishable fk_pub key holding read; one without it gets 403 insufficient_scope. Submit from a server, with a secret key holding read.
  • 404 not_found — a country other than fr.
  • 404 not_found — on a job or its result: an id that is not one of your organization's live jobs, malformed, expired or another organization's alike.
  • 409 job_not_finished — on the result, while the job is queued or running.

Next

  • Errors — every code above, and how to tell a refusal from an outage.
  • Rate limits — the rows member of RateLimit, which shows the agent rows left.
POST/v1/{country_code}/jobsScope read

Parameters

ParameterInTypeRequiredExample
country_codepathstringYesfr

Request

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

Responses

Submits a file too large for one batch — a CSV as the multipart field `file` or as the JSON string `csv`, or a JSON `rows` array — to be resolved, enriched, or resolved then enriched (`operation`) in the background. `mapping` says which column holds each row field (`name`, `postal_code`, `city`, `address`, `registry_number`, `establishment_number`, `vat_number`, `ref`); without it the columns are detected from the headers, and the answer says what was detected. `fields` and `event_kinds` select the enrich columns, as on /enrich. A job on Free holds 1 000 rows and 2 MB, on Pro 25 000 and 20 MB, on Scale 100 000 and 50 MB. The file is checked against the plan's agent rows before it is admitted — every row once per step, plus what the organization's unfinished jobs hold — and refused with 402 `quota_exceeded` when it does not fit; the rows processed are then counted as the batches count them. Answers 202 with the job; its result is kept seven days.

{
  "id": "job_Spec11111111111111111111",
  "status": "queued",
  "operation": "resolve_then_enrich",
  "filename": null,
  "mapping": {
    "registry_number": "SIREN",
    "name": "Company",
    "postal_code": "Zip",
    "city": "Ville",
    "ref": "Id"
  },
  "mapping_detected": false,
  "fields": [
    "name",
    "legal_form",
    "registered_on",
    "status",
    "ceased_on",
    "activity"
  ],
  "progress": {
    "total_rows": 2,
    "processed_rows": 0,
    "partial_rows": 0
  },
  "counts": {
    "resolve": {
      "matched": 0,
      "ambiguous": 0,
      "not_found": 0,
      "invalid": 0
    },
    "enrich": {
      "enriched": 0,
      "ambiguous": 0,
      "not_found": 0,
      "invalid": 0,
      "not_processed": 0
    },
    "segment_export": null
  },
  "agent_rows": {
    "resolve": 0,
    "enrich": 0
  },
  "error": null,
  "result_url": null,
  "download_url": null,
  "created_at": "2026-11-01T09:00:00Z",
  "started_at": null,
  "finished_at": null,
  "expires_at": "2026-11-08T09:00:00Z",
  "exported_rows": null,
  "segment": null
}
  • idstring
  • statusstring
  • operationstring
  • filenamestringnullable
  • mappingobject
    • registry_numberstringoptional
    • namestringoptional
    • postal_codestringoptional
    • citystringoptional
    • refstringoptional
  • mapping_detectedboolean
  • fieldsstring[]nullable
    • []stringoptional
  • progressobject
    • total_rowsinteger
    • processed_rowsinteger
    • partial_rowsinteger
  • countsobject
    • resolveobjectnullable
      • matchedinteger
      • ambiguousinteger
      • not_foundinteger
      • invalidinteger
    • enrichobjectnullable
      • enrichedinteger
      • ambiguousinteger
      • not_foundinteger
      • invalidinteger
      • not_processedinteger
    • segment_exportstringoptionalnullable
  • agent_rowsobject
    • resolveinteger
    • enrichinteger
  • errorstringnullable
  • result_urlstringnullable
  • download_urlstringnullable
  • created_atstring
  • started_atstringnullable
  • finished_atstringnullable
  • expires_atstring
  • exported_rowsstringoptionalnullable
  • segmentstringoptionalnullable