French public buyers advertise their contracts in the BOAMP, the Bulletin officiel des annonces des marchés publics. It's run by the DILA, the French government's legal-information directorate. According to the DILA, notices above the EU thresholds have to be published there, and below them it's one of the allowed channels. Every notice is also released as open data on an Opendatasoft portal, refreshed twice a day, seven days a week, under the Licence Ouverte / Open Licence 2.0. There's no account and no API key.

boamp.fr has its own search. If you want the notices in your own tools (a spreadsheet, a morning email filtered your way), the API is easier. Here's what the script in this tutorial does:

  1. pulls every notice published on a given day, within the API's paging limits,
  2. tells new tenders, corrections and award notices apart,
  3. digs CPV codes out of three different notice formats,
  4. filters by CPV, French keywords, département, procedure and market type,
  5. follows corrections (avis rectificatifs) to notices you've already seen,
  6. writes CSV and an email-ready text file, remembers what it has reported, and is scheduled daily.

Dependencies: Python 3 (tested on 3.13) plus requests. The full script is at the end.

The endpoint

GET https://boamp-datadila.opendatasoft.com/api/explore/v2.1/catalog/datasets/boamp/records 

It's the standard Opendatasoft Explore API v2.1 (dataset id boamp), with filters written in ODSQL, a small SQL-like language:

Parameter Meaning
where ODSQL filter, e.g. dateparution = date'2026-10-16'
order_by Sort order. Set it, or offset paging isn't reliable
limit Records per page, at most 100
offset Index of the first record
select, group_by Pick fields, or aggregate (count(*))

Try it:

curl -s -G "https://boamp-datadila.opendatasoft.com/api/explore/v2.1/catalog/datasets/boamp/records" \ --data-urlencode "where=dateparution = date'2026-10-16'" \ --data-urlencode "select=idweb, objet, nomacheteur, famille, nature_libelle" \ --data-urlencode "limit=3" | python -m json.tool 

The answer is {"total_count": ..., "results": [...]}. When we looked on 17 October 2026, the dataset held 1,717,457 notices.

Two limits shape the whole design:

  • offset + limit can't go above 10,000. We asked for offset=9950&limit=100 and got HTTP 400: "Invalid value for sum of offset + limit API parameter: 10050 was found but <= 10000 is expected." For bigger pulls the docs point to the /exports endpoint.
  • A daily quota. The docs list HTTP 429 for "too many requests", and responses carry X-RateLimit-* headers (reset at midnight UTC). We saw a limit of 2,000,000 on /records (10,000 on the metadata call), and the remaining count fell faster than our own calls, so it looks like a shared anonymous pool. A daily alert needs about ten requests, but back off on 429 anyway.

How much is a day? Between 3 and 16 October 2026, weekdays had 223 to 584 notices, and BOAMP published at weekends too (124 and 143 on the Saturdays, 317 and 323 on the Sundays). Two weeks came to 5,276, so a month would hit the offset cap. The script asks for one publication day at a time.

What one record looks like

Here's a real national-form notice from 16 October 2026, trimmed:

