Skip to content
Log in

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.

ChangeArea
Received invoice webhook eventsWebhooks
Create offices via POST /accounts with parent_idAccounts
Independent CIN for contact offices 🚨 BreakingContacts
parent_id in contact responsesContacts
Stricter contact validation on invoice creation 🚨 BreakingInvoices
DGFiP refuse_reason_code on mark_as and invoice responsesInvoices
issue_after_import on invoice creation and importInvoices
French Annuaire lookup on /directory/fr 🚨 BreakingDirectory
Inline contact routing_codes on invoice creation 🚨 BreakingInvoices
Invoice-level routing_codes on invoice creation 🚨 BreakingInvoices
Taxable base and tax amount in invoice listInvoices
Invoice list filters: number now matches by prefix and series_codeInvoices
Generate tax report returns 201 Created 🚨 BreakingInvoices
Account unit_code_format preferenceAccounts
Per-invoice apply_taxes_per_lineInvoices

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_id in 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_scheme cannot be set on an office — they are always inherited from the parent account. Sending them returns 422 parameter_cannot_change (param tin_value).
  • cin_value / cin_scheme are stored independently on the office. If not set, the office inherits the parent's CIN in read 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_id is null.
  • For offices, parent_id contains 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_send tax report setting enabled (Verifactu, Ksef, Zatca) with auto_generate on and the invoice qualifies, the tax report is generated inline and exposed via tax_report_ids in the response. When auto_generate is 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 as currency).
  • 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"
}
]
}

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.


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's apply_taxes_per_line setting (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 effective apply_taxes_per_line.
  • POST /accounts/{account}/invoices — accepts and returns apply_taxes_per_line.
  • PATCH /invoices/{id} — accepts and returns apply_taxes_per_line.

🚨 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_scheme are provided, they are stored on the office and returned in responses.
  • If omitted (or cleared with cin_value: ""), the office inherits cin_value from the parent contact.
  • tin_value / tin_scheme are still inherited from the parent and cannot be set independently.
  • CIN uniqueness within the account is enforced: sending a cin_value that is already assigned to another contact in the same account returns 422 Unprocessable Entity.

Affected endpoints:

  • POST /accounts/{account}/contactscin_value / cin_scheme are now stored when parent_id is set.
  • PUT /contacts/{id}cin_value / cin_scheme are 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 returns 422 Unprocessable Entity with code data_mismatch. Pass update_contact: true (top-level request field, default false) 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_contact is 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_id directly.


🚨 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 OK with the company data.
  • If it is not yet known, the response is 202 Accepted with a polling_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 returns 200 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 OK immediately and a refresh is triggered in the background (stale-while-revalidate).
  • After a 404 Not Found on a SIREN/SIRET, subsequent requests for the same identifier return 404 for 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 Dependency with error code upstream_unavailable and a Retry-After header — retry after the indicated delay. A transient upstream failure is never converted into a 404.

The response includes a new field information_flags (array of strings) carrying the Annuaire registration state, aligned with the Peppol CTC specification:

FlagMeaning
FR_ASSUJETTI_ACTIVEIdentifier is present and active in the Annuaire
FR_ASSUJETTI_INACTIVEIdentifier is present in the Annuaire but inactive
FR_ASSUJETTI_UNKNOWNCompany 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

Terminal window
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_schemecin5_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_schemecin5_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:

SlotDIR3 (scheme 8014)DIRe (8015)EDI/GLN (88)
cin1accounting_unitnadby
cin2managing_unitnaddp
cin3processing_unitnadiv
cin4proponent_unitnadpr
cin5direnadud

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.


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-2

returns INV-20, INV-21, … as well as an invoice numbered exactly INV-2.

In previous versions the number filter 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.


🚨 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 200 status. Clients checking for any 2xx success are unaffected.