GET /legal/diff

Returns what changed in a legal article’s consolidated text between a date A (from) and a date B (to): the two versions in force at each date are resolved from the local store and their texts are diffed, giving you both a structured segment list (agent-friendly, word-granularity) and a unified patch string (git-style, line-granularity). The lookup is served in milliseconds without an account or API key.

This is the hardest legal signal to reconstruct independently: an agent that calls /legal/article twice and tries to diff the texts cannot do so reliably at scale or across corpus updates. Use this endpoint when you need to know what the law says now that it did not say before - due diligence, regulatory watch, compliance delta, amendment tracking.

See the live /catalog for the authoritative endpoint listing and price.

x402 golden rule: the agent pays for the answer to its question. A well formed, known article with readable dates returns a diff -> 200, including when nothing changed (changed: false). An unchanged article is a successful answer, not an error. Requests the service cannot answer - malformed references, missing from, unknown articles - leave the 200 range.

Parameters

ParameterTypeRequiredDescription
codestringFR identityFrench text identifier, for example code-civil
articlestringFR identity; optional for EUArticle number within the text, for example 1240
celexstringEU identityCELEX identifier, for example 32016R0679
elistringEU identityELI identifier; used when celex is absent
fromstringrequiredStart date YYYY-MM-DD (date A - the older snapshot)
tostringnoEnd date YYYY-MM-DD (date B); defaults to today’s date

Provide a French identity (code + article) or an EU identity (celex or eli, with optional article). from is always required.

Dates use the strict ISO calendar format YYYY-MM-DD, zero-padded: 2026-07-05 is accepted, 2026-7-5 is rejected with 400 INVALID_DATE. And from must not be after to: an inverted interval would describe the evolution backwards (insertions read as deletions) with no signal, so it is rejected with 400 INVALID_RANGE rather than answered and billed.

GET /legal/diff?code=code-civil&article=1240&from=2000-01-01&to=2026-01-01
GET /legal/diff?celex=32016R0679&article=17&from=2018-01-01

200 response - UnifiedResponse

{
  "data": { ... },
  "provenance": {
    "source": "legifrance-legi",
    "fetched_at": "2026-06-20T12:00:00Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-06-01T00:00:00Z" }
  }
}
  • provenance.source: source identifier from the served store, commonly legifrance-legi for French law or eur-lex-cellar for EU law.
  • freshness.kind: snapshot; as_of is the store dump date that backed the answer.

Fields of data

FieldTypeDescription
codestringEcho of the requested French text identifier (FR requests)
articlestringEcho of the requested article number, when supplied
celexstringEcho of the requested CELEX identifier (EU requests)
elistringEcho of the requested ELI identifier (EU requests)
labelstringHuman label for the article
fromstringEffective date A, YYYY-MM-DD
tostringEffective date B, YYYY-MM-DD
from_versionobject | nullVersion retained at from; null when no version covered that date
to_versionobject | nullVersion retained at to; null when no version covered that date
from_in_forceboolWhether the from side text was the applicable law on that date
to_in_forceboolWhether the to side text is the applicable law on that date
changedbooltrue when the two resolved texts differ
status_changedbooltrue when the status (etat) differs between the two dates
in_force_changedbooltrue when applicability flips between the two dates
differsboolchanged OR status_changed OR in_force_changed - the single field to filter on for a watch
renumbering_suspectedbooltrue when a renumbering break falls between the two dates: the number stopped designating one text and started designating another, so the diff describes a change that never happened
diffobjectDiff payload; empty/zeroed when changed: false

from_version and to_version share the same shape:

FieldTypeDescription
version_idstringStable source identifier for this version
etatstringLEGI / EUR-Lex status carried as stored, never reinterpreted
date_debutstringStart date of this version, inclusive, YYYY-MM-DD
date_finstring | nullEnd date, exclusive; omitted for the current version
continuitystringWhether this version continues the previous one under the same number: same_article, renumbered, unknown. French law only - omitted for EU law

diff

FieldTypeDescription
segmentsarrayStructured word-level diff: [ { op, text } ]
unifiedstringGit-style unified patch (line-granularity)
addednumberTotal characters added
removednumberTotal characters removed

op in segments[] is one of equal, insert, or delete.

Boundary semantics

When a date falls outside all known versions for an article - before its first recorded version, or after the article has been fully repealed and no version before its first recorded version - the service treats that side as empty text rather than returning a 4xx. The diff then represents an insertion from nothing (“added since”) or a deletion to nothing (“removed since”). The null side is signalled by from_version: null or to_version: null.

A repeal no longer produces a null side: the last known version is served on both sides, and in_force_changed carries the event.

This preserves the x402 golden rule: a known article with a valid reference is always a 200, even when one date is uncovered. Only unknown articles and malformed requests return 4xx.

Text change vs status change vs applicability change

