Skip to content
firmus.shdocs

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

Watch companies

Put SIRENs and SIRETs in a watchlist, and receive what happens to them by webhook, email digest or a feed you poll.

A watchlist holds the registry numbers you care about. From the moment a number is in one, every event the timeline records about it — a cessation, an insolvency proceeding, a sale of the business, a move, a renaming — is kept for your organization and delivered three ways: a signed POST to your webhook endpoints, an email digest, and a feed you read with GET /v1/fr/events. A list can also follow a saved segment and be told which companies enter it, through the same three channels. Served for fr only; any other country is a 404.

Watching starts when you add a number. What the timeline already held is not delivered: read it with the timeline. Nothing is real time either: the registry publishes each day's changes the following day, so an event reaches you within a day of the registry publishing it, not the moment it happened.

The configuration endpoints are not counted as calls; reading the feed is one call per page. The per-second rate of your plan applies to all of them. Call them from a server, with a secret key that carries the read scope.

Create a watchlist

Keep the key out of your shell history:

read -rs FIRMUS_KEY && export FIRMUS_KEY

Then create a list with its first numbers — Danone's SIREN, and one of its establishments by SIRET:

curl -s -o watchlist.json -X POST -H "Authorization: Bearer $FIRMUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Key accounts", "numbers": ["552032534", "55203253400041"]}' \
  https://api.firmus.sh/v1/fr/watchlists
WATCHLIST_ID=$(jq -r '.watchlist.id' watchlist.json)
cat watchlist.json

The answer is 201 Created, with the list's URL in Location, and the block keeps its id in $WATCHLIST_ID:

{
  "watchlist": {
    "id": 4,
    "name": "Key accounts",
    "country_code": "fr",
    "watched": 2,
    "severity_floor": "notable",
    "include_kinds": [],
    "exclude_kinds": [],
    "include_establishments": false,
    "include_bulk": false,
    "digest": { "frequency": "off", "recipients": [], "sent_at": null },
    "created_at": "2026-11-02T09:00:00Z",
    "updated_at": "2026-11-02T09:00:00Z"
  },
  "results": [
    { "number": "552032534", "status": "added" },
    { "number": "55203253400041", "status": "added" }
  ],
  "allowance": { "included": 1000, "used": 2 }
}

An organization holds at most 100 lists. Another organization's list is a 404, exactly as an id that never existed.

Choose what a list delivers

The defaults deliver what a finance or risk team acts on, and nothing below it:

FieldDefaultWhat it does
severity_floornotableDelivers every kind at or above this severity: critical, notable or informational.
include_kinds[]Kinds delivered whatever the floor. The only way to receive legal_entity.accounts_filed, which is off by default.
exclude_kinds[]Kinds silenced although they are above the floor, legal_entity.renamed for instance.
include_establishmentsfalseEvery event of every establishment of a watched legal entity.
include_bulkfalseEvents marked bulk: true: the monthly corrections, mass reclassifications, and the gazette's notices loaded back to 2008.
digest_frequencyoffdaily or weekly: the email digest below.
digest_recipients[]Up to 10 addresses. Required as soon as the digest is not off.

Each kind's severity is in the timeline's tables of kinds. Without include_establishments, a watched legal entity still receives the establishment events that matter to it: those about its head office today, a branch becoming the head office (establishment.became_head_office), and every critical one. A SIRET you watch on its own receives all of its own events, whatever the option.

Change a list with PATCH /v1/fr/watchlists/{id}; the fields you leave out keep their value. A field this API does not know, an unknown kind, severity or frequency, a boolean other than true, false, 1 or 0, or a digest with no recipient is refused with 400 invalid_request and a message naming it, rather than ignored: a misspelled filter dropped silently would read as "nothing happened".

Add numbers, and what the allowance refuses

