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_KEYA 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.jsonThe 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/jobsfields 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
| Plan | Rows per job | Bytes per job |
|---|---|---|
| Free | 1 000 | 2 MB |
| Pro | 25 000 | 20 MB |
| Scale | 100 000 | 50 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:
| Step | Columns |
|---|---|
| resolve | firmus_resolve_status, firmus_resolve_confidence, firmus_registry_number, firmus_establishment_number, firmus_matched_name, firmus_matched_on, firmus_candidates, firmus_resolve_message |
| enrich | firmus_enrich_status, then one or more columns per selected field, then firmus_data_updated_at and firmus_enrich_message |
| last | firmus_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 unknownoperation, a mapping to a column the file does not have, nothing to resolve from, anenrichjob without a SIREN column, an unknown field, an unreadable CSV, a header with no rows, or more rows or bytes than the plan allows.messagesays which.411 length_required— the body has noContent-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 publishablefk_pubkey holdingread; one without it gets403 insufficient_scope. Submit from a server, with a secret key holdingread.404 not_found— a country other thanfr.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 isqueuedorrunning.
Next
- Errors — every code above, and how to tell a refusal from an outage.
- Rate limits — the
rowsmember ofRateLimit, which shows the agent rows left.