Four booleans answer four different questions, and a regulatory watch needs all four to stay honest:

  • changed - did the wording change? It mirrors diff.changed and is the answer to “do I need to re-read the text?”.
  • status_changed - did the status (etat) change? It is true when from_version.etat differs from to_version.etat, and also when only one of the two dates has a version at all (one side null). Statuses are compared as stored, never reinterpreted.
  • in_force_changed - did the text stop (or start) being the applicable law between the two dates? It is from_in_force != to_in_force. This is the only flag that fires on a French-style repeal, where the article’s last version simply ends: both dates then resolve to the same version, so neither changed nor status_changed moves.
  • differs - changed OR status_changed OR in_force_changed. This is the field to filter on when monitoring a citation: it catches a rewritten text, a repeal that left the wording untouched, and a repeal that left the status untouched too.

Why the distinction matters: an EU article repealed by a later act keeps its last known wording in the store, with a new version carrying etat: "abroge". The text is byte-identical, so changed is legitimately false - but the article you are about to cite is no longer in force. Filtering on changed alone silently misses exactly the event that matters most.

Renumbering: when the diff describes a change that never happened

renumbering_suspected answers a fifth, different question: are the two dates even talking about the same article?

A reform can make an article number stop designating one text and start designating another. French Civil Code article 1240 carried “payment made in good faith to the holder of the claim is valid” until 2016-10-01; the reform of the law of obligations then moved the tort provision of the former article 1382 onto that number. Diffing 1240 between 2000 and today therefore returns changed: true with a perfectly credible word-level diff - of two texts that have nothing to do with each other.

  • The diff itself is unchanged: the service adds a signal, it never rewrites or suppresses what the store holds.
  • renumbering_suspected deliberately stays out of differs. It is not one more change event to watch, it is a warning about how to read the diff: when it is true, treat the two sides as two different provisions and follow up with GET /legal/history, where each version carries its own continuity marker.
  • It is true when a version marked continuity: "renumbered" starts inside the interval (from < date_debut <= to).
  • French law only. EU consolidations keep their numbering, so the flag is always false there.

Detection happens when the store is built, from the source filiation first (a version whose text was formally transferred to another number) and from a textual heuristic otherwise - hence suspected: a fully rewritten article raises the same signal as a renumbered one.

GET /legal/diff?celex=32014R0910&article=17&from=2024-01-01&to=2026-07-25
{
  "data": {
    "celex": "32014R0910",
    "article": "17",
    "label": "Regulation (EU) No 910/2014 (eIDAS), Article 17",
    "from": "2024-01-01",
    "to": "2026-07-25",
    "from_version": {
      "version_id": "32014R0910-art-17",
      "etat": "vigueur",
      "date_debut": "2016-07-01",
      "date_fin": "2024-05-20"
    },
    "to_version": {
      "version_id": "32014R0910-art-17-abroge",
      "etat": "abroge",
      "date_debut": "2024-05-20"
    },
    "from_in_force": true,
    "to_in_force": false,
    "changed": false,
    "status_changed": true,
    "in_force_changed": true,
    "differs": true,
    "diff": { "segments": [], "unified": "", "added": 0, "removed": 0 }
  },
  "provenance": {
    "source": "eur-lex-cellar",
    "fetched_at": "2026-07-25T12:00:00Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-07-20T00:00:00Z" }
  }
}

changed keeps its original meaning and is never flipped by a status or applicability change - that would make diff.added / diff.removed contradict it. status_changed, in_force_changed, from_in_force, to_in_force and differs are additive fields; existing integrations are unaffected.

The two corpora encode a repeal in opposite ways. EU law opens a repeal version that starts at the repeal (the example above): the status flips, so status_changed fires. French law simply closes the last version’s interval: both dates resolve to that same version, the text and the status are identical, and only in_force_changed fires. differs covers both, which is why it is the field to watch.

Example - article that changed between two dates (and changed meaning)

Civil Code article 1240 between 2000 and 2026 is also the renumbering case described above: renumbering_suspected is true, and the two sides are two different provisions rather than two states of one.

{
  "data": {
    "code": "code-civil",
    "article": "1240",
    "label": "Code civil, art. 1240",
    "from": "2000-01-01",
    "to": "2026-01-01",
    "from_version": {
      "version_id": "LEGIARTI000006437044",
      "etat": "modifie",
      "date_debut": "1804-03-21",
      "date_fin": "2016-10-01",
      "continuity": "same_article"
    },
    "to_version": {
      "version_id": "LEGIARTI000032041571",
      "etat": "vigueur",
      "date_debut": "2016-10-01",
      "continuity": "renumbered"
    },
    "from_in_force": true,
    "to_in_force": true,
    "changed": true,
    "status_changed": true,
    "in_force_changed": false,
    "differs": true,
    "renumbering_suspected": true,
    "diff": {
      "segments": [
        { "op": "delete", "text": "Le paiement" },
        { "op": "insert", "text": "Tout" },
        { "op": "equal", "text": " fait " }
      ],
      "unified": "@@ -1 +1 @@\n-Le paiement fait de bonne foi a celui qui est en possession de la creance ...\n+Tout fait quelconque de l'homme, qui cause a autrui un dommage ...",
      "added": 18,
      "removed": 22
    }
  },
  "provenance": {
    "source": "legifrance-legi",
    "fetched_at": "2026-06-20T12:00:00Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-06-01T00:00:00Z" }
  }
}

