v2026-06-26
Changelog
Select this version by sending X-B2B-API-Version: 2026-06-26 in your request headers.
This version implements breaking changes. Each breaking change is marked with the tag Breaking.
Changes overview
Section titled “Changes overview”| Change | Area |
|---|---|
| Received invoice webhook events | Webhooks |
Create offices via POST /accounts with parent_id | Accounts |
| Independent CIN for contact offices 🚨 Breaking | Contacts |
parent_id in contact responses | Contacts |
| Stricter contact validation on invoice creation 🚨 Breaking | Invoices |
DGFiP refuse_reason_code on mark_as and invoice responses | Invoices |
issue_after_import on invoice creation and import | Invoices |
French Annuaire lookup on /directory/fr 🚨 Breaking | Directory |
Inline contact routing_codes on invoice creation 🚨 Breaking | Invoices |
Invoice-level routing_codes on invoice creation 🚨 Breaking | Invoices |
| Taxable base and tax amount in invoice list | Invoices |
Invoice list filters: number now matches by prefix and series_code | Invoices |
| Generate tax report returns 201 Created 🚨 Breaking | Invoices |
Account unit_code_format preference | Accounts |
Per-invoice apply_taxes_per_line | Invoices |
Received invoice webhook events
Section titled “Received invoice webhook events”Two new webhook events for received invoices:
received_invoice.created— fired when a new received invoice is created in the platform (e.g. imported or upload).received_invoice.state_change— fired when a received invoice transitions to a new state.
Subscribe to these events when configuring a webhook:
{ "web_hook": { "url": "https://example.com/hooks", "events": ["received_invoice.created", "received_invoice.state_change"] }}Payload for received_invoice.created:
{ "invoice_id": 12345, "account_id": 42, "state": "received"}Payload for received_invoice.state_change:
{ "invoice_id": 12345, "event_id": 67890, "state": "accepted", "notes": null}Create offices via POST /accounts with parent_id
Section titled “Create offices via POST /accounts with parent_id”POST /accounts now accepts an optional parent_id field. When provided, the account is created as an office (subsidiary) under the specified parent account.
When parent_id is set:
- The parent must be a top-level account within the same group (not itself an office).
- The standard required fields (
name,postalcode,email,address,city,province,phone,tin_value) become optional. - The created account inherits the group's integration and is returned with
parent_idin the response.
Returns 422 Unprocessable Entity with code parameter_invalid (param parent_id) if:
- The parent account is not found or belongs to a different group.
- The parent is itself an office.
Example request:
POST /accounts{ "account": { "parent_id": 12345, "name": "Paris Establishment", "address": "1 Rue de la Paix", "city": "Paris", "postalcode": "75001", "province": "Ile-de-France", "email": "paris@example.com" }}Example response:
{ "account": { "id": 67890, "parent_id": 12345, "name": "Paris Establishment", ... }}Notes:
tin_value/tin_schemecannot be set on an office — they are always inherited from the parent account. Sending them returns422 parameter_cannot_change(paramtin_value).cin_value/cin_schemeare stored independently on the office. If not set, the office inherits the parent's CIN in read responses.
parent_id in contact responses
Section titled “parent_id in contact responses”All single-contact responses now include parent_id: GET /contacts/{id}, POST /accounts/{account}/contacts, PUT /contacts/{id}, and DELETE /contacts/{id}.
- For top-level contacts,
parent_idisnull. - For offices,
parent_idcontains the ID of the parent contact.
DGFiP refuse_reason_code on mark_as and invoice responses
Section titled “DGFiP refuse_reason_code on mark_as and invoice responses”POST /invoices/{id}/mark_as now accepts an optional reason_code parameter when transitioning to the refused state. This field carries a DGFiP-defined structured reason code for process 210 refusals.
Accepted values:
ADR_ERR, REF_CT_ABSENT, CMD_ERR, TX_TVA_ERR, MONTANTTOTAL_ERR, CALCUL_ERR, DOUBLON, NON_CONFORME, DEST_ERR, TRANSAC_INC, EMMET_INC, CONTRAT_TERM, DOUBLE_FACT, AUTRE
Example request:
POST /invoices/{id}/mark_as{ "state": "refused", "reason": "Incorrect total amount", "reason_code": "MONTANTTOTAL_ERR"}Example response (excerpt):
{ "invoice": { "id": 12345, "state": "refused", "refuse_reason": "Incorrect total amount", "refuse_reason_code": "MONTANTTOTAL_ERR", "..." }}issue_after_import on invoice creation and import
Section titled “issue_after_import on invoice creation and import”POST /accounts/{account}/invoices and POST /accounts/{account}/invoices/import now accept an optional issue_after_import flag. When true and the invoice passes validations:
- The invoice transitions to
state=issued(c1 corner) synchronously, inline with the request. - If the account has a
before_sendtax report setting enabled (Verifactu, Ksef, Zatca) withauto_generateon and the invoice qualifies, the tax report is generated inline and exposed viatax_report_idsin the response. Whenauto_generateis off the invoice is still issued but no report is created — consistent with the send flow, those reports are managed through the Tax Reports API. - Authorities that report after sending (e.g. SII, SDI) are not triggered by issuing; their reports are created when the invoice is sent or marked as sent.
- No background send job is enqueued for the invoice; the transport-level state (c2) remains untouched. Tax reports that auto-send (e.g. Ksef) still enqueue their own send job.
While the invoice remains in state=issued it is read-only: it cannot be updated, deleted, or reverted to new until it is sent or downloaded.
If both send_after_import and issue_after_import are true, send_after_import wins — sending already issues the invoice. Ignored for non-invoice documents.
The bulk import endpoint POST /accounts/{account}/invoices/imports accepts the same issue_after_import flag (as a multipart form field). It is applied per file: each imported invoice that qualifies is issued; a file that cannot be issued, or whose issuing fails, is reported on that item without aborting the batch.
Example request:
POST /accounts/{account}/invoices{ "issue_after_import": true, "invoice": { "number": "F-2026-0001", "..." }}Taxable base and tax amount in invoice list
Section titled “Taxable base and tax amount in invoice list”GET /accounts/{account}/invoices now returns two additional fields per invoice:
taxable_base— the invoice subtotal before taxes (float, same currency ascurrency).tax_amount— the total tax amount, calculated as the sum of all tax breakdown amounts (float).
Example response (partial):
{ "invoices": [ { "id": 12345, "number": "INV-2025-001", "total": 121.0, "taxable_base": 100.0, "tax_amount": 21.0, "currency": "EUR" } ]}Account unit_code_format preference
Section titled “Account unit_code_format preference”Accounts now expose a unit_code_format field that controls the code list used for invoice line unit codes when reading and writing invoices through the API.
internal(default) — B2BRouter's own integer unit code list.unece_rec20— UN/ECE Recommendation 20 codes (e.g.HUR,KGM,H87).
Read it on GET /accounts/{account} and set it on PUT /accounts/{account}:
{ "account": { "unit_code_format": "unece_rec20" }}When set to unece_rec20, the invoice line unit field changes type: instead of the default integer id it is exchanged as a UNECERec20 string code (e.g. "HUR"). It is returned this way on invoice reads — GET /invoices/{id} and the create/update responses — and accepted as a code on writes (invoice create and import); unknown codes fall back to other. Integrations that parse unit as an integer must handle the string form for these accounts.
The default internal keeps the integer id, so existing integrations are unaffected unless they opt in.
Per-invoice apply_taxes_per_line
Section titled “Per-invoice apply_taxes_per_line”New field apply_taxes_per_line (boolean, nullable) on invoices, both writable and returned in the response. It overrides, per invoice, the account-level setting of the same name that controls VAT rounding:
true— taxes are computed per line (each line rounded, then summed).false— taxes are computed per invoice (the taxable base is summed per rate, then rounded once).- omitted /
null— inherit the account'sapply_taxes_per_linesetting (the previous behaviour).
The override is honoured only for premium accounts. This lets you align the rounding mode with public administrations that recompute totals and reject mismatches, without changing the account-wide default. See the invoice calculation guide for worked examples.
Affected endpoints:
GET /invoices/{id}— returns the effectiveapply_taxes_per_line.POST /accounts/{account}/invoices— accepts and returnsapply_taxes_per_line.PATCH /invoices/{id}— accepts and returnsapply_taxes_per_line.
Changed
Section titled “Changed”Independent CIN for contact offices
Section titled “Independent CIN for contact offices”🚨 Breaking
POST /accounts/{account}/contacts and PUT /contacts/{id} now store cin_value and cin_scheme independently on offices. Previously, CIN was always inherited from the parent contact.
Behavior:
- If
cin_value/cin_schemeare provided, they are stored on the office and returned in responses. - If omitted (or cleared with
cin_value: ""), the office inheritscin_valuefrom the parent contact. tin_value/tin_schemeare still inherited from the parent and cannot be set independently.- CIN uniqueness within the account is enforced: sending a
cin_valuethat is already assigned to another contact in the same account returns422 Unprocessable Entity.
Affected endpoints:
POST /accounts/{account}/contacts—cin_value/cin_schemeare now stored whenparent_idis set.PUT /contacts/{id}—cin_value/cin_schemeare now stored on offices.
Example: Create an office with its own CIN
POST /accounts/{account_id}/contacts{ "contact": { "parent_id": 12345, "name": "Branch Office", "cin_value": "1234567890", "cin_scheme": 88 }}Response:
{ "contact": { "id": 67890, "parent_id": 12345, "name": "Branch Office", "cin_value": "1234567890", "cin_scheme": "88", "..." }}Stricter contact validation on invoice creation
Section titled “Stricter contact validation on invoice creation”🚨 Breaking
When a contact hash is provided on invoice creation, the API matches it against existing contacts by tin_value or cin_value:
- If all data matches an existing contact, the invoice is created and linked to it.
- If routing identifiers differ (
cin_value,cin_scheme,routing_codes,pin_value,recipient_code), an office is created automatically. - If only descriptive fields differ (
address,city,postalcode, etc.), the request returns422 Unprocessable Entitywith codedata_mismatch. Passupdate_contact: true(top-level request field, defaultfalse) to merge the descriptive data into the matched contact instead — routing and tax identifiers are never overwritten this way. - If no matching contact is found, a new contact is created. When it matches a company published in the B2Brouter directory it is linked to it: if the invoice's descriptive data matches one of that company's registered offices, the contact is linked to that office; if it diverges from the directory entry, the contact keeps the invoice's data (linked to the directory but not verified against it).
update_contactis ignored for received documents: an inbound invoice never rewrites an existing contact's data.
Note: The contacts endpoint is the recommended way to create and maintain contacts and their offices. To link an invoice to an existing contact without any matching logic, use
contact_iddirectly.
French Annuaire lookup on /directory/fr
Section titled “French Annuaire lookup on /directory/fr”🚨 Breaking
GET /directory/fr/{scheme}/{id} now consults the French PPF Annuaire (DGFiP REST API) when the identifier is a SIREN (9 digits, scheme 0002), a SIRET (14 digits, scheme 0009), or an FRCTC electronic address (scheme 0225, format SIREN, SIREN_SIRET, SIREN_SIRET_CodeRoutage or SIREN_Suffix).
The identifier must be valid for its scheme — length and checksum are validated for every scheme, not just French ones. An identifier the scheme's validator rejects returns 422 Unprocessable Entity with code invalid_identifier (this includes a SIREN/SIRET of the wrong length, a failed checksum, or an FRCTC address whose SIREN/SIRET is invalid). Schemes with no validator are accepted and looked up as before.
For scheme 0225 the value is used as the Annuaire addressing identifier as-is, and the resolved company is keyed by its underlying legal identifier: a bare SIREN resolves to cin_scheme 0002, a SIREN_SIRET to 0009 (SIRET), with the full FRCTC address kept as pin_scheme 0225; a code routage is exposed as a cin1 routing code (scheme 0224). This means a 0225 lookup and a later 0009/0002 lookup of the same company return the same directory entry.
The lookup is asynchronous to keep response times in milliseconds:
- If the company is already known in B2Brouter's directory, the response is
200 OKwith the company data. - If it is not yet known, the response is
202 Acceptedwith apolling_url. A background job queries the Annuaire and persists the result. Retry the request after a few seconds — once the data is available the request returns200 OK. - When a known directory entry has not been checked in the last 24 hours (or has never been enriched from the Annuaire), the response is still
200 OKimmediately and a refresh is triggered in the background (stale-while-revalidate). - After a
404 Not Foundon a SIREN/SIRET, subsequent requests for the same identifier return404for 15 minutes without re-querying the Annuaire. Retry after the window if you believe the identifier has since been registered. - If the lookup fails because an upstream service (Annuaire, Peppol) is unavailable, the response is
424 Failed Dependencywith error codeupstream_unavailableand aRetry-Afterheader — retry after the indicated delay. A transient upstream failure is never converted into a404.
The response includes a new field information_flags (array of strings) carrying the Annuaire registration state, aligned with the Peppol CTC specification:
| Flag | Meaning |
|---|---|
FR_ASSUJETTI_ACTIVE | Identifier is present and active in the Annuaire |
FR_ASSUJETTI_INACTIVE | Identifier is present in the Annuaire but inactive |
FR_ASSUJETTI_UNKNOWN | Company is known to B2Brouter but absent from the Annuaire |
Exactly one of these flags is set per company. When a company already known to B2Brouter (e.g. from Peppol) is absent from the Annuaire, it is returned with FR_ASSUJETTI_UNKNOWN so the absence is visible. An identifier not known to B2Brouter at all and absent from the Annuaire resolves to 404 Not Found (see the negation-cache note above).
Subordinate units (SIRET offices below a SIREN) are not yet exposed in the response.
Example: company not yet known — first request
curl --request GET \ --url 'https://app-staging.b2brouter.net/api/v1/directory/fr/0002/123456789' \ --header 'X-B2B-API-Key: {YOUR_API_KEY}' \ --header 'X-B2B-API-Version: 2026-06-26'{ "status": "processing", "message": "Querying French PPF Annuaire. Please retry this request.", "polling_url": "https://app-staging.b2brouter.net/api/v1/directory/fr/0002/123456789"}HTTP status: 202 Accepted.
Example: same request a few seconds later
{ "name": "SIREN 123456789", "country": "fr", "information_flags": ["FR_ASSUJETTI_ACTIVE"], ...}HTTP status: 200 OK.
Affected endpoints:
GET /directory/fr/{scheme}/{id}— extended behavior described above. Non-FR countries are unaffected.
Inline contact routing_codes on invoice creation
Section titled “Inline contact routing_codes on invoice creation”🚨 Breaking
The inline contact on POST /accounts/{account}/invoices now accepts a routing_codes object — the same shape used by POST /accounts/{account}/contacts and returned in contact responses. It carries up to five additional Company Identifier Numbers (cin1_value/cin1_scheme … cin5_value/cin5_scheme) beyond the primary cin. If cinN_value is provided, cinN_scheme is required (and vice versa). These routing codes are also returned inside routing_codes in the embedded invoice.contact of the response.
POST /accounts/{account_id}/invoices{ "invoice": { "contact": { "tin_value": "ESA13585625", "name": "Buyer Full Name AS", "routing_codes": { "cin1_value": "ESR_MISSION_FACTURES_DEPLACEMENTS", "cin1_scheme": "8017" } } }}Breaking change: flat cinN_value / cinN_scheme keys inside contact are no longer accepted. Such requests return 422 Unprocessable Entity. Send the contact's routing codes through contact.routing_codes instead.
Invoice-level routing_codes on invoice creation
Section titled “Invoice-level routing_codes on invoice creation”🚨 Breaking
POST /accounts/{account}/invoices and PUT /invoices/{id} now accept a routing_codes object at the invoice top level — the same cin1_value/cin1_scheme … cin5_value/cin5_scheme shape used by contact.routing_codes. It carries the invoice's own routing codes (e.g. the buyer's DIR3 / Chorus Pro service codes), independent of the contact's.
The applied codes are echoed back in a routing_codes object at the invoice top level of the response, so an integrator can verify what was stored.
POST /accounts/{account_id}/invoices{ "invoice": { "number": "F-2026-0001", "routing_codes": { "cin1_value": "ESR_MISSION_FACTURES_DEPLACEMENTS", "cin1_scheme": "8017" } }}The dedicated routing-code fields that earlier versions accepted at the invoice top level are no longer accepted. Set the equivalent routing_codes slot instead, depending on the scheme:
| Slot | DIR3 (scheme 8014) | DIRe (8015) | EDI/GLN (88) |
|---|---|---|---|
| cin1 | accounting_unit | — | nadby |
| cin2 | managing_unit | — | naddp |
| cin3 | processing_unit | — | nadiv |
| cin4 | proponent_unit | — | nadpr |
| cin5 | — | dire | nadud |
Breaking change: at the invoice top level, both the flat cinN_value / cinN_scheme keys and the dedicated routing-code fields (dire, accounting_unit, managing_unit, processing_unit, proponent_unit, nadby, naddp, nadiv, nadpr, nadud) are no longer accepted, on both POST /accounts/{account}/invoices and PUT /invoices/{id}. Such requests return 422 Unprocessable Entity. Send the invoice's routing codes through the invoice routing_codes object instead. Earlier API versions still accept these fields.
Invoice list filters
Section titled “Invoice list filters”Filtering changes on GET /accounts/{account}/invoices:
number now matches by prefix
The number query parameter now performs a prefix match instead of an exact match. Invoices whose number starts with the supplied value are returned.
GET /accounts/{account}/invoices?number=INV-2returns INV-20, INV-21, … as well as an invoice numbered exactly INV-2.
In previous versions the
numberfilter required the full invoice number and returned only an exact match.
series_code
The series_code query parameter filters invoices by series code using an exact match.
Generate tax report returns 201 Created
Section titled “Generate tax report returns 201 Created”🚨 Breaking
POST /invoices/{id}/generate_tax_report now returns 201 Created on success instead of 200 OK. The response body is unchanged.
Before (≤ 2026-04-20): 200 OK
After (≥ 2026-06-26): 201 Created
This is a minor breaking change for clients that check for an exact
200status. Clients checking for any2xxsuccess are unaffected.