POST /v1/fr/watchlists/{id}/watches adds up to 1 000 numbers to a list, as numbers (an array, or one comma-separated string), or as job_id: the companies a finished file job of your organization identified. A job gives the SIREN of every matched row, or, for an enrich job, the registry number of every enriched row; ambiguous, not_found and invalid rows are never watched, since a guess is not a company. A job adds up to 100 000 companies in one request, the largest file a job holds. Every watch endpoint refuses a publishable key (fk_pub) with 403 public_key_forbidden: call them from a server, with a secret key.

curl -s -X POST -H "Authorization: Bearer $FIRMUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"numbers": ["552032534", "552 081 317", "123", "999999999"]}' \
  "https://api.firmus.sh/v1/fr/watchlists/$WATCHLIST_ID/watches"

A number is read by its length once spaces are removed: 9 digits a legal entity, 14 an establishment, anything else invalid. Each number answers in the order sent:

statusMeaning
addedWatched from now on.
already_watchedAlready in this list. Nothing changes.
not_foundNeither the registry nor the event log knows the number, so it is not watched. Check it for a typo.
invalidNot 9 or 14 digits.

A company that has left the registry but has a timeline can still be watched. The answer also carries counts (the statuses tallied; a status no number answered is absent), watched (the list's total) and allowance.

The allowance is a number of watched companies, not of calls: 5 on Free, 1 000 on Pro, 10 000 on Scale. A unit is one distinct number across all of your organization's lists: a SIREN in two lists counts once, and a SIRET watched on its own counts as one more, even when its legal entity is watched too. It is never reset; removing a number frees its unit at once. allowance.used is what is held now, allowance.included what the plan allows.

An add is all or nothing. One that would take you past the allowance adds nothing, so a list is never left half added, and is refused with 402:

{
  "error": {
    "status": 402,
    "code": "quota_exceeded",
    "message": "These numbers would add 3 watched entities; this organization's plan includes 5 and 1 is left. Remove watches, or upgrade the plan.",
    "documentation_url": "https://docs.firmus.sh/reference/errors#quota_exceeded"
  }
}

This 402 carries no Retry-After: waiting does not free a unit, removing a number or changing plan does. On Free, every endpoint of this page is also refused with 402 quota_exceeded once the month's 1 000 lookups are spent, and that one does carry Retry-After; its absence is how to tell the two apart.

Remove numbers with DELETE, listing them in the query string:

curl -s -X DELETE -H "Authorization: Bearer $FIRMUS_KEY" \
  "https://api.firmus.sh/v1/fr/watchlists/$WATCHLIST_ID/watches?numbers=552081317,55203253400041"

Each answers removed, not_watched or invalid. GET /v1/fr/watchlists/{id}/watches lists a list's numbers, page by page, 20 per page by default and at most 100.

Receive events by webhook

Webhook endpoints are created in the console, under Webhooks, not over the API: an HTTPS URL whose host resolves to a public address, receiving every list of the organization or one list. An organization has at most 10. The signing secret, whsec_ followed by 32 characters, is shown once, when the endpoint is created: store it then. To change it, create a new endpoint and delete the old one. Deleting a watchlist also deletes the endpoints that receive that list alone.

Each watched event is POSTed to every endpoint that takes its list:

POST /firmus HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Firmus-Webhooks/1.0 (+https://docs.firmus.sh)
Firmus-Event-Id: 1002
Firmus-Delivery-Id: whd_31
Firmus-Signature: t=1793520000,v1=5f2b0c6a3e9d…

{"id":"whd_31","type":"event","trigger":"recorded","event":{"id":1002,"kind":"establishment.moved","severity":"notable","occurred_on":"2026-06-15","recorded_at":"2026-10-02T06:00:00Z","source":"sirene_daily","source_ref":"france_sirene_syncs:1","bulk":false,"subject":{"kind":"establishment","number":"55203253400041"},"payload":{"legal_entity_number":"552032534","from":{"city":"LYON"},"to":{"city":"PARIS 9"}}},"watchlist_ids":[4]}

event is the timeline's event, field for field, so one parser reads both. watchlist_ids names every one of your lists it matched: an event that two lists match is delivered once, not twice. trigger is recorded, or retyped (below). type is event here; a list that follows a segment also receives segment.entered and segment.left bodies, which hold segment_change instead of event (below), so read type before anything else.

Verify the signature

Firmus-Signature is t=<unix seconds>,v1=<signature>, where the signature is the lower-case hex HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret, whsec_ included. Compute it over the bytes you received, before parsing them, and reject a t more than five minutes from your clock, so that a captured request cannot be played back later. A receiver on Node.js 24, with no dependency; save it as receiver.ts:

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const TOLERANCE_SECONDS = 300;

function verifyFirmusSignature(rawBody: string, header: string, secret: string): boolean {
  const fields = new Map(header.split(",").map((part) => part.split("=", 2) as [string, string]));
  const t = Number(fields.get("t"));
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const received = Buffer.from(fields.get("v1") ?? "", "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

createServer((request, response) => {
  const chunks: Buffer[] = [];
  request.on("data", (chunk: Buffer) => chunks.push(chunk));
  request.on("end", () => {
    const rawBody = Buffer.concat(chunks).toString("utf8");
    const header = String(request.headers["firmus-signature"] ?? "");
    if (!verifyFirmusSignature(rawBody, header, process.env.FIRMUS_WEBHOOK_SECRET ?? "")) {
      response.writeHead(400).end();
      return;
    }

    // Answer first, then work: anything slower than 5 seconds is a failed attempt.
    response.writeHead(204).end();
    const delivery = JSON.parse(rawBody);
    if (delivery.type === "event") {
      console.log(delivery.trigger, delivery.event.id, delivery.event.kind);
    } else {
      const change = delivery.segment_change;
      console.log(delivery.type, change.id, change.segment.id, change.company.registry_number);
    }
  });
}).listen(8080);

Run it with FIRMUS_WEBHOOK_SECRET=whsec_… node receiver.ts, and sign a request the same way to test it:

BODY='{"id":"whd_1","type":"event","trigger":"recorded","watchlist_ids":[4],"event":{"id":1002,"kind":"establishment.moved"}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$FIRMUS_WEBHOOK_SECRET" | sed 's/^.* //')
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "Content-Type: application/json" \
  -H "Firmus-Signature: t=$T,v1=$SIG" -d "$BODY" http://localhost:8080

204 means the signature checked; a body changed by one byte answers 400.

Retries, ordering and duplicates

  • A 2xx within 5 seconds is delivered. Firmus allows 2 seconds to resolve your host, 3 to connect and 5 for the answer. Any other status, a timeout or a refused connection is retried about 1 minute after the first attempt, then 5 minutes, 30 minutes and 2 hours after the one before, then every 6 hours, as long as the attempt falls within 24 hours of the first: eight attempts in all. An endpoint that times out also gets nothing more until the next sending run, about a minute later, so one slow answer delays the deliveries behind it. Redirects are not followed, so a 301 is a failure.
  • After that the delivery is dead. It is kept, shown in the console's delivery log on the Webhooks screen with each attempt's status, and can be replayed from there, which starts a fresh 24-hour window with the same event id. A delivered one can be replayed too. Nothing else re-sends it: if your endpoint was down for more than a day, replay, or read the feed below.
  • Three dead deliveries in a row disable the endpoint. The organization's owners get an email, the Webhooks screen shows the endpoint as disabled, and nothing is queued for it until a member clicks Enable: the deliveries that were pending then go out at once, and what happened in between is in the pull feed. A 2xx resets the count.
  • Ordered per subject. A company's or establishment's next delivery to an endpoint waits while an earlier one to the same endpoint is still being retried, so you receive a subject's events in the order they were recorded. A dead delivery no longer holds the queue, so after a dead one, the next arrives before the replay.
  • At least once. The same event can arrive more than once — a retry after a response that never reached Firmus, a replay. Key what you store by event.id, which is the same on every attempt and in Firmus-Event-Id, and write it as an upsert. A segment body carries Firmus-Segment-Change-Id instead, and is keyed by segment_change.id; the two ids are separate sequences, so keep them apart.
  • trigger: "retyped" is the same event again. A legal notice about a company the registry did not yet hold is first recorded without its values, with a payload of {"withheld": "unknown"}. Once the registry holds the company, the event gets its values and is delivered a second time, with the same event.id, kind and dates and trigger: "retyped". An upsert by event.id replaces the masked item, which is what it should do; a store that ignores an id it has seen keeps the empty one.

Webhook deliveries are never counted as calls.

Receive a digest by email

Set digest_frequency to daily or weekly and digest_recipients to up to 10 addresses. The digests go out at 07:30, Paris time: the daily ones every morning, the weekly ones on Mondays. Each holds what the list matched since the previous one, up to 200 events and the count of the rest, each with its kind, number, date, source and, for a gazette notice, the link to it. A list with nothing new sends nothing. A digest turned on starts from that moment: what the list matched before is in the feed, not in the first mail. digest.sent_at says when the last one went out.

Poll the feed

GET /v1/fr/events reads your organization's watched events, every list of that country together, oldest first: in the order they were matched, which is not the order of occurred_on.

curl -s -H "Authorization: Bearer $FIRMUS_KEY" \
  "https://api.firmus.sh/v1/fr/events?per_page=2"
{
  "events": [
    {
      "type": "event",
      "trigger": "recorded",
      "event": {
        "id": 1002,
        "kind": "establishment.moved",
        "severity": "notable",
        "occurred_on": "2026-06-15",
        "recorded_at": "2026-10-02T06:00:00Z",
        "source": "sirene_daily",
        "source_ref": "france_sirene_syncs:1",
        "bulk": false,
        "subject": { "kind": "establishment", "number": "55203253400041" },
        "payload": {
          "legal_entity_number": "552032534",
          "from": { "city": "LYON" },
          "to": { "city": "PARIS 9" }
        }
      },
      "watchlist_ids": [4],
      "cursor": "dzEy"
    }
  ],
  "pagination": { "per_page": 2, "next_cursor": "dzEy", "has_more": false }
}

Each item is a webhook body without its id, plus its cursor. Without cursor the feed starts at its beginning. Pass pagination.next_cursor back unchanged as cursor to read from there; has_more says whether a next page is already waiting. Store next_cursor and poll with it: it is never null once the feed holds an event, and on an empty page it is the cursor you sent, so polling with it again loses nothing. While your lists have matched nothing yet it is null; poll without a cursor. watchlist_id limits the feed to one list. per_page is 20 by default, between 1 and 100. A cursor this API did not issue is refused with 400 invalid_request.

A reader polling every few minutes, and catching up after a webhook outage, uses the same loop:

CURSOR=""
while PAGE=$(curl -sf -H "Authorization: Bearer $FIRMUS_KEY" \
    "https://api.firmus.sh/v1/fr/events?per_page=100${CURSOR:+&cursor=$CURSOR}"); do
  echo "$PAGE" | jq -c '.events[] | if .type == "event"
    then {type, trigger, id: .event.id, kind: .event.kind}
    else {type, id: .segment_change.id, siren: .segment_change.company.registry_number} end'
  NEXT=$(echo "$PAGE" | jq -r '.pagination.next_cursor // empty')
  [ -z "$NEXT" ] || [ "$NEXT" = "$CURSOR" ] && break
  CURSOR=$NEXT
  [ "$(echo "$PAGE" | jq -r '.pagination.has_more')" = "true" ] || break
done
echo "next cursor: $CURSOR"

Each page is one call against your plan. The loop stops on the last page, on any refusal, through curl -f, and on a page that did not move the cursor; keep the printed cursor for the next run. It never stops on an empty page alone: a page can hold fewer items than per_page, or none, while more are waiting, because a segment item that may not be delivered is skipped (below). A cursor that does not move with has_more: true means the next item is a segment item not readable yet: reading again at once returns the same page and spends a call, so wait for the next run.

Withheld at delivery

A company or establishment that is partially disclosed when an event is delivered — the person behind it asked for parts of the record to be withheld from public reuse — arrives with its kind, dates, source and subject, and without its values: payload is {} for a legal entity and {"legal_entity_number": "…"} alone for an establishment, and a gazette notice's source_ref is null, since the link to the notice is built from it. The status is read at each delivery, not when the number was added: a webhook retried after its subject was restricted carries the withheld payload, even though its first attempt did not, and the feed and the digest read the same way. legal_entity.disclosure_restricted is itself a critical event, delivered by default: when you receive it, remove the values you hold for that company.

Follow a saved segment

A list can follow a saved segment as well as numbers: after each daily update of the segment index, the companies that entered the segment arrive through the list's webhook endpoints, digest and feed, and with include_leavers: true those that left it too. Save a segment, then follow it from the list created above:

SEGMENT_ID=$(curl -sf -X POST -H "Authorization: Bearer $FIRMUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": {"activity": ["62"], "departement": ["69"]}, "name": "Lyon software", "save": true}' \
  https://api.firmus.sh/v1/fr/segments | jq -r '.segment.id')
curl -s -X PUT -H "Authorization: Bearer $FIRMUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"include_leavers": true}' \
  "https://api.firmus.sh/v1/fr/watchlists/$WATCHLIST_ID/segments/$SEGMENT_ID"

The save is one call; following is not counted. The first PUT answers 201 Created:

{
  "segment_watch": {
    "segment": {
      "id": 12,
      "name": "Lyon software",
      "include_sole_traders": false,
      "watch": { "paused_at": null, "failures": 0, "last_failed_at": null }
    },
    "watchlist_id": 4,
    "include_leavers": true,
    "followed_at": "2026-11-02T09:05:00Z"
  },
  "allowance": { "included": 10, "used": 1 }
}

Sending the same PUT again answers 200, and changes include_leavers only if the body sends it. Leavers are recorded only while at least one list following the segment asks for them, so turning include_leavers on later delivers the companies that leave from then on, not those that left before. GET /v1/fr/watchlists/{id}/segments lists what a list follows, and DELETE /v1/fr/watchlists/{id}/segments/{segment_id} stops (204; 404 if the list does not follow it). Deleting the segment or the list stops the follow. A saved segment is never edited: changing its filters means saving a new one and following that, which starts from nothing again.

Nothing already in the segment is delivered. Each follow gets its own baseline, the segment's companies as they stand just after the follow, and the list receives only what changes after it: the first items are the companies that enter at the next daily update of the index. For the companies in it today, export it. A company enters when it newly matches: a new registration, a change of activity, address, workforce or legal form into the filters, a window filter such as head_office_moved_within_days it now meets, or the end of its partial disclosure. It leaves on the converse, when it ceases, or when a window slides past it. The index is fed by the registry's daily changes, which the registry publishes the day after, so a company that enters reaches you a day after the registry recorded it at the earliest.

The allowance is a number of segments followed: 1 on Free, 10 on Pro, 100 on Scale, counted as distinct segments across your lists, so two lists following one segment use one. It is a stock like watched companies: following one more is refused, follows nothing, and carries no Retry-After; stop following a segment or change plan.

{
  "error": {
    "status": 402,
    "code": "quota_exceeded",
    "message": "This organization's plan includes 1 watched segment, and all are in use. Stop following one, or upgrade the plan.",
    "documentation_url": "https://docs.firmus.sh/reference/errors#quota_exceeded"
  }
}

When a segment is paused

If a segment's daily update fails three times in a row, the segment is paused for every list that follows it, and nothing more is delivered for it until it is resumed. The list of followed segments says so in segment.watch: paused_at is set, failures counts the consecutive failed updates and last_failed_at dates the last one. The digest of every list following it carries a line saying so, the console shows it on the segment's page, and the organization's owners are mailed, as when a webhook endpoint is disabled. No webhook announces it, so an integration that relies on webhooks alone should check it from time to time:

curl -s -H "Authorization: Bearer $FIRMUS_KEY" \
  "https://api.firmus.sh/v1/fr/watchlists/$WATCHLIST_ID/segments" |
  jq -r '.segments[] | select(.segment.watch.paused_at != null) | "\(.segment.id) paused at \(.segment.watch.paused_at)"'

Resume it with Resume on the segment's page in the console, or by sending the follow's PUT again from any of its lists. It resumes from a new baseline for every list: what changed while it was paused is never delivered, so export the segment if you need to catch up.

What a segment item holds

In the feed, a segment item looks like this:

{
  "type": "segment.entered",
  "trigger": "recorded",
  "segment_change": {
    "id": 77,
    "change": "entered",
    "segment": { "id": 12, "name": "Lyon software" },
    "company": {
      "registry_number": "847831690",
      "name": "PROMY",
      "legal_form": { "code": "5710", "label": "SAS, société par actions simplifiée" },
      "registered_on": "2018-12-07",
      "activity": { "code": "62.01Z", "scheme": "naf2008", "label": "Programmation informatique" },
      "workforce": { "code": "11", "label": "10 à 19 salariés", "year": 2023 },
      "size_category": { "code": "PME", "label": "Petite ou moyenne entreprise", "year": 2023 },
      "head_office_address": {
        "address": "30 AVENUE MARECHAL FOCH",
        "postal_code": "69006",
        "city": "LYON",
        "departement": "69"
      },
      "sources": {
        "registry": "insee_sirene",
        "data_updated_at": "2025-12-06T08:51:12Z",
        "head_office_data_updated_at": "2025-09-30T14:47:09Z",
        "legal": null
      }
    },
    "index_at": "2026-11-03T10:42:11Z",
    "previous_index_at": "2026-11-02T10:41:57Z",
    "prospecting": {
      "notice": "Built from INSEE Sirene. It does not reflect an opposition to prospecting recorded only in the RNE (diffusionCommerciale), which Firmus does not read yet. Partially disclosed companies are counted but never listed.",
      "acknowledgement": null
    }
  },
  "watchlist_ids": [4],
  "cursor": "dzEz"
}

company carries the fields of a segment's sample, read when the item is delivered, not when the company entered. sources.data_updated_at and head_office_data_updated_at are the registry's own timestamps for the company's record and its head office, not import times. index_at and previous_index_at are the two daily updates of the segment index the change was read between. A webhook body is the same item with its id and without cursor, sent with Firmus-Segment-Change-Id in place of Firmus-Event-Id, signed, retried and ordered exactly as an event is. The digest lists them in a table of their own, after the events. Segment items are kept 90 days: the feed holds none older than that.

Repeat prospecting.notice wherever you show these companies. Segments are built from INSEE Sirene, and an opposition to prospecting recorded only in the RNE is not reflected in them. For a segment saved with sole traders, prospecting.acknowledgement repeats the acknowledgement it was saved with; otherwise it is null.

Some companies are never delivered. A company partially disclosed when the item is due — the company or its head office — a sole trader in a segment saved without the acknowledgement, and a company no longer in the registry are withheld whole, their registry number included, and never sent later. A webhook delivery withheld that way is settled withheld in the console's delivery log: nothing is posted, and it does not count towards disabling the endpoint. The feed skips it and moves the cursor past it. The digest's count is of the items it carries, never of the withheld ones, and a digest whose items were all withheld is not sent. A company that left a segment because it became partially disclosed is never named as a leaver.

A company that could not be read in full is not withheld, only delayed. Nothing is sent for it yet, and each channel asks again: the webhook delivery stays pending and is retried on the schedule above, within its 24-hour window, and if the window closes on it, it goes dead without counting towards disabling the endpoint; the feed ends the page before it, with has_more: true and a cursor that does not move past it; the digest stops before it, and the next digest starts there. This lasts 24 hours from when the change was recorded: a company still unreadable after that is withheld on every channel, so one record can never hold the feed for good.

Next