Français
Écrire une intégration
Une intégration branche un service tiers qui s’authentifie par une clé d’API ou un jeton (celles qui existent sont Airtable, Calendly, MyNotary, Notion et Yousign). La plateforme fournit déjà tout ce qui l’entoure : le catalogue des connexions, le type de credential, la page Connexions et son formulaire, le test de connexion, le client HTTP avec son limiteur de débit et ses nouvelles tentatives, la garde anti-SSRF, la classification des erreurs, les essais. Votre intégration déclare des données et écrit les quelques fonctions qui appellent réellement l’API.
Calendly est l’exemple de référence. Chaque étape ci-dessous nomme son fichier Calendly : copiez-le.
Cette page complète Écrire un nœud, qui décrit le contrat de nœud en entier. Les comptes OAuth (Google, Microsoft) et les fournisseurs de modèles d’IA sont des natures de connexion intégrées ; ils ne suivent pas cette recette.
De quoi se compose une intégration
Des fichiers nouveaux, plus trois lignes d’enregistrement dans des fichiers existants :
| Fichier | Ligne d’enregistrement |
|---|---|
packages/api-types/src/integrations/index.ts | <id>Integration, dans INTEGRATIONS (ordre alphabétique : c’est l’ordre de la page Connexions) |
packages/server/src/integrations/plugin.ts | registerIntegrationProvider(<id>Provider); |
packages/nodes/src/catalog/index.ts | <id>Node, dans CATALOG_NODES, plus son bloc de réexports |
Si vous vous surprenez à modifier un fichier central absent de ce tableau (routes, moteur d’exécution, formulaire de connexion), arrêtez-vous : soit il manque quelque chose à la plateforme et il faut en discuter, soit l’intégration essaie de fonctionner autrement que les autres.
Étape 1 : la déclaration
Fichier : packages/api-types/src/integrations/<id>.ts. Modèle : calendly.ts. Types, chaque champ expliqué en commentaire : packages/api-types/src/integrations/types.ts (IntegrationDefinition).
Tout ce qui est une donnée se déclare une fois ici, et la plateforme le lit partout : client HTTP, formulaire de connexion, vérification de signature, page Connexions.
| Champ | Contenu |
|---|---|
id | En snake case minuscule ([a-z][a-z0-9_]*). Il est stocké dans le type de credential service:<id> et dans les noms de ressources : le changer casse les connexions existantes. |
label, docsHint | { fr, en }. docsHint tient en une phrase : ce que la connexion permet. |
logo | La clé du logo dans packages/ui/src/components/brandLogos.ts. |
docsUrl | La documentation officielle de l’API du fournisseur. |
guide | Les étapes affichées dans le formulaire de connexion, chacune en { fr, en }, à l’impératif et dans l’ordre des clics. Au moins une étape. |
fields | Les champs de la connexion. kind: 'secret' masque la valeur, ne la réaffiche jamais et n’en garde qu’un repère de quatre caractères. |
auth | Comment la clé s’applique : bearer, header, query ou basic, en désignant un champ. |
baseUrl ou environments | Exactement l’un des deux. Un bac à sable est une connexion distincte, jamais un paramètre de nœud ; avec environments, placez le bac à sable en premier (c’est le repli pour une valeur stockée inconnue) et déclarez environmentField. |
headers | Des en-têtes fixes (une version d’API, par exemple). |
rateLimit | requests par windowMs, plus maxAttempts et maxBackoffMs. Déclarez le débit de l’offre la plus basse du fournisseur. |
pagination | cursor, nextLink ou page, avec les chemins des éléments et du curseur. |
webhook | Le schéma de signature, si le service envoie des webhooks signés (voir plus bas). |
resources | Les listes que l’éditeur peut parcourir (name, label, parent facultatif). |
validateIntegrationDefinition s’exécute au chargement du module et lève une erreur sur une déclaration mal formée : un id qui n’est pas en snake case, aucun champ ou aucune étape de guide, un champ en double, un auth qui désigne un champ absent, à la fois ou ni baseUrl ni environments, une URL de base terminée par /, un champ choice sans options, une ressource parente inconnue, un débit non positif. Les textes de la déclaration sont vérifiés par packages/api-types/src/integrations/integrations.test.ts : un texte anglais manquant, ou identique au français sans raison, échoue. Le nom du service est un nom propre : { fr: 'Calendly', en: 'Calendly' } est correct.
Étape 2 : le fournisseur serveur
Fichier : packages/server/src/integrations/providers/<id>.ts. Modèle : providers/calendly.ts. Types : IntegrationServerProvider dans packages/server/src/integrations/registry.ts.
Il ne contient que ce qui appelle l’API et ne peut donc pas se déclarer :
verify(api): le test de connexion. Appelez l’endpoint le moins coûteux de l’API et rendez{ account }quand l’API donne un nom de compte.resources: une fonction de liste par ressource déclarée, aveclist(api, request)et unresolveUrl(url)facultatif.
L’objet api est le client d’intégration complet : connexion résolue et déchiffrée, authentification posée, URL de base préfixée, limiteur respecté, réponses 429, 409 et 5xx rejouées en honorant Retry-After, erreurs classées, et aucun secret dans les journaux. Les règles :
- Écrivez des chemins relatifs (
/users/me). Une URL absolue est refusée : elle contournerait l’environnement de la connexion. - Vous ne voyez jamais le secret.
- Dans une liste,
idest ce que le nœud utilise à l’exécution etlabelce que le membre lit. Filtrez surrequest.qsi l’API n’a pas de recherche côté serveur. Ne paginez pas : rendez la liste complète (avecapi.collectet unmaxItems), la plateforme la découpe en pages. resolveUrlest pure et totale : une URL non reconnue rendundefined, jamais un identifiant inventé.- Une réponse inattendue lève une erreur. Une liste vide ferait croire au membre que son compte est vide ; la route transforme l’erreur en
502 resource.unavailable.
Enregistrez-le dans packages/server/src/integrations/plugin.ts, à côté des autres : registerIntegrationProvider(<id>Provider);. Au démarrage, integrationProviderProblems() journalise un avertissement pour une ressource déclarée sans fonction de liste, ou pour une intégration testable sans verify.
Étape 3 : le nœud
Fichier : packages/nodes/src/catalog/<id>.ts. Modèle : calendly.ts. Fonctions partagées : packages/nodes/src/catalog/integration-common.ts.
La famille du type de nœud est l’id de l’intégration :
- un nœud multi-actions (le cas normal) s’appelle
<id>.api, avec un paramètreactionde typeoptions(calendly.api) ; - un nœud dédié à un seul geste s’appelle
<id>.<action>.
Les paramètres commencent par integrationConnectionParam({ integrationId, serviceName }), un paramètre credential requis nommé connection, du type de credential service:<id>. Une ressource distante est un paramètre resourceLocator avec resource: '<id>.<ressource>', credentialParam: INTEGRATION_CONNECTION_PARAM, et parentParam pour une cascade.
Dans execute, obtenez le service par integrationService(context.services, TYPE), puis appelez service.request(...) ou service.collect(...) avec { integration, connectionId, method, path } et { idempotencyKey: context.idempotencyKey, signal: context.abortSignal }. Les règles :
- Le nœud ne fait que transmettre la valeur de la connexion au service ; c’est un identifiant opaque.
- Aucune URL absolue, aucun en-tête d’authentification, aucune nouvelle tentative, aucune pagination à la main.
service.collectpagine selon la déclaration ; vous donnezmaxItems, obligatoire. policy.effectest honnête : un nœud qui peut créer, annuler ou signer déclareexternal_write, même si la plupart de ses actions lisent.throwIfAbortedavant et après chaque appel de service.- Les erreurs passent par
integrationError(TYPE, opération, cause), qui garde la classification déjà faite par le client. - Les essais ne demandent aucun code : le service d’intégration fait les lectures réelles et décrit les écritures au lieu de les envoyer.
- Les résumés d’étape passent par
summaryPatch(localeOf(context), 'clé', paramètres).
Enregistrez le nœud dans packages/nodes/src/catalog/index.ts (bloc des intégrations de CATALOG_NODES et réexports), et ajoutez-le à l’inventaire de packages/nodes/src/catalog/catalog.test.ts, comme pour tout nœud.
Listes en cascade
Déclarez le parent à la fois sur la ressource (parent dans api-types) et sur le paramètre (parentParam dans nodes). Les tables d’Airtable, par exemple, ont parent: 'airtable.base', et le paramètre table a parentParam: 'base'.
La plateforme gère alors la cascade : le champ enfant n’interroge rien tant que le parent est vide, changer le parent vide l’enfant, le serveur refuse une chaîne incomplète par 400 resource.context_required, et votre list() reçoit request.parentId (le parent immédiat) et request.parentIds (les ancêtres, racine en tête, parent immédiat exclu).
Webhooks signés
Déclarez la signature dans webhook (étape 1). La vérification est verifyWebhookSignature dans packages/server/src/integrations/webhook-signature.ts ; verifyIntegrationWebhook (webhook-verification.ts) résout le secret depuis la connexion et l’applique. Le corps brut vient de rawBodyOf(request) (raw-body.ts).
Vérifiez toujours le corps brut, les octets exactement reçus. Un JSON re-sérialisé change l’ordre des clés, les espaces et les échappements, et la signature ne tombe jamais juste.
| Forme | Exemple d’en-tête | Déclaration |
|---|---|---|
| Valeur nue | X-Sig: <hex> | rien de plus |
| Valeur préfixée | X-Notion-Signature: sha256=<hex> | valuePrefix: 'sha256=' |
| Valeur préfixée, secret en base64 | X-Airtable-Content-MAC: hmac-sha256=<hex> | valuePrefix: 'hmac-sha256=', secretEncoding: 'base64' |
| Paires | Calendly-Webhook-Signature: t=…,v1=… | pairs: { signatureKey: 'v1', timestampKey: 't' } |
| Corps horodaté | signature sur <ts>.<corps> | payload: 'timestamp.body', toleranceSeconds: 180 |
toleranceSeconds empêche le rejeu d’un webhook capté. Une signature refusée reçoit le même 404 muet qu’un jeton inconnu : l’URL est publique et ne doit pas devenir un oracle. Une intégration qui ne signe pas ses webhooks s’appuie sur le seul jeton de l’URL.
Déclencheurs par sondage
Un déclencheur par sondage déclare ce qu’il faut sonder ; les mécanismes du serveur (packages/server/src/workflows/integration-poll.ts) l’arment à la publication, tiennent un bail pour que deux sondages ne se chevauchent jamais, stockent la position, créent une exécution par nouvel élément et gèrent les erreurs. Le contrat est IntegrationPollSpec dans packages/nodes/src/catalog/integration-poll.ts ; l’exemple complet est airtableTriggerPoll dans packages/nodes/src/catalog/airtable-trigger.ts.
L’enregistrement touche trois endroits :
| Fichier | Contenu |
|---|---|
packages/nodes/src/catalog/<id>-trigger.ts | Le nœud trigger.<id> et son IntegrationPollSpec. |
packages/nodes/src/catalog/index.ts | La spec dans CATALOG_POLL_SPECS, le nœud dans CATALOG_NODES. |
packages/workflow/src/graph/triggers.ts | Le type dans TRIGGER_NODE_TYPES, MESSAGELESS_TRIGGER_NODE_TYPES et ARMED_TRIGGER_NODE_TYPES. Oublier le dernier fait afficher par l’éditeur trigger_not_armed sur un déclencheur que le serveur arme pourtant. |
La spec déclare nodeType, integrationId, connectionParam, dataKey (ce que lit le graphe, data.<dataKey>), maxItems par sondage, les bornes et la valeur par défaut de l’intervalle, everyMinutes(params), parseCursor, startCursor(now), poll(cursor, ctx) et dedupKey(workflowId, nodeId, item). Les règles :
pollest une lecture. Elle rend{ items, nextCursor }et n’écrit jamais ; la plateforme écrit la position après avoir créé les exécutions, dans la même transaction.dedupKeydérive de l’élément, jamais de l’instant du sondage. Un crash entre la création des exécutions et l’écriture de la position fait re-sonder les mêmes éléments, et la clé absorbe les doublons.- Un
nextCursorabsent signifie que la position n’a pas bougé : rien n’est écrit. startCursor(now)signifie « partir de maintenant ». Elle est posée une fois, à la publication, pour que le premier sondage ne ramène pas l’historique du compte.packages/nodes/src/catalog/integration-poll.test.tsexige que chaque spec enregistrée la déclare.- Sans position (l’essai du déclencheur depuis l’éditeur),
pollrend les éléments les plus récents, dans l’ordre chronologique. - Les paramètres d’un déclencheur par sondage portent
templatable: false: un sondage tourne hors de toute exécution, sans données à interpoler. - Le plancher d’instance
INTEGRATION_POLL_MIN_MINUTES(5 minutes par défaut) s’applique par-dessus l’intervalle demandé par le membre. - Vous ne classez pas les erreurs : un échec transitoire laisse la position inchangée pour le sondage suivant ; un échec définitif déclenche une seule notification
workflow.trigger_errorpar panne.
Les déclencheurs par webhook (trigger.notion, trigger.yousign, trigger.mynotary) n’ont pas de spec de sondage. Un service qui a à la fois un webhook et un chemin de sondage reçoit deux nœuds déclencheurs, comme Notion avec trigger.notion et trigger.notion_changes.
Fichiers rapatriés dans une exécution
Un nœud qui obtient un document (un acte signé, la pièce jointe d’un enregistrement) le confie à l’exécution pour qu’un nœud suivant, comme Rédiger, puisse le joindre. Les octets ne transitent jamais par les données d’étape. Trois chemins, selon l’origine des octets :
| Origine des octets | Méthode |
|---|---|
| Le service les sert sur sa propre URL de base | service.downloadToAttachment({ integration, connectionId, method, path, maxBytes, filename }, opts) : chemin relatif, par la connexion. |
Le service rend une URL https signée sur un CDN | service.downloadUrlToAttachment({ integration, url, maxBytes, filename }, opts) : URL https absolue, par le client HTTP durci, et la connexion n’est jamais envoyée à cet hôte. |
| Le nœud a encore besoin des octets après le téléchargement | service.download(...), puis depositBinary(context.services, { binary, filename, integration }) d’integration-common.ts. |
Rendez la position de la pièce jointe dans les données de l’étape : c’est une position de pièce jointe ordinaire, qu’acceptent Rédiger et Lire les pièces jointes. La plateforme déduplique par contenu, applique les quotas de l’instance (EXECUTION_ATTACHMENT_MAX_BYTES, EXECUTION_ATTACHMENTS_MAX_TOTAL_BYTES, EXECUTION_ATTACHMENTS_MAX_COUNT), enregistre la provenance, et supprime le fichier avec l’exécution.
Logo
Ajoutez le logo officiel dans packages/ui/src/components/brandLogos.ts (l’entrée calendly sert de modèle), notez sa source et sa licence dans brandLogos.md, et posez logo: '<id>' dans la déclaration. Utilisez un logo officiel et fidèle, jamais un redessin ; les seules retouches admises sont mécaniques (dégradés aplatis, filtres retirés) et documentées dans brandLogos.md. Un test refuse les scripts, les <use> externes et les href distants dans un logo. Le nœud reprend le logo automatiquement ; mettez à jour l’id de logo attendu dans packages/ui/src/components/NodeIcon.test.ts.
Textes et traductions
- Les textes de l’intégration (nom, aide, guide, libellés des champs) vivent dans la déclaration, en français et en anglais.
- Les résumés d’étape du nœud vont dans
packages/nodes/src/i18n/fr.tseten.ts, une phrase entière par clé. - Tout nouveau texte d’écran va dans
packages/ui/src/i18n/locales/fr.jsoneten.json. - Aucun nom de produit ni domaine dans le code : le
User-Agentenvoyé aux fournisseurs est construit à partir du nom de marque de l’instance.
Les règles générales sont dans Conventions.
Tests
Aucun appel réseau réel, jamais. Deux techniques suffisent : un faux service (un objet littéral qui implémente IntegrationService ou IntegrationApi, enregistre les appels et rend des réponses programmées) pour le nœud et le fournisseur, et un serveur HTTP local sur le port 0 quand vous testez le client lui-même. Modèles : packages/server/src/integrations/client.test.ts, packages/server/src/integrations/providers/calendly.test.ts, packages/nodes/src/catalog/calendly.test.ts.
Couvrez au minimum :
- la déclaration : textes français et anglais,
integrationDataSchemaqui refuse un champ en trop et un secret manquant ; - le vérificateur : succès avec le nom du compte, et un
401qui donneintegration.unauthorized; - chaque liste : chemin appelé, filtre
q, projection{ id, label },resolveUrlsur une URL reconnue, une non reconnue et un autre hôte ; - chaque action du nœud : chemin, query, corps, données de sortie, et l’erreur définitive sur un paramètre vide ;
- la signature de webhook : un vecteur calculé avec
node:crypto, et un corps re-sérialisé qui doit échouer ; - les clés i18n des résumés dans les deux dictionnaires.
Base de données
Vous n’écrivez aucune migration. La colonne du type de credential accepte déjà la famille service:<id> ; c’est le registre applicatif qui dit quels id existent. Si votre intégration a vraiment besoin d’une table à elle, les migrations sont des fichiers SQL numérotés dans packages/server/migrations, jamais modifiés une fois fusionnés : prenez le prochain numéro libre au moment de l’écrire.
Guide de connexion dans la documentation
Chaque entrée du catalogue des connexions exige un guide en deux fichiers, apps/docs/content/integrations/<id>.en.md et <id>.fr.md, faute de quoi le générateur de documentation échoue. Une nouvelle intégration rejoint le catalogue automatiquement : le guide est donc obligatoire dès le premier commit.
Le guide n’a pas de frontmatter. Il commence par un ou deux paragraphes qui disent à quoi sert la connexion, puis des sections ## libres. Le générateur écrit déjà le tableau de synthèse, les étapes du guide de l’application, les champs de la connexion, les événements de webhook et la liste des nœuds qui utilisent la connexion : ne les répétez pas. Les guides existants emploient ces sections (en français) : ## Avant de commencer, ## Autorisations, ## Ajouter la connexion, ## Webhooks, ## Erreurs fréquentes (avec les vrais codes d’erreur, leur cause et le remède). calendly.fr.md en est un exemple complet.
Chaque nœud de l’intégration exige aussi sa propre source de documentation ; voir Écrire un nœud.
Liste de vérification
[ ] packages/api-types/src/integrations/<id>.ts la déclaration
[ ] packages/api-types/src/integrations/index.ts une ligne dans INTEGRATIONS
[ ] packages/server/src/integrations/providers/<id>.ts verify + resources
[ ] packages/server/src/integrations/plugin.ts registerIntegrationProvider(…)
[ ] packages/nodes/src/catalog/<id>.ts le nœud
[ ] packages/nodes/src/catalog/index.ts CATALOG_NODES + réexports
[ ] packages/nodes/src/catalog/catalog.test.ts clé et effet dans l’inventaire
[ ] packages/nodes/src/i18n/fr.ts ET en.ts résumés d’étape
[ ] packages/ui/src/components/brandLogos.ts (+ .md) le logo officiel
[ ] packages/ui/src/components/NodeIcon.test.ts l’id de logo attendu
[ ] apps/docs/content/integrations/<id>.en.md ET .fr.md le guide de connexion
[ ] apps/docs/content/nodes/<type>.en.md ET .fr.md une source par nœud
[ ] les tests listés plus hautPour un déclencheur par sondage, ajoutez :
[ ] packages/nodes/src/catalog/<id>-trigger.ts le nœud + son IntegrationPollSpec
[ ] packages/nodes/src/catalog/index.ts CATALOG_POLL_SPECS + CATALOG_NODES
[ ] packages/workflow/src/graph/triggers.ts TRIGGER_NODE_TYPES, MESSAGELESS_TRIGGER_NODE_TYPES,
ARMED_TRIGGER_NODE_TYPES
[ ] un test de la spec : chemin relatif, position avancée, clé de déduplication dérivée de l’élément,
nextCursor absent quand rien n’a bougéDépannage
| Symptôme | Où regarder |
|---|---|
| La requête part vers la mauvaise URL | packages/server/src/integrations/client.ts : l’URL de base et l’environnement de la connexion. |
401 alors que la clé est bonne | Le field désigné par auth n’existe pas, ou la nature d’auth est fausse. |
Des 429 en rafale | rateLimit est trop généreux pour l’offre du fournisseur. Le limiteur est par processus. |
| Une liste reste vide | La fonction de liste rend [] au lieu de lever une erreur quand l’API a refusé. |
| « Choisissez d’abord « … » pour voir cette liste. » alors que tout est rempli | Un trou dans la chaîne des parentParam, ou parent absent de la déclaration. |
| La signature du webhook échoue toujours | Le corps n’est pas brut, ou secretEncoding manque. |
trigger_not_armed sur un déclencheur par sondage | Le type manque dans ARMED_TRIGGER_NODE_TYPES. |
| Le sondage ne trouve jamais rien | La position stockée est en avance. |
attachment.quota_exceeded | Les quotas de l’instance sur les pièces jointes d’exécution. |