
API v1
Branchez la simulation
sur votre logiciel
Lancez des simulations de menuiserie depuis votre CRM, votre ERP ou votre application, et récupérez les rendus. Odoo est notre premier intégrateur — l'API, elle, ne connaît aucun logiciel en particulier.
Démarrer en cinq minutes
Une simulation se lance en un appel. La réponse est un 202 : le rendu n'est pas prêt, il arrivera dans la minute.
curl -X POST https://menuiserieai.fr/api/v1/simulations \
-H "Authorization: Bearer mai_live_…" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"category": "portail",
"site_photo_url": "https://votre-domaine.fr/photo.jpg",
"material": "aluminium",
"color": "gris-anthracite",
"numero_devis": "DEV-2026-118"
}'
# → 202 { "id": "job_cbae…", "status": "pending" }Puis suivez l'avancement, toutes les 5 à 10 secondes :
curl https://menuiserieai.fr/api/v1/jobs/job_cbae… \
-H "Authorization: Bearer mai_live_…"
# → { "status": "succeeded", "simulation_id": "sim_…" }
# Lisez ensuite GET /v1/simulations/sim_… pour l'adresse du rendu.simulation.succeeded vers l'adresse de votre choix, signé et rejoué jusqu'à huit fois. Demandez-le en même temps que votre clé.Le parcours complet
Ce qu'un devis produit, du clic de votre commercial au rendu affiché chez vous. En Node ; la transposition dans un autre langage est directe.
const MAI = 'https://menuiserieai.fr/api/v1';
const H = {
Authorization: `Bearer ${process.env.MAI_KEY}`,
'Content-Type': 'application/json',
// Le commercial à qui la simulation sera imputée.
'X-MAI-On-Behalf-Of': 'jean.martin@votre-reseau.fr',
};
// ① Lancer. La clé d'idempotence protège d'un double débit sur relance.
const job = await fetch(`${MAI}/simulations`, {
method: 'POST',
headers: { ...H, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({
category: 'portail',
site_photo_url: 'https://votre-domaine.fr/chantier.jpg',
material: 'aluminium',
color: 'gris-anthracite',
numero_devis: 'DEV-2026-118',
external_ref: 'crm:devis:118', // votre identifiant, republié tel quel
}),
}).then((r) => r.json());
// ② Attendre. Le webhook est préférable ; le sondage reste le repli.
let etat = job;
while (etat.status === 'pending' || etat.status === 'processing') {
await new Promise((r) => setTimeout(r, 5000)); // 5 s, pas moins
etat = await fetch(`${MAI}/jobs/${job.id}`, { headers: H })
.then((r) => r.json());
}
if (etat.status === 'failed') {
// Le crédit n'a PAS été débité : une génération échouée ne coûte rien.
throw new Error(etat.error?.code ?? 'echec');
}
// ③ Lire le rendu. Le détail publie des champs absents de la liste.
const simulation = await fetch(`${MAI}/simulations/${etat.simulation_id}`, {
headers: H,
}).then((r) => r.json());
console.log(simulation.generated_image_url); // à afficher dans votre devisAuthentification
Une clé, un en-tête. Rien d'autre.
Authorization: Bearer mai_live_78ac4b4c755c_…La clé porte une portée — ce qu'elle peut faire — et un périmètre — ce qu'elle peut voir. Une clé d'agence ne verra jamais les dossiers d'une autre agence, quoi qu'elle demande. Ce n'est pas un filtre que vous appliquez : c'est une contrainte que nous appliquons.
Imputer au bon commercial
Une clé appartient à un compte, pas à une personne. Pour qu'une simulation soit attribuée au commercial qui l'a lancée — et non au siège — indiquez son adresse :
X-MAI-On-Behalf-Of: jean.martin@votre-reseau.frL'adresse doit correspondre à celle d'un membre de l'organisation. Elle n'est jamais déclarée : elle est vérifiée. Une adresse inconnue reçoit un ACTOR_NOT_IN_ORGANIZATION, jamais un rattachement approximatif.
Cet en-tête exige la portée act_on_behalf et une autorisation explicite sur la clé — deux verrous, parce qu'imputer une consommation à quelqu'un d'autre n'est pas anodin.
Recevoir les rendus (webhooks)
Plutôt que d'interroger en boucle, laissez-nous vous prévenir. Nous poussons un événement vers l'adresse HTTPS de votre choix, dès que le rendu est prêt.
Les événements
| Événement | Quand |
|---|---|
| simulation.succeeded | Le rendu est prêt. La charge utile porte son adresse. |
| simulation.failed | La génération a échoué. Aucun crédit débité. |
| video.succeeded | Une vidéo est prête. |
| video.failed | La vidéo a échoué. |
| lead.created | Un prospect est arrivé par le simulateur public. |
| lead.routed | Un prospect a été attribué à une agence. |
| lead.updated | Le statut d’un prospect a changé. |
Ce que vous recevez
POST https://votre-domaine.fr/hooks/menuiserieai
X-MAI-Event-Type: simulation.succeeded
X-MAI-Event-Id: evt_9f2a… ← pour dédupliquer
X-MAI-Signature: t=1785312045,v1=8c1f… ← à vérifier, voir plus bas
X-MAI-Delivery-Attempt: 1
{
"id": "evt_9f2a…",
"type": "simulation.succeeded",
"created_at": "2026-09-02T09:14:05Z",
"data": {
"simulation_id": "sim_…",
"job_id": "job_…",
"external_ref": "crm:devis:118",
"numero_devis": "DEV-2026-118",
"generated_image_url": "https://…"
}
}Vérifier la signature
Obligatoire. Sans cette vérification, n'importe qui connaissant votre adresse peut vous injecter de faux rendus. Le format est celui de Stripe — vos bibliothèques existantes s'appliquent.
import { createHmac, timingSafeEqual } from 'crypto';
function verifier(rawBody, entete, secret) {
const parts = Object.fromEntries(
entete.split(',').map((p) => p.split('=')),
);
const t = parts.t;
// ① Rejouer une requête vieille de plusieurs heures ne doit pas passer.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
// ② La signature porte sur « horodatage.corps », corps BRUT — pas
// re-sérialisé : un JSON.parse suivi d'un JSON.stringify change les
// octets, et la signature ne correspondra plus.
const attendu = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// ③ Comparaison en temps constant : une comparaison naïve laisse
// deviner la signature octet par octet.
const a = Buffer.from(attendu);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}X-MAI-Event-Id. C'est la conséquence directe du point précédent : sans cette garde, un rendu pourrait être attaché huit fois au même devis.L'adresse de réception et le secret se configurent avec votre clé : demandez-les en même temps.
Les routes
12 opérations, engendrées depuis le code. Si une route figure ici, elle existe.
| Méthode | Route | Rôle |
|---|---|---|
| POST | /v1/simulations | Lancer une simulation |
| GET | /v1/simulations | Lister les simulations |
| GET | /v1/simulations/{id} | Lire une simulation |
| GET | /v1/jobs/{id} | Suivre un rendu |
| POST | /v1/embed_sessions | Ouvrir un écran MenuiserieAI |
| POST | /v1/uploads | Obtenir une adresse de dépôt |
| GET | /v1/meta | Catégories, matériaux, styles, coloris |
| GET | /v1/products | Le catalogue de modèles |
| GET | /v1/leads | Lister les prospects |
| POST | /v1/leads | Transmettre un prospect |
| GET | /v1/usage | Consommation et quota |
| GET | /v1/agencies | Lister les agences |
Ouvrir nos écrans chez vous
Plutôt que de reproduire notre interface, ouvrez-la. Un appel renvoie une adresse : votre utilisateur y voit l'écran réel de MenuiserieAI, sans se reconnecter, avec son devis déjà rempli.
curl -X POST https://menuiserieai.fr/api/v1/embed_sessions \
-H "Authorization: Bearer mai_live_…" \
-H "X-MAI-On-Behalf-Of: jean.martin@votre-reseau.fr" \
-H "Content-Type: application/json" \
-d '{ "destination": "simulate", "quote_ref": "DEV-2026-118" }'
# → 201 { "url": "https://menuiserieai.fr/embed/simulate#…", "expires_in": 1800 }
# Ouvrez cette adresse dans un onglet. Usage unique.Quinze écrans sont ouvrables : l'assistant de simulation, le studio conversationnel, la bibliothèque, les vidéos, le catalogue, les outils de retouche. La liste est fermée — un chemin libre serait refusé, faute de quoi cette adresse deviendrait une redirection ouverte vers n'importe quelle page authentifiée.
Codes d’erreur
Branchez-vous sur le code, jamais sur le message : le premier est stable, le second peut être reformulé. La colonne Rejouable dit si réessayer a une chance d'aboutir — sur un QUOTA_EXCEEDED, une boucle de reprise ne fera qu'user votre journal.
| HTTP | Code | Rejouable | Signification |
|---|---|---|---|
| 400 | API_VERSION_UNSUPPORTED | non | Version d'API inconnue ou retirée. Mettez à jour le connecteur ; voir l'en-tête Sunset. |
| 400 | IDEMPOTENCY_KEY_REQUIRED | non | L'en-tête Idempotency-Key est obligatoire sur cet endpoint. Générez un UUID v4 par tentative métier et réutilisez-le sur les rejeux. |
| 400 | IDEMPOTENCY_KEY_TOO_LONG | non | La clé d'idempotence dépasse 255 caractères. |
| 400 | INVALID_CURSOR | non | Le curseur de pagination est invalide ou ne correspond plus aux filtres. Relancez la pagination depuis le début, sans curseur. |
| 400 | MALFORMED_JSON | non | Le corps de la requête n'est pas un JSON valide. Corrigez la sérialisation. |
| 401 | INVALID_API_KEY | non | Clé d'API absente ou invalide. Vérifiez l'en-tête « Authorization: Bearer mai_live_… ». Ne réessayez pas : prévenez l'administrateur du compte. |
| 401 | KEY_EXPIRED | non | Cette clé d'API a expiré. Faites-la renouveler avant sa date d'expiration. |
| 401 | KEY_REVOKED | non | Cette clé d'API a été révoquée. Arrêtez les tâches planifiées et demandez une nouvelle clé au siège. |
| 402 | MEMBER_ALLOCATION_EXCEEDED | non | Le plafond individuel de ce commercial est atteint. Son responsable d'agence peut le réajuster. |
| 402 | PLAN_REQUIRED | non | Cette fonctionnalité n'est pas incluse dans l'offre du compte. |
| 402 | QUOTA_EXCEEDED | non | Quota de simulations épuisé pour la période en cours. Ne réessayez pas avant le renouvellement (voir « resets_at » de GET /v1/usage). |
| 402 | SUBSCRIPTION_INACTIVE | non | L'abonnement du compte n'est pas actif. Prévenez la comptabilité ; ne réessayez pas avant régularisation. |
| 402 | VIDEO_QUOTA_EXCEEDED | non | Quota de vidéos épuisé pour la période en cours. |
| 403 | ACTOR_OUT_OF_AGENCY_SCOPE | non | Le commercial indiqué n'appartient pas à l'agence de cette clé. Corrigez la correspondance entre son compte et son membre MenuiserieAI. |
| 403 | AGENCY_OUT_OF_SCOPE | non | Le paramètre « agency_id » est hors du périmètre de cette clé. Retirez le filtre, ou utilisez la clé de siège. |
| 403 | BROWSER_ORIGIN_FORBIDDEN | non | Cette API est serveur-à-serveur. Une clé « mai_live_ » exposée dans un navigateur doit être considérée comme compromise : retirez l'appel du front et faites tourner la clé. |
| 403 | ON_BEHALF_NOT_ALLOWED | non | Cette clé n'est pas autorisée à agir au nom d'un commercial (en-tête X-MAI-On-Behalf-Of). Demandez l'activation au siège. |
| 403 | SCOPE_MISSING | non | Cette clé d'API ne porte pas le droit requis pour cette opération. Demandez au siège d'ajouter le scope indiqué dans « details ». |
| 404 | NOT_FOUND | non | Ressource introuvable. |
| 405 | METHOD_NOT_ALLOWED | non | Méthode HTTP non autorisée sur cette ressource. Voir l'en-tête Allow. |
| 409 | IDEMPOTENCY_CONFLICT | non | Cette clé d'idempotence a déjà été utilisée avec un corps différent. Une clé = une tentative métier : générez-en une neuve. |
| 409 | IDEMPOTENCY_IN_PROGRESS | oui | Une requête portant cette clé est en cours de traitement. |
| 409 | JOB_NOT_CANCELABLE | non | Ce job n'est plus annulable : il est déjà terminé. |
| 409 | WEBHOOK_URL_ALREADY_REGISTERED | non | Cette URL est déjà enregistrée pour cette organisation. |
| 413 | PAYLOAD_TOO_LARGE | non | Le corps de la requête dépasse la limite acceptée. Utilisez le flux d'upload en deux temps (POST /v1/uploads). |
| 415 | UNSUPPORTED_MEDIA_TYPE | non | Type de contenu non supporté. « application/json » est attendu. |
| 422 | ACTOR_NOT_ACTIVE | non | Le commercial indiqué n'a pas d'accès actif (invitation en attente, refusée ou révoquée). Faites accepter l'invitation, ou réassignez. |
| 422 | ACTOR_NOT_IN_ORGANIZATION | non | Le commercial indiqué n'appartient pas à cette organisation. Corrigez le mapping ; voir GET /v1/members. |
| 422 | ACTOR_REQUIRED | non | Aucun commercial n'a été indiqué et cette clé n'a pas d'acteur par défaut. Renseignez l'en-tête X-MAI-On-Behalf-Of. |
| 422 | IMAGE_UNREACHABLE | non | L'URL de la photo de chantier n'est pas accessible depuis nos serveurs. Passez par POST /v1/uploads. |
| 422 | INVALID_CATEGORY | non | Catégorie de menuiserie inconnue. Utilisez un slug de GET /v1/meta/categories. |
| 422 | INVALID_MODE | non | Le mode doit valoir « add » ou « replacement ». |
| 422 | INVALID_STATUS | non | Statut de lead invalide. Valeurs : nouveau, contacte, devis, signe, pose. |
| 422 | NO_IMAGE | non | Cette simulation n'a pas de rendu : la vidéo est impossible. Attendez « status: succeeded ». |
| 422 | VALIDATION_ERROR | non | Le corps de la requête est invalide. Chaque entrée de « details » porte le champ concerné, le motif et la valeur reçue. |
| 422 | WEBHOOK_URL_FORBIDDEN | non | URL de webhook refusée : HTTPS sur le port 443 obligatoire, adresses privées interdites. |
| 429 | CONCURRENCY_LIMIT_REACHED | oui | Trop de générations simultanées pour cette clé ou cette organisation. Réessayez après le délai indiqué, sans augmenter le parallélisme. |
| 429 | DAILY_CAP_REACHED | non | Le plafond quotidien de générations de ce compte est atteint. Reprenez demain, ou demandez un relèvement au siège. |
| 429 | RATE_LIMITED | oui | Trop de requêtes. Réessayez après le délai indiqué par l'en-tête Retry-After. |
| 500 | INTERNAL | oui | Erreur interne. Réessayez une fois avec la même Idempotency-Key, puis transmettez la référence « request_id » au support. |
| 503 | DEPENDENCY_UNAVAILABLE | oui | Un service dont dépend l'API est momentanément indisponible. Réessayez après le délai indiqué, avec la même Idempotency-Key. |
| 503 | PROVIDER_UNAVAILABLE | oui | Le moteur de rendu est indisponible. Aucun crédit n'a été débité ; réessayez après le délai indiqué. |
| 503 | SCHEMA_UNAVAILABLE | non | Incident de configuration côté MenuiserieAI. Ne réessayez pas en boucle : alertez le support avec la référence « request_id ». |
Portées
Demandez la portée minimale. Une clé qui ne fait que lire ne pourra jamais consommer un crédit, même exploitée.
| Portée | Ce qu’elle autorise |
|---|---|
| simulations:read | Lire GET /v1/simulations et GET /v1/simulations/{id}. |
| simulations:write | Créer POST /v1/simulations (202) et supprimer DELETE /v1/simulations/{id}. |
| leads:read | Lire GET /v1/leads. |
| leads:write | Créer POST /v1/leads. |
| uploads:write | Obtenir une URL d'upload signée via POST /v1/uploads. |
| agencies:read | Lire GET /v1/agencies (le réseau vu par la clé). |
| usage:read | Lire GET /v1/usage (quotas, consommation, date de renouvellement). |
| act_on_behalf | Employer l'en-tête X-MAI-On-Behalf-Of pour imputer une action à un commercial précis (et donc à son plafond individuel `TeamMember.allocated_credits`). |
Les pièges à éviter
Ne recopiez pas les catégories
Rapatriez-les depuis GET /v1/meta, une fois par jour. Une valeur écrite en dur sera refusée le jour où le référentiel évolue — et votre commercial ne comprendra pas pourquoi.
Envoyez toujours une Idempotency-Key
Sans elle, une coupure réseau pendant un POST /v1/simulations peut consommer deux crédits pour une seule demande. Avec elle, rejouer le même appel renvoie le même travail.
C'est l'image du modèle qui pilote le rendu
product_reference_url est la référence technique à reproduire. Les libellés — matériau, style, coloris — n'alimentent que le devis. Une photo de modèle vaut mieux que dix champs bien remplis.
La photo du chantier doit être en HTTPS
Une adresse en clair est refusée. Si votre photo n'est pas publiquement accessible, passez par POST /v1/uploads, qui vous donne une adresse de dépôt signée.
Paginez par curseur, jamais par décalage
Utilisez next_cursor. Un décalage numérique décale toutes vos pages dès qu'une simulation est créée pendant votre parcours.
N'exposez jamais votre clé côté navigateur
Elle donne accès à l'ensemble de votre périmètre. Les appels partent de votre serveur ; pour l'interface, utilisez POST /v1/embed_sessions, dont l'adresse est à usage unique et expire en 30 minutes.
Une question ?
Chaque réponse d'erreur porte un request_id. Citez-le : il nous permet de retrouver l'appel exact, sans avoir à vous demander de reproduire le problème.

L'Intelligence Artificielle au service de la menuiserie
Besoin d'aide ?
Réponse sous 24h