Skip to content

É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 :

FichierLigne 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.tsregisterIntegrationProvider(<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.

ChampContenu
idEn 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.
logoLa clé du logo dans packages/ui/src/components/brandLogos.ts.
docsUrlLa documentation officielle de l’API du fournisseur.
guideLes é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.
fieldsLes 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.
authComment la clé s’applique : bearer, header, query ou basic, en désignant un champ.
baseUrl ou environmentsExactement 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.
headersDes en-têtes fixes (une version d’API, par exemple).
rateLimitrequests par windowMs, plus maxAttempts et maxBackoffMs. Déclarez le débit de l’offre la plus basse du fournisseur.
paginationcursor, nextLink ou page, avec les chemins des éléments et du curseur.
webhookLe schéma de signature, si le service envoie des webhooks signés (voir plus bas).
resourcesLes 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, avec list(api, request) et un resolveUrl(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, id est ce que le nœud utilise à l’exécution et label ce que le membre lit. Filtrez sur request.q si l’API n’a pas de recherche côté serveur. Ne paginez pas : rendez la liste complète (avec api.collect et un maxItems), la plateforme la découpe en pages.
  • resolveUrl est pure et totale : une URL non reconnue rend undefined, 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ètre action de type options (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.collect pagine selon la déclaration ; vous donnez maxItems, obligatoire.
  • policy.effect est honnête : un nœud qui peut créer, annuler ou signer déclare external_write, même si la plupart de ses actions lisent.
  • throwIfAborted avant 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.

FormeExemple d’en-têteDéclaration
Valeur nueX-Sig: <hex>rien de plus
Valeur préfixéeX-Notion-Signature: sha256=<hex>valuePrefix: 'sha256='
Valeur préfixée, secret en base64X-Airtable-Content-MAC: hmac-sha256=<hex>valuePrefix: 'hmac-sha256=', secretEncoding: 'base64'
PairesCalendly-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 :

FichierContenu
packages/nodes/src/catalog/<id>-trigger.tsLe nœud trigger.<id> et son IntegrationPollSpec.
packages/nodes/src/catalog/index.tsLa spec dans CATALOG_POLL_SPECS, le nœud dans CATALOG_NODES.
packages/workflow/src/graph/triggers.tsLe 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 :

  • poll est 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.
  • dedupKey dé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 nextCursor absent 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.ts exige que chaque spec enregistrée la déclare.
  • Sans position (l’essai du déclencheur depuis l’éditeur), poll rend 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_error par 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 octetsMéthode
Le service les sert sur sa propre URL de baseservice.downloadToAttachment({ integration, connectionId, method, path, maxBytes, filename }, opts) : chemin relatif, par la connexion.
Le service rend une URL https signée sur un CDNservice.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échargementservice.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.

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.ts et en.ts, une phrase entière par clé.
  • Tout nouveau texte d’écran va dans packages/ui/src/i18n/locales/fr.json et en.json.
  • Aucun nom de produit ni domaine dans le code : le User-Agent envoyé 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 :

  1. la déclaration : textes français et anglais, integrationDataSchema qui refuse un champ en trop et un secret manquant ;
  2. le vérificateur : succès avec le nom du compte, et un 401 qui donne integration.unauthorized ;
  3. chaque liste : chemin appelé, filtre q, projection { id, label }, resolveUrl sur une URL reconnue, une non reconnue et un autre hôte ;
  4. chaque action du nœud : chemin, query, corps, données de sortie, et l’erreur définitive sur un paramètre vide ;
  5. la signature de webhook : un vecteur calculé avec node:crypto, et un corps re-sérialisé qui doit échouer ;
  6. 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 haut

Pour 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ômeOù regarder
La requête part vers la mauvaise URLpackages/server/src/integrations/client.ts : l’URL de base et l’environnement de la connexion.
401 alors que la clé est bonneLe field désigné par auth n’existe pas, ou la nature d’auth est fausse.
Des 429 en rafalerateLimit est trop généreux pour l’offre du fournisseur. Le limiteur est par processus.
Une liste reste videLa 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 rempliUn trou dans la chaîne des parentParam, ou parent absent de la déclaration.
La signature du webhook échoue toujoursLe corps n’est pas brut, ou secretEncoding manque.
trigger_not_armed sur un déclencheur par sondageLe type manque dans ARMED_TRIGGER_NODE_TYPES.
Le sondage ne trouve jamais rienLa position stockée est en avance.
attachment.quota_exceededLes quotas de l’instance sur les pièces jointes d’exécution.