API publique
Les systèmes tiers (Zapier / Make, back-offices, scripts) lisent et écrivent les données du CRM en HTTPS, sur l'origine du déploiement Convex :
https://<deployment>.convex.site/api/v1/
Authorization: Bearer wap_<keyId>_<secret>
Les clés se créent dans Paramètres → Clés d'API (nom, portées, expiration
optionnelle). Le secret n'est affiché qu'une fois ; seule son empreinte
salée est stockée. Une clé se révoque sans délai ; sa création, sa modification
et sa révocation sont journalisées dans auditLogs (entityType: 'apiKey').
Une clé voit et modifie toutes les fiches de l'organisation. L'API est une surface serveur-à-serveur : les portées limitent les ressources, pas le périmètre — la grille rôles × modules et les équipes ne s'y appliquent pas. Pas d'en-têtes CORS : une clé ne doit jamais vivre dans un navigateur.
Ressources et portées
| Ressource | Routes | Portées |
|---|---|---|
| Sonde | GET /me (nom, portées, expiration de la clé) | n'importe quelle clé valide |
| Contacts (leads) | GET /contacts[?email=], GET /contacts/:id, POST /contacts, POST /contacts/upsert, PATCH /contacts/:id, DELETE /contacts/:id | contacts:read / contacts:write |
| Entreprises | GET /companies[?domain=], GET /companies/:id, POST, PATCH, DELETE | companies:read / companies:write |
| Transactions | GET /deals[?leadId=], GET /deals/:id, POST, PATCH, DELETE | deals:read / deals:write |
| Activités | GET /activities[?leadId=], GET /activities/:id, POST, PATCH, DELETE | activities:read / activities:write |
| Listes | GET /lists, GET /lists/:id/members | lists:read (+ contacts:read pour les membres, qui sont des contacts complets) |
| Propriétés | GET /properties?entityType=lead|company|deal|activity | properties:read |
Une portée d'écriture inclut la lecture de la même ressource (contacts:write
suffit pour lire les contacts et pour recevoir la fiche renvoyée par une
écriture). Les réponses sont des DTO explicites (id, createdAt, updatedAt,
champs publics) : jamais de document brut, jamais de jeton de consentement ni
de champ interne. Les fiches supprimées (soft delete) sont invisibles partout.
Lecture
- Pagination par curseur :
?limit=(50 par défaut, 100 max) et?cursor=; réponse{ "data": [...], "nextCursor": "..." | null }. Une page peut être plus courte quelimit(fiches supprimées filtrées) : itérer jusqu'ànextCursor: null, ne pas se fier à la taille de la page. - Tri par date de création décroissante. Ce n'est pas un mécanisme de synchronisation incrémentale (prévu avec les webhooks sortants).
Écriture
- Corps JSON ;
POSTrenvoie201et la fiche,PATCH200et la fiche,DELETE204(suppression douce, comme l'interface). - Contacts :
firstName,lastNameetemailsont obligatoires surPOST /contactsetPOST /contacts/upsert(400 field_required, le champ dansdetails.field), etPATCHne peut ni les vider ni les passer ànull. Le formulaire de l'interface applique la même règle. PATCHest partiel : seuls les champs fournis changent,nullvide un champ optionnel.customProperties(clés = ids deGET /properties) fusionne clé par clé,nullretire une clé ; un id de propriété inconnu est refusé (unknown_property).- Consentement en lecture seule :
marketingConsent,consentSource,consentUpdatedAtsont renvoyés mais refusés en écriture (read_only_field) — la trace RGPD reste celle de la page de consentement et des formulaires. Idem pour les champs calculés (score, compteurs). POST /contactsest une création stricte : un contact vivant avec le même e-mail (normalisé) renvoie409 duplicate_emailavec l'existingIddansdetails.PATCHapplique la même règle à un changement d'e-mail.POST /contacts/upsertcrée ou fusionne par e-mail, avec les règles de l'import CSV : seuls les champs fournis écrasent, les propriétés fusionnent, une fiche supprimée est ravivée, le statut d'une fiche existante n'est jamais touché. Si plusieurs fiches partagent l'e-mail, la plus ancienne vivante gagne. Réponse{ "created": true|false, "data": {…contact} }(201/200).- Rattachement d'entreprise d'un contact :
companyIdexplicite, oucompany: { name, domain, registrationNumber, vatNumber, country }(correspondance puis création), sinon la correspondance automatique par domaine de l'e-mail (entreprise existante uniquement). - Nouvelle fiche : sans propriétaire sauf
ownerIdsfournis (employés vivants), statut par défaut sauflifecycleStage, historique de statut avec la sourceapi. Les déclencheurs de workflow (lead_created,lead_property_changed,deal_*) partent comme pour une écriture d'interface. - Transactions :
POST /dealssanspipelineIdprend le pipeline par défaut et son premier stade. Un changement de stade passe parPATCH { stageKey, stageTags?, stageComment? }et respecte le graphe des transitions (409 deal_transition_forbidden,409 stage_tag_required).status,closedAtetpipelineIdne s'écrivent pas. - Activités :
POSTacceptestatus: open | done;PATCH { status: 'done' }horodatecompletedAt,openl'efface.
Idempotence
Les clients qui rejouent leurs requêtes (Zapier, Make) envoient un en-tête
Idempotency-Key (1 à 255 caractères, unique par clé d'API) sur les POST.
Pendant 24 h, la même clé avec le même corps renvoie la réponse enregistrée
(en-tête Idempotent-Replayed: true) sans réécrire ; la même clé avec un corps
différent renvoie 422 idempotency_key_reused ; une requête encore en cours
renvoie 409 idempotency_in_progress. Sans cet en-tête, un POST rejoué crée
un doublon (sauf /contacts/upsert).
Limites de débit
| Limite | Valeur | Clé |
|---|---|---|
| Requêtes | 600 / min | par clé d'API |
Écritures (POST, PATCH, DELETE) | 300 / min, en plus de la précédente | par clé d'API |
| Échecs d'authentification | 10 / min | par adresse IP |
Dépassement : 429 avec Retry-After (secondes). Une adresse IP à court de
budget d'authentification est refusée avant toute recherche de clé en base.
Erreurs
{ "error": { "code": "invalid_fields", "message": "…", "details": { "path": ".email" } } }
Codes HTTP : 400 (corps ou champ invalide ou absent, id malformé, référence
inconnue — le code est le code d'erreur métier, ex. field_required, invalid_owner,
invalid_address, unknown_stage), 401 (clé absente, malformée, inconnue,
révoquée ou expirée — toujours le même corps), 403 missing_scope,
404 not_found, 409 (conflit d'état : duplicate_email,
company_domain_exists, lifecycle_regression_blocked,
deal_transition_forbidden…), 422 idempotency_key_reused, 429 rate_limited,
500 internal_error.
Journal et traçabilité
Chaque écriture par l'API produit une ligne auditLogs portant apiKeyId (et
pas d'userId) ; le fil d'activité des fiches l'affiche comme
API · <nom de la clé>. Les lectures ne sont pas journalisées : lastUsedAt
de la clé (rafraîchi au plus toutes les 5 min) et les logs Convex suffisent.
Documentation OpenAPI
Le contrat est décrit dans docs/openapi.yaml (OpenAPI 3.1, relu comme du
code) et servi sans clé par le déploiement :
GET /api/v1/openapi.json— le document, avecserverspointant sur le déploiement qui le sert ;GET /api/v1/docs— explorateur Swagger UI ; le bouton Authorize prend votre clé pour essayer les appels.
Après une modification du YAML, bun run openapi régénère le module servi
(convex/lib/api/openapi.generated.ts, commité) ; tests/backend/openapi.test.ts
vérifie que le module est à jour, que le document est un OpenAPI valide et
qu'il décrit exactement les routes du routeur avec leurs portées.