Skip to content

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:

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

FieldContent
idLowercase 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.
logoThe key of the logo in packages/ui/src/components/brandLogos.ts.
docsUrlThe provider's official API documentation.
guideThe step-by-step instructions shown in the connection form, { fr, en } each, in the imperative and in click order. At least one step.
fieldsThe connection fields. kind: 'secret' masks the value, never shows it again and keeps only a four-character hint.
authHow the key is applied: bearer, header, query or basic, pointing at a field.
baseUrl or environmentsExactly 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.
headersFixed headers (an API version, for example).
rateLimitrequests per windowMs, plus maxAttempts and maxBackoffMs. Declare the rate of the provider's lowest plan.
paginationcursor, nextLink or page, with the paths of the items and the cursor.
webhookThe signature scheme, if the service sends signed webhooks (see below).
resourcesThe 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, with list(api, request) and an optional resolveUrl(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, id is what the node uses at run time and label is what the member reads. Filter on request.q if the API has no server-side search. Do not paginate: return the full list (with api.collect and a maxItems), the platform pages it.
  • resolveUrl is pure and total: an unrecognised URL returns undefined, 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 an action parameter of type options (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.collect paginates according to the declaration; you give maxItems, which is mandatory.
  • policy.effect is honest: a node that can create, cancel or sign declares external_write, even if most of its actions read.
  • throwIfAborted before 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.

ShapeExample headerDeclaration
Bare valueX-Sig: <hex>nothing more
Prefixed valueX-Notion-Signature: sha256=<hex>valuePrefix: 'sha256='
Prefixed value, base64 secretX-Airtable-Content-MAC: hmac-sha256=<hex>valuePrefix: 'hmac-sha256=', secretEncoding: 'base64'
PairsCalendly-Webhook-Signature: t=…,v1=…pairs: { signatureKey: 'v1', timestampKey: 't' }
Timestamped bodysignature 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:

FileContent
packages/nodes/src/catalog/<id>-trigger.tsThe trigger.<id> node and its IntegrationPollSpec.
packages/nodes/src/catalog/index.tsThe spec in CATALOG_POLL_SPECS, the node in CATALOG_NODES.
packages/workflow/src/graph/triggers.tsThe 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:

  • poll is a read. It returns { items, nextCursor } and never writes; the platform writes the position after creating the runs, in the same transaction.
  • dedupKey derives 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 nextCursor means 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.ts requires every registered spec to declare it.
  • Without a position (the trigger's test from the editor), poll returns 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_error notification 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 bytesMethod
The service serves them on its own base URLservice.downloadToAttachment({ integration, connectionId, method, path, maxBytes, filename }, opts): relative path, through the connection.
The service returns a signed https URL on a CDNservice.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 downloadingservice.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.

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.ts and en.ts, one whole sentence per key.
  • Any new screen text goes in packages/ui/src/i18n/locales/fr.json and en.json.
  • No product name or domain in the code: the User-Agent sent 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:

  1. the declaration: French and English texts, integrationDataSchema refusing an extra field and a missing secret;
  2. the verifier: success with the account name, and a 401 giving integration.unauthorized;
  3. each lister: path called, q filter, { id, label } projection, resolveUrl on a recognised URL, an unrecognised one and another host;
  4. each node action: path, query, body, output data, and the permanent error on an empty parameter;
  5. the webhook signature: a vector computed with node:crypto, and a re-serialised body that must fail;
  6. 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 above

For 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 changed

Troubleshooting ​

SymptomWhere to look
The request goes to the wrong URLpackages/server/src/integrations/client.ts: the base URL and the environment of the connection.
401 although the key is rightThe field named in auth does not exist, or the auth kind is wrong.
Bursts of 429rateLimit is too generous for the provider's plan. The limiter is per process.
A list stays emptyThe lister returns [] instead of throwing when the API refused.
"Pick “…” first to see this list." although everything is filledA gap in the parentParam chain, or parent missing in the declaration.
The webhook signature always failsThe body is not raw, or secretEncoding is missing.
trigger_not_armed on a polling triggerThe type is missing from ARMED_TRIGGER_NODE_TYPES.
The poll never finds anythingThe stored position is ahead.
attachment.quota_exceededThe instance quotas on run attachments.