Example - unchanged article (changed: false)

When from and to resolve to the same version, or the text is identical, the diff is empty and the response is still a 200. “No change” is a successful answer.

“Empty” is literal: segments is [], unified is "", added and removed are 0. The endpoint never returns the article’s text as a single equal segment - the consolidated text at a date is what GET /legal/article sells. The version metadata still tells you what you need: both dates resolve to the same version.

{
  "data": {
    "code": "code-civil",
    "article": "1240",
    "label": "Code civil, art. 1240",
    "from": "2020-01-01",
    "to": "2026-01-01",
    "from_version": {
      "version_id": "v-courant",
      "etat": "vigueur",
      "date_debut": "2016-10-01"
    },
    "to_version": {
      "version_id": "v-courant",
      "etat": "vigueur",
      "date_debut": "2016-10-01"
    },
    "from_in_force": true,
    "to_in_force": true,
    "changed": false,
    "status_changed": false,
    "in_force_changed": false,
    "differs": false,
    "diff": {
      "segments": [],
      "unified": "",
      "added": 0,
      "removed": 0
    }
  },
  "provenance": {
    "source": "legifrance-legi",
    "fetched_at": "2026-06-20T12:00:00Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-06-01T00:00:00Z" }
  }
}

Example - from before the first known version (null side)

When from predates the article’s first version, the service treats the from side as empty text. from_version is null and the diff shows a total insertion.

{
  "data": {
    "code": "code-civil",
    "article": "1240",
    "label": "Code civil, art. 1240",
    "from": "1800-01-01",
    "to": "2026-01-01",
    "from_version": null,
    "to_version": {
      "version_id": "v-courant",
      "etat": "vigueur",
      "date_debut": "2016-10-01"
    },
    "from_in_force": false,
    "to_in_force": true,
    "changed": true,
    "status_changed": true,
    "in_force_changed": true,
    "differs": true,
    "diff": {
      "segments": [
        { "op": "insert", "text": "Tout fait quelconque de l'homme, qui cause a autrui un dommage, oblige celui par la faute duquel il est arrive a le reparer." }
      ],
      "unified": "@@ -0,0 +1 @@\n+Tout fait quelconque ...",
      "added": 123,
      "removed": 0
    }
  },
  "provenance": {
    "source": "legifrance-legi",
    "fetched_at": "2026-06-20T12:00:00Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-06-01T00:00:00Z" }
  }
}

Coverage window

coverage_window reports the period the text or act can actually be read over (from inclusive, to exclusive, null when open). It matters here: a bound outside the window is diffed as empty text, which would otherwise read as “the article was created (or deleted) on that date”. Historical depth varies per text — the e-Commerce Directive, in force since 2000, is only covered from 2024-02-17. See /legal/article.

Coverage honesty

The service answers from a local snapshot of the LEGI and EUR-Lex corpora. It does not fetch sources at request time, search by keyword, infer missing versions or interpret the legal significance of changes. The diff is computed purely on the consolidated text as stored; etat is carried as-is from the source store.

provenance.freshness.as_of tells you which dump backed both resolutions. An amendment that postdates the snapshot will not appear until the store is refreshed.

Errors

Only requests the service cannot answer leave the 200 range.

StatuscodeCase
400INVALID_REFNo usable identity: neither French code+article nor EU celex/eli
400INVALID_DATEfrom or to is present but not strictly formatted as YYYY-MM-DD
400INVALID_RANGEfrom is after to (inverted interval)
400MISSING_PARAMETERfrom is absent
404UNKNOWN_ARTICLEThe reference is well formed but no matching article exists in the store
404UNKNOWN_ACTA bare EU act identifier is well formed but no matching act exists
500INTERNALInternal error (detail logged, not exposed)
{ "error": "query parameter `code`+`article` or `celex`/`eli` is required", "code": "INVALID_REF" }
{ "error": "missing or empty required parameter: from", "code": "MISSING_PARAMETER" }
{ "error": "invalid `date` `2026-7-5`; expected ISO calendar format YYYY-MM-DD", "code": "INVALID_DATE" }
{ "error": "`from` (2026-07-25) must not be after `to` (2000-01-01)", "code": "INVALID_RANGE" }
{ "error": "no article `9999` found for text `code-civil` in the store", "code": "UNKNOWN_ARTICLE" }
{ "error": "no act `39999R9999` found in the store", "code": "UNKNOWN_ACT" }

Attribution

French legislation and regulation data is derived from the LEGI dataset made available by the Direction de l’information legale et administrative (DILA) on Legifrance, under the Licence Ouverte / Etalab open licence. EU law data is derived from EUR-Lex / Cellar data made available by the Publications Office of the European Union; reuse must preserve attribution to the European Union and EUR-Lex.

See also

  • GET /legal/history - the full version timeline of an article (when it changed, not what changed).
  • GET /legal/article - full consolidated text of a French article at a date.
  • GET /legal/eu-act - full consolidated text of an EU act or article at a date.
  • For agents - discovery surfaces, the live /catalog and how settlement works.