Version 2.2 du 09/09/2026 : ajout du flux sessions d'examen (dates, clotures, places) et de la section attribution marketing (UTM). Version 2.0 testée de bout en bout (XML-RPC, JSON-RPC, et tir réel des 2 webhooks). Contact technique : direction NEOSEN.
sale.order), de leurs lignes (sale.order.line), du catalogue produits (product.template, product.product) et des devises (res.currency).Limites importantes (par conception, périmètre « ventes seules ») :
account.move) ne sont pas lisibles en API. Le webhook paiement vous notifie, mais vous ne pouvez pas re-lire la facture ; vous rebasculez sur la commande via le champ invoice_origin (voir §6).mail.message, chatter) n'est pas accessible : il contient des échanges nominatifs avec les clients, hors périmètre.| Paramètre | Valeur |
| URL | `https://www.digimoov.fr` |
| Base de données | `marouane177-odoo-17-production-19137003` |
| Login | `goralab-api@digimoov.fr` |
| Mot de passe | la clé API (transmise séparément par canal sécurisé, jamais par email) |
Protocoles : XML-RPC (/xmlrpc/2/) ou JSON-RPC (/jsonrpc). Les deux sont testés et fonctionnels. La clé API s'utilise à la place du mot de passe.
import xmlrpc.client
URL = "https://www.digimoov.fr"
DB = "marouane177-odoo-17-production-19137003"
LOGIN = "goralab-api@digimoov.fr"
API_KEY = "..." # transmise séparément
common = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common")
uid = common.authenticate(DB, LOGIN, API_KEY, {}) # entier stable, cacheable
models = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object")
# Ventes confirmées depuis une date (curseur incrémental sur write_date)
orders = models.execute_kw(DB, uid, API_KEY, "sale.order", "search_read",
[[["state", "=", "sale"], ["write_date", ">=", "2026-09-01 00:00:00"]]],
{"fields": ["name", "date_order", "state", "amount_untaxed", "amount_total",
"currency_id", "partner_id", "company_id", "order_line", "write_date"],
"limit": 200, "order": "write_date asc"})
# Lignes d'une commande
lines = models.execute_kw(DB, uid, API_KEY, "sale.order.line", "read",
[orders[0]["order_line"]],
{"fields": ["product_id", "name", "product_uom_qty", "price_unit", "price_total"]})
En search_read/read, les champs relationnels reviennent en paire [id, libellé] :
partner_id = [88298, "MIMMIH Nabil"], currency_id = [1, "EUR"], company_id = [2, "DIGIMOOV"], order_line = [id1, id2, ...].
→ C'est ainsi que vous obtenez le nom du client (même si l'accès direct au partenaire est bloqué).
curl -s https://www.digimoov.fr/jsonrpc -H 'Content-Type: application/json' -d '{
"jsonrpc": "2.0", "method": "call",
"params": {"service": "object", "method": "execute_kw",
"args": ["marouane177-odoo-17-production-19137003", UID, "API_KEY",
"sale.order", "search_read",
[[["state", "=", "sale"]]],
{"fields": ["name", "date_order", "amount_total"], "limit": 10}]}}'
UID = l'entier renvoyé par common.authenticate (stable, cacheable).
sale.order : name, date_order, state (draft/sent/sale/cancel), amount_untaxed, amount_total, currency_id, partner_id, company_id, order_line, write_date.
sale.order.line : order_id, product_id, name, product_uom_qty, price_unit, price_subtotal, price_total.
AAAA-MM-JJ HH:MM:SS en UTC (pas d'heure locale : convertissez côté Goralab).currency_id). Ne supposez pas EUR partout : lisez toujours currency_id.company_id : 1 = MCM ACADEMY, 2 = DIGIMOOV.limit/offset et un curseur incrémental sur write_date (jamais un export complet répété).Ces champs sont déjà accessibles avec votre clé, sans aucune donnée personnelle. Vous n'avez rien à demander de plus.
Sur sale.order :
| Champ | Type | Contenu |
| `campaign_id` | many2one | `[id, "nom_de_campagne"]` |
| `medium_id` | many2one | `[id, "cpc"]`, `[id, "email"]`… |
| `source_id` | many2one | `[id, "google"]`, `[id, "facebook"]`… |
| `conversion_utm_campaign` / `_medium` / `_source` | texte | champs bruts d'un module interne, rarement remplis (~130 commandes) |
Les référentiels sont lisibles directement : utm.campaign (349 enregistrements), utm.medium (36), utm.source (1 211), champ name.
Taux de couverture, à intégrer dans vos calculs : sur ~43 900 commandes, seules 5 750 portent une campagne et 6 181 une source, soit environ 13 %. Les 87 % restants n'ont pas d'attribution en base : ne les comptez pas comme « direct », comptez-les comme « inconnu ».
Qualité des données : le référentiel contient des valeurs polluées par des gabarits de tracking mal substitués ({campaign}, %7Bmedium%7D, 1). Filtrez-les avant analyse plutôt que de les traiter comme de vraies campagnes.
Exemple, lecture des commandes attribuées :
cmds = ex('sale.order', 'search_read',
[[('campaign_id', '!=', False), ('date_order', '>=', '2026-01-01 00:00:00')]],
{'fields': ['name', 'date_order', 'amount_total', 'currency_id', 'state',
'campaign_id', 'medium_id', 'source_id'], 'limit': 500})
Exemple, agrégation directe côté Odoo (plus rapide qu'un export, et strictement anonyme) :
ca = ex('sale.order', 'read_group',
[[('campaign_id', '!=', False), ('state', 'in', ['sale', 'done'])]],
{'fields': ['amount_total'], 'groupby': ['campaign_id'], 'orderby': 'amount_total desc'})
# -> [{'campaign_id': [313, '19965112228'], 'campaign_id_count': 53, 'amount_total': 39360.0}, ...]
Piège webhook : comme tous les champs relationnels, le webhook renvoie un entier nu (campaign_id: 387), pas [id, nom]. Re-fetchez le nom via utm.campaign / utm.medium / utm.source, qui vous sont ouverts en lecture.
Ces données ne passent pas par l'API Odoo. Le modèle des sessions contient des données candidats et des identifiants techniques, donc nous ne l'ouvrons pas. À la place, nous publions un flux JSON qui ne contient que ville, dates et places : ni candidat, ni jury, ni surveillant, ni identifiant.
| Flux | `GET https://goralab-feed.formalab.tech/sessions.json` |
| Authentification | en-tête `Authorization: Bearer <jeton>` (ou `?token=<jeton>`) |
| Supervision | `GET https://goralab-feed.formalab.tech/health` — public, sans jeton |
| Rafraîchissement | toutes les 30 minutes depuis Odoo |
| Périmètre | **Digimoov uniquement** (MCM Academy est hors périmètre), sessions à venir |
Le jeton vous est transmis par lien sécurisé à usage unique, séparément de ce document.
curl -H "Authorization: Bearer $GORALAB_FEED_TOKEN" \
https://goralab-feed.formalab.tech/sessions.json
Champs de chaque session :
| Champ | Sens |
| `session` | libellé, contient la ville et la date (ex. `Lyon 27 octobre 2026`) |
| `date_examen` | date de l'examen |
| `date_ouverture_inscriptions` / `date_cloture_inscriptions` | fenêtre de commercialisation |
| `jours_avant_cloture` | négatif = déjà clôturée |
| `inscriptions_ouvertes` | booléen |
| `places_max`, `places_prises`, `places_restantes`, `complet` | remplissage |
| `vendable` | **inscriptions ouvertes ET places restantes > 0** |
| `date_debut_session`, `date_fin_session`, `date_jury` | jalons pédagogiques |
Règle de clôture, importante pour le pilotage des campagnes : une session ouvre 120 jours avant l'examen et ferme automatiquement un nombre de jours avant l'examen défini session par session, le plus souvent 42 ou 35 jours. Une session peut donc afficher des places libres tout en étant déjà fermée à la vente.
Pilotez sur vendable, jamais sur places_restantes seul. Exemple réel au 09/09/2026 : Nantes 15 septembre affiche 30 places libres, mais sa clôture était le 11 août : pousser du trafic dessus, c'est brûler du budget. À la même date, 16 sessions sont réellement vendables, et Bordeaux 27 octobre clôture dans 6 jours.
L'en-tête du flux porte genere_le, nombre_sessions, nombre_sessions_vendables et anomalies_coherence (doit rester vide ; une valeur non vide signale une incohérence entre places max, prises et disponibles côté Odoo, à nous signaler).
Deux notifications sortantes (POST JSON, Content-Type: application/json). Le type d'événement se lit sur le champ _model :
| Événement | `_model` | Déclencheur | Fréquence |
| Vente confirmée | `sale.order` | la commande passe à l'état `sale` | à chaque confirmation |
| Paiement reçu | `account.move` | la facture entre en `in_payment` ou `paid` | **une seule fois par facture** (pas de doublon) |
Payload réel (tel que reçu lors des tests) :
Vente :
{"_model": "sale.order", "_id": 54510, "_action": "GORALAB_vente_confirmee(#4727)",
"id": 54510, "name": "S54510", "state": "sale",
"date_order": "2026-09-02 16:21:08", "amount_untaxed": 100.0, "amount_total": 100.0,
"partner_id": 88347, "company_id": 2}
Paiement :
{"_model": "account.move", "_id": 23881, "_action": "GORALAB_paiement_recu(#4728)",
"id": 23881, "name": "FAD210425-02175", "payment_state": "paid",
"invoice_date": "2026-09-02", "invoice_origin": "S54510",
"amount_total": 100.0, "partner_id": 88347, "company_id": 2}
Points à respecter impérativement (sinon « ça marche pas ») :
1. Discriminez par _model (sale.order vs account.move). N'analysez pas _action (c'est un libellé humain, pas un type stable).
2. Dans le webhook, les champs relationnels sont des ID entiers NUS : partner_id: 88347, company_id: 2 : pas [id, nom] comme dans search_read. Vous n'avez pas le nom dans le payload.
3. Le webhook est une notification, pas la donnée complète. Modèle recommandé :
- Vente : sur réception, faites read/search_read sur sale.order id=_id → vous obtenez le nom du client et le détail des lignes.
- Paiement : account.move n'est pas lisible en API. Utilisez invoice_origin (ici "S54510") pour faire search_read sur sale.order où name = invoice_origin → vous récupérez client + montants.
4. Paiement, valeur de payment_state : peut valoir in_payment (paiement enregistré, en attente de rapprochement bancaire) ou paid (soldé). L'événement ne part qu'une fois ; traitez-le en upsert idempotent sur name (numéro de facture).
5. Sécurité : nous mettrons un token secret en query string (?token=...). Vérifiez-le et rejetez toute requête sans le bon token. (Odoo n'envoie pas de signature HMAC.)
6. Pas de retry : le webhook Odoo est « send and forget », timeout 1 s, aucune reprise si votre endpoint est indisponible. Prévoyez impérativement un poll de réconciliation (ex. toutes les 15 min : search_read sur write_date >= dernier_curseur) pour rattraper les événements manqués. Le temps réel est un confort, le poll est le filet de sécurité.
1. L'URL HTTPS de votre endpoint webhook (nous y ajoutons le token et activons les 2 automatisations, désactivées aujourd'hui).
2. Un contact technique pour un test d'activation en réel ensemble.
Ce document est directement exploitable par votre Claude Code : payloads réels, chemins de re-fetch et pièges connus y sont explicites.