English
Writing an integration
An integration connects a third-party service that authenticates with an API key or a token (the existing ones are Airtable, Calendly, MyNotary, Notion and Yousign). The platform already provides everything around it: the connection catalog, the credential type, the Connections page and its form, the connection test, the HTTP client with its rate limiter and retries, the SSRF guard, error classification, test runs. Your integration declares data and writes the few functions that actually call the API.
Calendly is the reference example. Every step below names its Calendly file; copy it.
This page complements Writing a node, which describes the node contract in full. OAuth accounts (Google, Microsoft) and AI model providers are built-in connection kinds; they do not follow this recipe.
What an integration is made of
New files, plus three registration lines in existing files:
| File | Registration line |
|---|---|
packages/api-types/src/integrations/index.ts | <id>Integration, in INTEGRATIONS (alphabetical order: it is the order of the Connections page) |
packages/server/src/integrations/plugin.ts | registerIntegrationProvider(<id>Provider); |
packages/nodes/src/catalog/index.ts | <id>Node, in CATALOG_NODES, plus its re-export block |
If you find yourself changing a central file that is not in this table (routes, the run engine, the connection form), stop: either the platform is missing something and it should be discussed, or the integration is trying to work differently from the others.
Step 1: the declaration
File: packages/api-types/src/integrations/<id>.ts. Model: calendly.ts. Types, with each field explained in comments: packages/api-types/src/integrations/types.ts (IntegrationDefinition).
Everything that is data is declared once here, and the platform reads it everywhere: HTTP client, connection form, signature check, Connections page.
| Field | Content |
|---|---|
id | Lowercase snake case ([a-z][a-z0-9_]*). It is stored in the credential type service:<id> and in resource names: changing it breaks existing connections. |
label, docsHint | { fr, en }. docsHint is one sentence saying what the connection allows. |
logo | The key of the logo in packages/ui/src/components/brandLogos.ts. |
docsUrl | The provider's official API documentation. |
guide | The step-by-step instructions shown in the connection form, { fr, en } each, in the imperative and in click order. At least one step. |
fields | The connection fields. kind: 'secret' masks the value, never shows it again and keeps only a four-character hint. |
auth | How the key is applied: bearer, header, query or basic, pointing at a field. |
baseUrl or environments | Exactly one of the two. A sandbox is a separate connection, never a node parameter; with environments, put the sandbox first (it is the fallback for an unknown stored value) and declare environmentField. |
headers | Fixed headers (an API version, for example). |
rateLimit | requests per windowMs, plus maxAttempts and maxBackoffMs. Declare the rate of the provider's lowest plan. |
pagination | cursor, nextLink or page, with the paths of the items and the cursor. |
webhook | The signature scheme, if the service sends signed webhooks (see below). |
resources | The lists the editor can browse (name, label, optional parent). |
validateIntegrationDefinition runs when the module is loaded and throws on a malformed declaration: an id that is not snake case, no field or no guide step, a duplicate field, an auth pointing at a missing field, both or neither of baseUrl and environments, a base URL ending with /, a choice field without options, an unknown parent resource, a non-positive rate limit. The declaration's texts are checked by packages/api-types/src/integrations/integrations.test.ts: an English text missing, or identical to the French one without reason, fails. The name of the service is a proper noun, so { fr: 'Calendly', en: 'Calendly' } is correct.
Step 2: the server provider
File: packages/server/src/integrations/providers/<id>.ts. Model: providers/calendly.ts. Types: IntegrationServerProvider in packages/server/src/integrations/registry.ts.
It holds only what calls the API and therefore cannot be declared:
verify(api): the connection test. Call the cheapest endpoint of the API and return{ account }when the API gives an account name.resources: one lister per declared resource, withlist(api, request)and an optionalresolveUrl(url).
The api object is the complete integration client: the connection is resolved and decrypted, authentication applied, the base URL prefixed, the rate limiter respected, 429, 409 and 5xx responses retried with Retry-After honoured, errors classified, and no secret in the logs. Rules:
- Write relative paths (
/users/me). An absolute URL is refused: it would bypass the connection's environment. - You never see the secret.
- In a lister,
idis what the node uses at run time andlabelis what the member reads. Filter onrequest.qif the API has no server-side search. Do not paginate: return the full list (withapi.collectand amaxItems), the platform pages it. resolveUrlis pure and total: an unrecognised URL returnsundefined, never an invented identifier.- An unexpected response throws. An empty list would make the member believe the account is empty; the route turns the error into
502 resource.unavailable.
Register it in packages/server/src/integrations/plugin.ts, next to the others: registerIntegrationProvider(<id>Provider);. At startup, integrationProviderProblems() logs a warning for a declared resource without a lister, or a testable integration without verify.
Step 3: the node
File: packages/nodes/src/catalog/<id>.ts. Model: calendly.ts. Shared helpers: packages/nodes/src/catalog/integration-common.ts.
The node type's family is the integration id:
- a multi-action node (the usual case) is
<id>.api, with anactionparameter of typeoptions(calendly.api); - a node dedicated to one gesture is
<id>.<action>.
Parameters start with integrationConnectionParam({ integrationId, serviceName }), a required credential parameter named connection with the credential type service:<id>. A remote resource is a resourceLocator parameter with resource: '<id>.<resource>', credentialParam: INTEGRATION_CONNECTION_PARAM, and parentParam for a cascade.
In execute, get the service with integrationService(context.services, TYPE), then call service.request(...) or service.collect(...) with { integration, connectionId, method, path } and { idempotencyKey: context.idempotencyKey, signal: context.abortSignal }. The rules:
- The node only passes the connection value to the service; it is an opaque identifier.
- No absolute URL, no authentication header, no retry, no manual pagination.
service.collectpaginates according to the declaration; you givemaxItems, which is mandatory. policy.effectis honest: a node that can create, cancel or sign declaresexternal_write, even if most of its actions read.throwIfAbortedbefore and after each service call.- Errors go through
integrationError(TYPE, operation, cause), which keeps the classification already made by the client. - Test runs need no code: the integration service performs real reads and describes the writes instead of sending them.
- Step summaries go through
summaryPatch(localeOf(context), 'key', params).
Register the node in packages/nodes/src/catalog/index.ts (integrations block of CATALOG_NODES and re-exports), and add it to the inventory of packages/nodes/src/catalog/catalog.test.ts, as for any node.
Cascading lists
Declare the parent both on the resource (parent in api-types) and on the parameter (parentParam in nodes). Airtable's tables, for example, have parent: 'airtable.base', and the table parameter has parentParam: 'base'.
The platform then handles the cascade: the child field queries nothing while the parent is empty, changing the parent clears the child, the server refuses an incomplete chain with 400 resource.context_required, and your list() receives request.parentId (the immediate parent) and request.parentIds (the ancestors, root first, immediate parent excluded).
Signed webhooks
Declare the signature in webhook (step 1). The check is verifyWebhookSignature in packages/server/src/integrations/webhook-signature.ts; verifyIntegrationWebhook (webhook-verification.ts) resolves the secret from the connection and applies it. The raw body comes from rawBodyOf(request) (raw-body.ts).
Always verify the raw body, the bytes exactly as received. Re-serialised JSON changes key order, spacing and escapes, and the signature never matches.
| Shape | Example header | Declaration |
|---|---|---|
| Bare value | X-Sig: <hex> | nothing more |
| Prefixed value | X-Notion-Signature: sha256=<hex> | valuePrefix: 'sha256=' |
| Prefixed value, base64 secret | X-Airtable-Content-MAC: hmac-sha256=<hex> | valuePrefix: 'hmac-sha256=', secretEncoding: 'base64' |
| Pairs | Calendly-Webhook-Signature: t=…,v1=… | pairs: { signatureKey: 'v1', timestampKey: 't' } |
| Timestamped body | signature over <ts>.<body> | payload: 'timestamp.body', toleranceSeconds: 180 |
toleranceSeconds closes replays of a captured webhook. A refused signature answers the same silent 404 as an unknown token: the URL is public and must not become an oracle. An integration that does not sign its webhooks relies on the URL token alone.
Polling triggers
A polling trigger declares what to poll; the server machinery (packages/server/src/workflows/integration-poll.ts) arms it on publication, keeps a lease so two polls never overlap, stores the position, creates one run per new item, and handles errors. The contract is IntegrationPollSpec in packages/nodes/src/catalog/integration-poll.ts; the full example is airtableTriggerPoll in packages/nodes/src/catalog/airtable-trigger.ts.
Registration takes three places:
| File | Content |
|---|---|
packages/nodes/src/catalog/<id>-trigger.ts | The trigger.<id> node and its IntegrationPollSpec. |
packages/nodes/src/catalog/index.ts | The spec in CATALOG_POLL_SPECS, the node in CATALOG_NODES. |
packages/workflow/src/graph/triggers.ts | The type in TRIGGER_NODE_TYPES, MESSAGELESS_TRIGGER_NODE_TYPES and ARMED_TRIGGER_NODE_TYPES. Forgetting the last one makes the editor show trigger_not_armed on a trigger the server does arm. |
The spec declares nodeType, integrationId, connectionParam, dataKey (what the graph reads, data.<dataKey>), maxItems per poll, the interval bounds and default, everyMinutes(params), parseCursor, startCursor(now), poll(cursor, ctx) and dedupKey(workflowId, nodeId, item). The rules:
pollis a read. It returns{ items, nextCursor }and never writes; the platform writes the position after creating the runs, in the same transaction.dedupKeyderives from the item, never from the time of the poll. A crash between creating the runs and saving the position re-polls the same items, and the key absorbs the duplicates.- An absent
nextCursormeans the position did not change: nothing is written. startCursor(now)means "start from now". It is set once on publication, so the first poll does not bring back the account's history.packages/nodes/src/catalog/integration-poll.test.tsrequires every registered spec to declare it.- Without a position (the trigger's test from the editor),
pollreturns the most recent items, in chronological order. - Parameters of a polling trigger are
templatable: false: a poll runs outside any run, with no data to interpolate. - The instance floor
INTEGRATION_POLL_MIN_MINUTES(5 minutes by default) applies over the interval the member asks for. - You do not classify errors: a retryable failure leaves the position unchanged for the next poll; a permanent one raises a single
workflow.trigger_errornotification per outage.
Webhook triggers (trigger.notion, trigger.yousign, trigger.mynotary) have no poll spec. A service with both a webhook and a polling path gets two trigger nodes, as Notion does with trigger.notion and trigger.notion_changes.
Files fetched into a run
A node that obtains a document (a signed contract, a record's attachment) hands it to the run so that a later node, such as Compose, can attach it. The bytes never travel through step data. Three paths, depending on where the bytes come from:
| Source of the bytes | Method |
|---|---|
| The service serves them on its own base URL | service.downloadToAttachment({ integration, connectionId, method, path, maxBytes, filename }, opts): relative path, through the connection. |
The service returns a signed https URL on a CDN | service.downloadUrlToAttachment({ integration, url, maxBytes, filename }, opts): absolute https URL, through the hardened HTTP client, and the connection is never sent to that host. |
| The node still needs the bytes after downloading | service.download(...), then depositBinary(context.services, { binary, filename, integration }) from integration-common.ts. |
Return the attachment's position in the step data: it is an ordinary attachment position that Compose and Read attachments accept. The platform deduplicates by content, applies the instance quotas (EXECUTION_ATTACHMENT_MAX_BYTES, EXECUTION_ATTACHMENTS_MAX_TOTAL_BYTES, EXECUTION_ATTACHMENTS_MAX_COUNT), records the provenance, and deletes the file with the run.
Logo
Add the official logo to packages/ui/src/components/brandLogos.ts (the calendly entry is the model), note its source and licence in brandLogos.md, and set logo: '<id>' in the declaration. Use an official, faithful logo, never a redrawing; the only accepted edits are mechanical ones (flattened gradients, removed filters), documented in brandLogos.md. A test refuses scripts, external <use> and remote href in a logo. The node picks up the logo automatically; update the expected logo id in packages/ui/src/components/NodeIcon.test.ts.
Texts and translations
- The integration's texts (label, hint, guide, field labels) live in the declaration, in French and English.
- The node's step summaries go in
packages/nodes/src/i18n/fr.tsanden.ts, one whole sentence per key. - Any new screen text goes in
packages/ui/src/i18n/locales/fr.jsonanden.json. - No product name or domain in the code: the
User-Agentsent to providers is built from the instance's brand name.
The general rules are in Conventions.
Tests
No real network call, ever. Two techniques are enough: a fake service (an object literal that implements IntegrationService or IntegrationApi, records calls and returns scripted responses) for the node and the provider, and a local HTTP server on port 0 when you test the client itself. Models: packages/server/src/integrations/client.test.ts, packages/server/src/integrations/providers/calendly.test.ts, packages/nodes/src/catalog/calendly.test.ts.
Cover at least:
- the declaration: French and English texts,
integrationDataSchemarefusing an extra field and a missing secret; - the verifier: success with the account name, and a
401givingintegration.unauthorized; - each lister: path called,
qfilter,{ id, label }projection,resolveUrlon a recognised URL, an unrecognised one and another host; - each node action: path, query, body, output data, and the permanent error on an empty parameter;
- the webhook signature: a vector computed with
node:crypto, and a re-serialised body that must fail; - the i18n keys of the summaries in both dictionaries.
Database
You write no migration. The credential type column already accepts the service:<id> family; the application registry decides which ids exist. If your integration really needs its own table, migrations are numbered SQL files in packages/server/migrations, never modified once merged: take the next free number when you write it.
Connection guide in the documentation
Each entry of the connection catalog needs a guide in two files, apps/docs/content/integrations/<id>.en.md and <id>.fr.md, otherwise the documentation generator fails. A new integration is added to the catalog automatically, so the guide is mandatory from the first commit.
The guide has no frontmatter. It starts with one or two paragraphs saying what the connection is used for, then free ## sections. The generator already writes the summary table, the in-app guide steps, the connection fields, the webhook events and the list of nodes that use the connection: do not repeat them. The existing guides use these sections: ## Before you start, ## Permissions, ## Add the connection, ## Webhooks, ## Common errors (with real error codes, their cause and the fix). calendly.en.md is a complete example.
Each node of the integration also needs its own documentation source; see Writing a node.
Checklist
[ ] packages/api-types/src/integrations/<id>.ts the declaration
[ ] packages/api-types/src/integrations/index.ts one line in INTEGRATIONS
[ ] packages/server/src/integrations/providers/<id>.ts verify + resources
[ ] packages/server/src/integrations/plugin.ts registerIntegrationProvider(…)
[ ] packages/nodes/src/catalog/<id>.ts the node
[ ] packages/nodes/src/catalog/index.ts CATALOG_NODES + re-exports
[ ] packages/nodes/src/catalog/catalog.test.ts key and effect in the inventory
[ ] packages/nodes/src/i18n/fr.ts AND en.ts step summaries
[ ] packages/ui/src/components/brandLogos.ts (+ .md) the official logo
[ ] packages/ui/src/components/NodeIcon.test.ts the expected logo id
[ ] apps/docs/content/integrations/<id>.en.md AND .fr.md the connection guide
[ ] apps/docs/content/nodes/<type>.en.md AND .fr.md one source per node
[ ] the tests listed aboveFor a polling trigger, add:
[ ] packages/nodes/src/catalog/<id>-trigger.ts the node + its 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
[ ] a test of the spec: relative path, position advanced, dedup key derived from the item,
nextCursor absent when nothing changedTroubleshooting
| Symptom | Where to look |
|---|---|
| The request goes to the wrong URL | packages/server/src/integrations/client.ts: the base URL and the environment of the connection. |
401 although the key is right | The field named in auth does not exist, or the auth kind is wrong. |
Bursts of 429 | rateLimit is too generous for the provider's plan. The limiter is per process. |
| A list stays empty | The lister returns [] instead of throwing when the API refused. |
| "Pick “…” first to see this list." although everything is filled | A gap in the parentParam chain, or parent missing in the declaration. |
| The webhook signature always fails | The body is not raw, or secretEncoding is missing. |
trigger_not_armed on a polling trigger | The type is missing from ARMED_TRIGGER_NODE_TYPES. |
| The poll never finds anything | The stored position is ahead. |
attachment.quota_exceeded | The instance quotas on run attachments. |