Documentation
Monitoring API and webhooks
Push your clients and suppliers from your ERP, read what monitoring detects in official sources, and receive every event on your server. A JSON REST API, authenticated by token, with no SDK to install.
Get started in four steps
- Create a TierSafe account (free).
- In the Sentinelle area, API page, create a token: it starts with ts_ and is shown only once.
- Push a first batch of third parties (POST /companies): the engine runs its first pass right away, on every source.
- As a subscriber, read the events (GET /events) or declare a webhook address to receive them as soon as they are detected.
Without a subscription, the API prepares the portfolio: pushing and listing records work, the watch state of each record stays hidden, and the event feed, event details, acknowledgement and webhooks require an active subscription.
The OpenAPI 3.1 specification of the API and webhooks imports into Postman, Insomnia or a client generator: download the specification (JSON)
Authentication
Every request carries the token in the Authorization header, with the Bearer scheme. The API is sessionless: no cookie is sent or expected.
curl -H "Authorization: Bearer ts_..." https://tiersafe.eu/api/v1/monitoring/subscription
- Up to 5 active tokens per account, one per application. Only the token fingerprint is stored: if lost, revoke it and create another.
- A missing, unknown or revoked token gets a 401 answer with the code invalid_token, never a redirection to the login page.
- The rate is limited per token: 120 requests per minute. Beyond that, the answer is 429 with the code rate_limited and a Retry-After header in seconds.
Conventions
- Paths relative to the base:
https://tiersafe.eu/api/v1/monitoring - Request and response bodies in JSON, encoded in UTF-8.
- Dates and times in ISO 8601 format with time zone (2026-09-28T03:10:00+02:00), plain dates as YYYY-MM-DD.
- Lists are paginated by limit (100 by default, 500 at most) and offset; the response carries the total.
- Every error carries a stable machine code ("code") and a readable message ("detail", in French). Code against the code, never against the message.
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"code": "subscription_required", "detail": "Le fil des événements demande un abonnement de veille actif."}
Third-party portfolio
The portfolio is the list of third parties you have monitored. Each record carries your client reference, unique within the portfolio, and at least one identifier: SIREN, SIRET or VAT number.
GET /subscription
Subscription and portfolio state: tier quota, monitored records, end of coverage, monthly rotation and credit balance. Query it before a large push.
{
"subscribed": true,
"quota": 2000,
"followed": 1843,
"over_quota": 0,
"coverage_end": "2027-09-30T00:00:00+02:00",
"rotation": {"allowance": 657, "used": 12},
"credit_balance": 450
}
POST /companies
Pushes a batch of records in one request, with the same rules as the file import of the Sentinelle area.
- Fields of a record: reference, name, siren, siret, vat, platform (registered receiving platform), routing (routing identifier), and optionally address, postal_code, city, iban, bic, email (one contact address). All text; an empty reference takes the most precise identifier. An unusable value next to a valid identifier (SIRET or VAT number with a wrong check digit, malformed e-mail) does not prevent monitoring: it is set aside and reported as an anomaly.
- Mode "merge" (default): adds unknown references and updates the others; nothing is retired.
- Mode "sync": the batch describes the whole portfolio, missing references are retired. Every line must then be usable: an unreadable line would retire its record, so the whole batch is refused (422 sync_invalid_lines, offending lines listed in "invalid").
- Parameter dry_run=1: returns the batch preview (additions, updates, retirements, ignored lines, rotation credits) without writing anything.
curl -X POST "https://tiersafe.eu/api/v1/monitoring/companies?dry_run=1" \
-H "Authorization: Bearer ts_..." -H "Content-Type: application/json" \
-d '{"mode": "merge", "companies": [
{"reference": "C0001", "name": "DANONE", "siren": "552032534"},
{"reference": "C0002", "siret": "55203253400646", "address": "17 boulevard Haussmann", "postal_code": "75009", "city": "Paris", "iban": "FR7630006000011234567890189", "email": "comptabilite@example.com"},
{"reference": "F0107", "name": "Muster GmbH", "vat": "DE123456789"}
]}'
The response details what was applied and what was ignored: lines without a usable identifier ("invalid", with their rank in the batch and the reason: field, no_identifier, siren, siret, vat) and duplicate references ("duplicates").
{
"applied": true,
"refused": null,
"summary": {
"refused": null,
"active_before": 1843,
"active_after": 1845,
"limit": 10000,
"quota": 2000,
"over_quota": 0,
"subscribed": true,
"rotation": {"allowance": 657, "used": 12, "additions": 2, "extra_credits": 0},
"balance": 450,
"counts": {"added": 2, "modified": 1, "removed": 0}
},
"invalid": [{"index": 4, "reason": "siren", "reference": "C0004"}],
"duplicates": []
}
Statuses: 200 applied, or nothing to change (the request can be replayed); 402 credit balance too low for the rotation, nothing written; 409 rotation exceeded without a subscription or portfolio too large; 413 batch too large; 422 invalid sync batch; 400 unreadable body.
GET /companies?limit=100&offset=0
Active records, in order of addition. For a subscriber, each record carries its alert level (none, watch, critical), its last check and its open events; without a subscription, these three fields are null.
{
"companies": [{
"uuid": "0192a0b1c2d34e5f8a6b7c8d9e0f1a2c",
"reference": "C0001",
"name": "DANONE",
"siren": "552032534",
"siret": null,
"vat": null,
"platform": null,
"routing": null,
"address": "17 boulevard Haussmann",
"postal_code": "75009",
"city": "Paris",
"iban": null,
"bic": null,
"email": "comptabilite@example.com",
"alert_level": "none",
"awaiting_baseline": false,
"open_events": 0,
"added_at": "2026-09-01T10:12:00+02:00",
"checked_at": "2026-09-28T03:10:00+02:00",
"anomalies": [{"code": "vat_missing", "severity": "review", "params": {"expected": "FR27552032534"}}]
}],
"total": 1845,
"limit": 100,
"offset": 0
}
GET /companies/{reference}
One active record by its reference, in the same shape as a list item ("company" field), watch state hidden without a subscription. Answer 404 unknown_reference if no active record carries this reference.
curl -H "Authorization: Bearer ts_..." https://tiersafe.eu/api/v1/monitoring/companies/C0001
Each record read carries its anomalies (anomalies, null without a subscription): the checks of a file verification, run when the record enters or changes, and conflicts with other records of the portfolio. An anomaly has a stable code, a severity (blocking, review or info) and the values of its message (params, never an IBAN). Codes: siren_invalid, siret_invalid, siret_other_siren, vat_invalid, vat_other_siren, vat_wrong_key, vat_missing, name_inconsistent, name_to_check, name_differs, email_invalid, email_public_provider, email_domain_unknown, email_dmarc_missing, email_dmarc_monitoring, email_spf_permissive, email_spf_missing, email_spf_invalid, postal_code_suspect, routing_invalid, routing_other_siren, iban_length, iban_checksum, iban_format, bic_format, iban_bic_country, bank_vat_country, bic_unknown, address_conflict, address_department, address_postal_code, address_city, address_number, address_not_found, address_imprecise, address_suggestion, conflict_siret_names, conflict_siret_addresses, conflict_iban, conflict_vat, siren_unknown, siret_unknown, replacement_siret, takeover, address_sirene_mismatch, platform_mismatch, directory_absent, directory_inactive, routing_not_in_directory, einvoicing_excluded.
DELETE /companies/{reference}
Retires a record by its reference: it stops being monitored and still counts in the monthly rotation if it entered it. Answer 404 unknown_reference if no active record carries this reference.
Events
An event is a dated change found on a record: what changed, according to which source, published when, compared with which version of the source. It is never recomputed. These endpoints require an active subscription.
GET /events?all=0&limit=100&offset=0
Event feed from most recent to oldest: unacknowledged events by default, all of them with all=1.
GET /events/{uuid}
One event by its identifier, with the details specific to its type (payload): this is what a client warned by webhook reads.
{
"event": {
"uuid": "0192a0b1c2d34e5f8a6b7c8d9e0f1a2b",
"type": "proceeding",
"severity": "critical",
"source": "bodacc",
"company_reference": "C0001",
"company_name": "SARL EXEMPLE",
"payload": {"family": "Procédures collectives", "nature": "Jugement d'ouverture d'une procédure de redressement judiciaire"},
"source_date": "2026-09-25",
"source_version": "2026-09-28T02:40:00+02:00",
"detected_at": "2026-09-28T03:10:00+02:00",
"acknowledged_at": null,
"acknowledgement": null
}
}
POST /events/{uuid}/ack
Acknowledges an event: "seen" (the record still needs handling, default) or "accepted" (decision made to keep the record as is). Idempotent: an event already acknowledged stays so.
curl -X POST https://tiersafe.eu/api/v1/monitoring/events/0192a0b1c2d34e5f8a6b7c8d9e0f1a2b/ack \
-H "Authorization: Bearer ts_..." -H "Content-Type: application/json" \
-d '{"ack": "accepted"}'
Event types:
| type | Meaning | severity |
|---|---|---|
proceeding |
Insolvency proceeding | critical |
proceeding_closed |
Proceeding closed (liabilities settled) | good |
ceased |
Closure | critical |
reactivated |
Reactivation | good |
establishment_closed |
Establishment closed | critical |
establishment_reopened |
Establishment reopened | good |
name_changed |
Name change | info |
head_office_moved |
Head office move | info |
sirene_missing |
Missing from the register | info |
vat_invalid |
VAT became invalid | critical |
vat_valid |
VAT active again | good |
platform_removed |
Platform struck off | critical |
platform_registered |
Platform registered | good |
peppol_reachable |
Became reachable (Peppol) | good |
peppol_operator |
Peppol operator change | info |
management_changed |
Change of director | info / critical |
registry_changed |
Registry change (head office, name, activity, dissolution) | info / critical |
directory_reachable |
Reachable (directory) | good |
directory_unreachable |
No longer reachable (directory) | info |
directory_address_changed |
Invoicing address changed (directory) | info |
platform_mismatch |
Peppol platform differs from the announced one | info |
address_mismatch |
Entered address outside the register's town | info |
Severity: critical for a proven risk (it also goes out as an immediate email alert), info for a change worth knowing, good for good news. New types may appear: ignore the ones you do not know.
Error codes
| code | HTTP | Meaning |
|---|---|---|
invalid_token |
401 | Missing, unknown or revoked token. |
rate_limited |
429 | Rate exceeded for this token; retry after the delay of the Retry-After header. |
invalid_json |
400 | Unreadable request body: JSON is expected. |
invalid_payload |
400 | Invalid shape: "companies" must be a list and "mode" must be merge or sync. |
empty_payload |
400 | The batch contains no record. |
too_many_entries |
413 | More than 10,000 records in the batch. |
payload_too_large |
413 | Body larger than 4 MB. |
sync_invalid_lines |
422 | Sync batch containing an unreadable line: nothing is written, the lines are listed in "invalid". |
unknown_reference |
404 | No active record carries this reference in your portfolio. |
unknown_event |
404 | No event of your portfolio carries this identifier. |
invalid_ack |
400 | Acknowledgement value other than seen or accepted. |
subscription_required |
403 | Endpoint reserved to active subscribers. |
email_unconfirmed |
403 | Account email address not confirmed: the portfolio does not change until it is. |
free_daily_limit |
429 | Portfolio without a subscription already changed 5 times in 24 hours (imports, additions, pushed batches); try again later, or subscribe to lift the limit. |
A push refused for a business reason says so in "refused": rotation (monthly rotation exceeded without a subscription), credits (balance too low), too_large (portfolio beyond 10,000 records), empty (nothing to change, 200 answer).
Webhooks
As a subscriber, declare up to 3 HTTPS addresses in the Sentinelle area: every detected event is sent there by POST, signed, as soon as the engine pass that found it ends.
POST /tiersafe/webhook HTTP/1.1
Host: erp.example.com
Content-Type: application/json
User-Agent: TierSafe-Webhooks/1.0
TierSafe-Event: monitoring.event.detected
TierSafe-Delivery: 0192a0b1c2d34e5f8a6b7c8d9e0f1a2d
TierSafe-Signature: t=1790563800,v1=6d2b0f9c...a41e
{"id": "0192a0b1c2d34e5f8a6b7c8d9e0f1a2d", "type": "monitoring.event.detected", "created_at": "2026-09-28T03:12:00+02:00",
"data": {"event": {"uuid": "0192a0b1c2d34e5f8a6b7c8d9e0f1a2b", "type": "proceeding", "severity": "critical", "source": "bodacc",
"company_reference": "C0001", "source_date": "2026-09-25", "detected_at": "2026-09-28T03:10:00+02:00",
"url": "https://tiersafe.eu/api/v1/monitoring/events/0192a0b1c2d34e5f8a6b7c8d9e0f1a2b"}}}
- The body is thin by design: identifier, type, severity, source, your client reference and the dates, never the company name or the details. Read the details through GET /events/{uuid} with your token: a stray delivery discloses nothing of your portfolio.
- Delivery types (TierSafe-Event header and "type" field): monitoring.event.detected, and webhook.test for the test button.
- At-least-once delivery: the same delivery may arrive twice (lost answer, new attempt). Drop duplicates thanks to its identifier, in the "id" field and the TierSafe-Delivery header. An event is queued only once per address; for idempotent handling, also rely on data.event.uuid.
- Arrival order is not guaranteed: sort on detected_at if order matters to you.
Verify the signature
The TierSafe-Signature header carries a timestamp (t) and one or two signatures (v1): HMAC-SHA256, with your secret, of the string "timestamp.body" where the body is byte for byte as received. Accept the delivery if one of the signatures matches, compared in constant time, and if the timestamp is within 300 seconds of your clock.
PHP
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_TIERSAFE_SIGNATURE'] ?? '';
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ('t' === $key) { $timestamp = (int) $value; }
if ('v1' === $key) { $signatures[] = $value; }
}
$expected = hash_hmac('sha256', $timestamp.'.'.$body, getenv('TIERSAFE_WEBHOOK_SECRET'));
$valid = null !== $timestamp && abs(time() - $timestamp) <= 300
&& [] !== array_filter($signatures, fn ($s) => hash_equals($expected, $s));
http_response_code($valid ? 204 : 400);
Node.js
const crypto = require('node:crypto');
function isValid(rawBody, header, secret) {
const pairs = header.split(',').map((part) => part.trim().split('='));
const timestamp = Number(pairs.find(([key]) => key === 't')?.[1]);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return pairs.filter(([key]) => key === 'v1').some(([, value]) => {
const given = Buffer.from(value ?? '', 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
Python
import hashlib, hmac, time
def is_valid(raw_body: bytes, header: str, secret: str) -> bool:
items = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
timestamps = [v for k, v in items if k == "t"]
if not timestamps or not timestamps[0].isdigit() or abs(time.time() - int(timestamps[0])) > 300:
return False
signed = timestamps[0].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in items if k == "v1")
Answer, new attempts, disabling
- Answer 2xx within ten seconds; the body of your answer is ignored. Handle the event after answering if handling takes long. A redirection is never followed and counts as a failure.
- Without a 2xx answer, the delivery is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h then 24 h: nine attempts over about three days.
- After the last attempt, the address is disabled and you are told by email. Events stay readable through the API; once re-enabled, the address receives the events of the last seven days still waiting.
- The secret is replaced from the Sentinelle area: for 24 hours, every delivery carries two signatures (new and previous secret), leaving time to deploy the new one without losing a delivery.
- The "Send a test" button sends a webhook.test delivery and shows your server's answer right away; the latest deliveries and their result stay available for 30 days.
Accepted addresses
HTTPS only, on port 443 or 8443, with no user name or password in the URL (authenticity is checked through the signature). The name must resolve to public IP addresses only: a private, local or reserved address is refused at registration as at every delivery, and the connection goes to the checked address.
Limits and versions
- Portfolio and batch: 10,000 records at most; request body: 4 MB at most.
- Rate: 120 requests per minute and per token. A full synchronisation fits in one request.
- Rotation: a quarter of the tier per month (25%) plus free places; beyond that, each new entry costs one credit (announced by dry_run, refused with 402 if the balance is short). Without a subscription, pushing is not limited by the rotation.
- Version: v1. Compatible additions (new field, new event type) arrive without a version change: ignore what you do not know. An incompatible change would go through a new version (/api/v2), v1 remaining served during the transition.
An integration question, a need beyond 10,000 records? Write to us.