{ "idweb": "26-100546", "dateparution": "2026-10-16", "famille": "FNS", "nature_libelle": "Avis de marché", "etat": "INITIAL", "objet": "Maintenance et assistance du logiciel Businessobjects", "nomacheteur": "Département de la Lozère", "code_departement": ["48"], "type_procedure": "OUVERT", "type_marche": ["SERVICES"], "descripteur_libelle": ["Logiciel", "Maintenance", "Informatique (assistance)"], "datelimitereponse": "2026-11-09T12:00:00+00:00", "annonce_lie": null, "url_avis": "https://www.boamp.fr/pages/avis/?q=idweb:26-100546", "gestion": "{\"REFERENCE\": {...}, \"INDEXATION\": {...}}", "donnees": "{\"FNSimple\": {\"organisme\": {...}, \"initial\": {...}}}" } 
  • idweb is the notice id, unique per published notice.
  • famille tells you the format. JOUE is a European notice in eForms, FNS is the national simplified form (the dataset labels it "Marchés entre 90 k€ et seuils européens"), MAPA is an adapted-procedure notice ("Marchés <90 k€"), and there's the occasional DSP (public-service delegation).
  • donnees is a JSON document stored as a string, and its schema depends on famille. It holds most of the detail, CPV codes included (there's no CPV column).
  • descripteur_libelle is BOAMP's own subject vocabulary (more on it below).
  • Personal data. donnees often names a contact person, with a direct e-mail address and phone number. The DILA reminds reusers that French data-protection law still applies, so our monitor keeps organisation names only.

Step 1: fetch one day, paginate politely

API = "https://boamp-datadila.opendatasoft.com/api/explore/v2.1/catalog/datasets/boamp/records" USER_AGENT = "LindenwerkData-boamp-monitor/1.0 (+https://dev.to/lindenwerkdata; open-data tutorial)" def fetch_day(s, day, natures): where = f"dateparution = date'{day.isoformat()}'" if natures: where += " and (" + " or ".join(f"nature_libelle = {odsql_quote(n)}" for n in natures) + ")" records, offset, pages = [], 0, 0 while True: params = {"where": where, "order_by": "idweb", "limit": 100, "offset": offset} data = get_with_retry(s, params).json() # timeout, backoff on 429/5xx  records.extend(data.get("results") or []) pages += 1 offset += 100 if offset >= data.get("total_count", 0): break if offset + 100 > 10_000: # can't happen for one day, but be explicit  print(f"warning: {day} is too big for /records, use /exports", file=sys.stderr) break time.sleep(1) return records, pages 

Set order_by. Without a fixed order, nothing guarantees that consecutive pages line up, so sort on the unique idweb. Accented values like "Avis de marché" work in where; let requests do the URL encoding.

On 17 October 2026 the full feed for 16 October was 562 records on 6 pages (about 9 seconds with a 1-second pause). With the default filter (tenders and corrections only), the script fetched 865 records for 15 and 16 October in 10 requests, in about 16 seconds.

A small trap if you use group_by to count things: fields such as type_avis and code_departement are multi-valued, and grouping counts a notice once per value. Our first per-type count for 16 October added up to 1,100, for a day with 562 notices.

Step 2: know your notice types, and the two shapes of a correction

The 562 notices of 16 October were 410 Avis de marché (calls for competition), 127 Résultat de marché (awards), 24 Rectificatif and 1 Modification. An alert wants the first group plus corrections, and corrections come in two shapes:

  • European (eForms) corrections are full notices. They carry nature_libelle = "Avis de marché", etat = "RECTIFICATIF", and the original notice's id in annonce_lie. On 16 October, 22 of the 410 "Avis de marché" were these. If you only filter on nature_libelle, they look like brand-new tenders.
  • National and MAPA corrections are separate Rectificatif notices. They carry the original id and a free-text infosRectif, and datelimitereponse is empty. That was true for all 55 of them in our two-day window. On 16 October, 22 of the 24 moved the response deadline, and the new date was written in at least six ways: "Au lieu de 04/11/2026 à 12h00, lire 18/11/2026 à 12h00", "reportée au 17 novembre 2026 à 12H00mn", "déplacée au 13.11.2026 à 18h00", a weekday plus a date, or a small JSON object with two ISO timestamps.

Don't parse that text. Show it to a human:

is_correction = rec["nature_libelle"] == "Rectificatif" or rec["etat"] == "RECTIFICATIF" corrects = (rec.get("annonce_lie") or [""])[0] if is_correction else "" 

In the two-day window, 99 of the 102 corrections named their original, but only 4 of those originals were in the same window. Most corrections refer to older notices, which is why the state file below matters.

Step 3: CPV codes are buried in donnees

National forms keep CPV codes under .../codeCPV/objetPrincipal/classPrincipale, sometimes per lot. eForms keep them in cbc:ItemClassificationCode elements, with the code under #text. Rather than hard-code a dozen paths, walk the document:

def walk(obj, path=""): if isinstance(obj, dict): for k, v in obj.items(): yield from walk(v, f"{path}/{k}") elif isinstance(obj, list): for v in obj: yield from walk(v, path) else: yield path, obj def cpv_codes(donnees): found = [] for path, value in walk(donnees): if ("CPV" in path or "ItemClassificationCode" in path) and re.fullmatch(r"\d{8}(-\d)?", str(value)): code = str(value)[:8] if code not in found: found.append(code) return found 

Check the path, not just the value: eight-digit numbers also appear as amounts and ids.

Now the gaps. Of the 763 new tenders in the window, all 66 MAPA notices had no CPV code at all, and 14 of 347 national-form notices had none either. The eForms notices all had one. A CPV-only filter would never show you a MAPA notice. That's where descripteur_libelle helps: every one of the 763 had BOAMP descriptors (239 different labels in the window), so the script matches keywords against the title and the descriptors.

Also: objet is a shortened title (never over 200 characters in our data, and once re-cased from "SIRH" to "Sirh"); the full title is inside donnees. And text in donnees can be HTML-escaped: 149 of the 865 records contained entities such as &amp;, a few escaped twice (&amp;lt;br/&amp;gt;). Unescape before you display.

Step 4: départements and accents

code_departement is a list, and it has two quirks. BOAMP drops leading zeros ("7" for Ardèche, not "07"), and it writes Corsica as "20A" and "20B" instead of the official 2A and 2B. A filter for 2A would have found nothing, though 14 notices in our window were for Corsica. Also, 170 of the 763 tenders listed more than one département (up to 9), so treat the field as "any of".

def norm_dept(code): code = str(code).strip().upper() if code in ("20A", "20B"): return "2" + code[-1] return code.zfill(2) if code.isdigit() and len(code) < 2 else code def fold(text): text = text.replace("’", "'").replace("œ", "oe").replace("Œ", "OE") text = unicodedata.normalize("NFKD", text) return "".join(c for c in text if not unicodedata.combining(c)).lower() 

Accent folding isn't optional. BOAMP's own descriptor is spelled Electricité (travaux), with no accent on the capital E. In the window, a plain case-insensitive search for "électricité" found 3 notices, and the folded search found 55. Apostrophes vary too: 117 of the 865 titles used the typographic ’, so système d’information and système d'information need to become the same string. The keyword regex anchors only at the start of a word, so logiciel also finds logiciels.

Step 5: don't do arithmetic on datelimitereponse

Every datelimitereponse ends in +00:00, but that suffix doesn't mean the same thing across notices:

  • In all 414 non-eForms notices with a deadline (national, MAPA and one DSP), the value is simply the clock time from the notice with +00:00 stuck on. "12:00" means noon local time, not noon UTC.
  • In 327 of the 373 eForms notices, it really was converted to UTC (12:00+01:00 became 11:00+00:00). In the other 46, the clock time was copied unchanged, even when the notice said +02:00.
  • The original values in the gestion JSON carry their own offsets, including +03:00, +04:00 and -04:00 for buyers in Mayotte, La Réunion, Guadeloupe and Saint-Martin.

For an alert, show the deadline as written, offset included:

def deadline_as_written(rec): gestion = json.loads(rec.get("gestion") or "{}") raw = (gestion.get("INDEXATION") or {}).get("DATE_LIMITE_REPONSE") or rec.get("datelimitereponse") if not raw: return "" offset = re.search(r"(Z|[+-]\d\d:\d\d)$", raw) return raw[:16].replace("T", " ") + (f" {offset.group(1)}" if offset else "") 

Also, 21 of the 763 tenders had no deadline at all.

Step 6: filter, follow corrections, remember

A notice matches on a CPV prefix or a keyword; hard filters (département, procedure, market type, excluded words) can then remove it. The state file is a small JSON dict of idweb to publication date, pruned after 180 days:

seen = load_state(state_path) for r in rows: if r["idweb"] in seen: continue reasons = matches(r, args) if not reasons and r["kind"] == "correction" and r["corrects"] in seen: reasons = [f"corrects {r['corrects']}, which you were sent before"] if reasons: r["reasons"] = "; ".join(reasons) out.append(r) seen.update({r["idweb"]: r["published"] for r in out}) 

The second rule is the useful one: a correction gets through if it matches your profile or fixes a notice you were sent, even when it's too thin to match on its own.

On the two-day window, an IT profile (--cpv 72 48 --keyword informatique logiciel cybersécurité "système d'information") reported 33 notices: 26 new tenders and 7 corrections. Nine of the 33 had no matching CPV code and came in through keywords, including 3 MAPA notices. Descriptors also bring noise: a laser projector and ticketing system for a small cinema matched "informatique" through its descriptors alone. Read the why column before you trust a match. Other profiles on the same data:

  • Brittany works (--cpv 45 --dept 22 29 35 56 --procedure OUVERT PROCEDURE_ADAPTE): 15
  • Corsica, anything (--dept 2A 2B): 14
  • Open-procedure services in Paris and the inner suburbs (--dept 75 92 93 94 --market SERVICES --procedure OUVERT): 62

Replaying the same saved records with the same state file produced 0. While tuning, save the raw records once with --save-raw raw.json and iterate offline with --from-file raw.json.

Step 7: output and attribution

The CSV is encoded as utf-8-sig. Microsoft's support page says Excel opens a UTF-8 CSV normally only if it was saved with a byte-order mark; otherwise you have to go through the import dialog. The text file is ready to paste into an email:

BOAMP alert for 2026-10-15 to 2026-10-16: 26 new notice(s), 7 correction(s) * Maintenance et assistance du logiciel Businessobjects Département de la Lozère | dept 48 | OUVERT | deadline 2026-11-09 12:00 why: CPV 72; 'informatique'; 'logiciel' https://www.boamp.fr/pages/avis/?q=idweb:26-100546 Corrections (avis rectificatifs): * Acquisition et déploiement d'un Système d'Information de Gestion des Ressources Humaines (Sirh) ... (corrects 26-95318) Rubrique : Date et heure limites de réception des plis, Au lieu de 04/11/2026 à 17h00, lire 16/11/2026 à 17h00 ... https://www.boamp.fr/pages/avis/?q=idweb:26-100060 

The Licence Ouverte allows free reuse, commercial too, as long as you credit the source. The DILA's reuse conditions ask you to name the DILA, the download URL, and the file and its date, so every output ends with the DILA credit, the dataset and API URLs, and the retrieval timestamp. The script also scrubs e-mail addresses and phone numbers from correction text.

Step 8: run it every day

BOAMP publishes at weekends, so run it every day. Fetching the last two days and letting the state file drop repeats also catches late additions.

Cron (it uses the machine's clock; write % as \%):

17 7 * * * cd $HOME/boamp-monitor && TZ=Europe/Paris ./venv/bin/python fr_boamp_monitor.py --days 2 --cpv 72 48 --keyword informatique logiciel --state seen.json --csv "out/$(date +\%F).csv" --text "out/$(date +\%F).txt" >> monitor.log 2>&1 

GitHub Actions (.github/workflows/boamp-monitor.yml). It commits seen.json back, so the state survives between runs:

name: BOAMP tender monitor on: schedule: - cron: "17 7 * * *" timezone: "Europe/Paris" workflow_dispatch: {} permissions: contents: write jobs: monitor: runs-on: ubuntu-latest timeout-minutes: 15 env: TZ: Europe/Paris steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: {python-version: "3.12"} - run: pip install -r requirements.txt - run: python fr_boamp_monitor.py --days 2 --cpv 72 48 --keyword informatique logiciel --state seen.json --csv boamp.csv --text boamp.txt - uses: actions/upload-artifact@v4 with: {name: "boamp-${{ github.run_id }}", path: "boamp.*", retention-days: 30} - run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add seen.json git diff --cached --quiet || git commit -m "BOAMP state $(date +%F)" git push 

From GitHub's docs: the default schedule time zone is UTC (override it with timezone); runs may start late when GitHub is busy, the top of the hour above all, and some are dropped under heavy load; and in a public repository, schedules are disabled after 60 days without activity. A private repository suits your filters and state file better anyway.

Where to take it next

  • Add a "closing soon" view. One eForms correction in our window carried a deadline that had already passed by the time we ran.
  • Switch --nature to "Résultat de marché" to watch award notices. titulaire holds the winners' names, so check for personal data before you store it.
  • Always open the official notice on boamp.fr before you respond.

Official documentation

Full script: fr_boamp_monitor.py (334 lines, requests only)
#!/usr/bin/env python3 """Daily French public-tender alert built on the BOAMP open-data API. BOAMP (Bulletin officiel des annonces des marchés publics) is published by the DILA as open data on an Opendatasoft portal (Explore API v2.1). Free, no account, no API key. Data: DILA, BOAMP, reused under the Licence Ouverte / Open Licence v2.0. Usage examples: python fr_boamp_monitor.py --from 2026-10-15 --to 2026-10-17 --cpv 72 48 --keyword informatique logiciel python fr_boamp_monitor.py --yesterday --cpv 45 --dept 29 35 56 22 --procedure OUVERT PROCEDURE_ADAPTE python fr_boamp_monitor.py --days 3 --keyword nettoyage --state seen.json # only what you haven't seen """ from __future__ import annotations import argparse import csv import html import json import re import sys import time import unicodedata from datetime import date, datetime, timedelta, timezone from pathlib import Path from zoneinfo import ZoneInfo import requests DATASET_PAGE = "https://boamp-datadila.opendatasoft.com/explore/dataset/boamp/" API = "https://boamp-datadila.opendatasoft.com/api/explore/v2.1/catalog/datasets/boamp/records" USER_AGENT = "LindenwerkData-boamp-monitor/1.0 (+https://dev.to/lindenwerkdata; open-data tutorial)" PAGE_SIZE = 100 # maximum for /records without group_by OFFSET_CAP = 10_000 # offset + limit may not go above this, so we query one publication day at a time PAUSE_BETWEEN_PAGES = 1.0 PARIS = ZoneInfo("Europe/Paris") # Tender notices plus the national correction notices. European corrections arrive as "Avis de marché" # records with etat = RECTIFICATIF, so they are included by the first value. DEFAULT_NATURES = ["Avis de marché", "Rectificatif"] KINDS = {"Avis de marché": "new", "Résultat de marché": "result"} # ---------------------------------------------------------------- 1. fetch one day, paginate politely def odsql_quote(value: str) -> str: return '"' + value.replace("\\", "\\\\").replace('"', Source:https://dev.to/lindenwerkdata/build-a-daily-french-public-tender-alert-in-python-with-the-free-boamp-open-data-api-3c1i

Related posts