Skip to main content

Overview

A messaging channel lets your app put conversations from an external provider — LinkedIn, WhatsApp, SMS, an in-app inbox — into the same Message and Message Thread records Twenty uses for email. Threads you ingest appear on the Person, Company and Opportunity records of the people in them, in the timeline and in search, with no bespoke UI. Twenty’s message model is deliberately channel-neutral: a message has participants, text, a timestamp and a thread; a participant has a handle and an optional link to a Person. Nothing in it is email-specific. A channel is what binds a stream of those messages to one account on one provider.
Beta — and possibly not live on your instance yet. App-owned message channels are new; the API described here may change, and it may not be available on the version of Twenty you are running. Check that createMessageChannel exists in your SDK before building on it.

Prerequisites

A channel always belongs to one of your app’s connections — the credential it speaks through. You need a connection provider before you can create a channel.

Creating a channel

Create the channel once per connected account, from the connection provider’s onConnect hook. That way the channel’s lifetime matches the credential’s.
src/logic-functions/handlers/linkedin-register-connection-handler.ts

handle

The account’s identity as the provider names it — a LinkedIn member URN, an E.164 phone number, a workspace-scoped user id. Twenty treats it as opaque. It is what you match inbound payloads against, and what decides whether an ingested message is incoming or outgoing. Pick the most stable identifier the provider offers. A handle that changes when the user renames themselves will split their history across two channels.

visibility

visibility is required, with no default, because it decides who in the workspace can read these conversations: The member who owns the underlying connection always reads their own messages in full, whatever this is set to. Choose based on how private the provider’s messages are, not on what is convenient to display. Personal inboxes (LinkedIn DMs, personal WhatsApp) want METADATA; shared ones (a support inbox, a team number) want SHARE_EVERYTHING.
visibility is the only thing standing between one member’s private conversations and the rest of the workspace. Twenty deliberately makes you state it rather than inheriting a default that might over-share.

Ingesting messages

Once the channel exists, ingestMessages writes conversations into it. Threading, de-duplication, channel association and per-message privacy are handled server-side — the same path Twenty’s own email import uses.
src/logic-functions/handlers/linkedin-inbound-handler.ts

What the server does for you

De-duplication. externalId is the key. Ingesting the same one twice creates nothing and changes nothing, so a redelivered webhook is safe to replay and a backfill may overlap a live stream. The returned messageId is stable across replays. Calls against one channel are serialised, so a redelivery arriving while its original is still being written waits rather than racing it. Threading. Messages sharing a threadExternalId land in one Message Thread, which then appears on the Person, Company and Opportunity records of its participants. Direction. Derived, not declared: the message is outgoing when the FROM participant’s handle equals the channel’s handle, incoming otherwise. Exactly one participant must carry FROM — direction, the thread’s sender column and the timeline preview all read it, so a message without one is rejected rather than rendering as an empty conversation. De-duplication is scoped to the channel. externalId only has to be unique within one channel, which is what most providers actually guarantee — plenty number messages per conversation or per account. The consequence is that two members in the same provider conversation each get their own Message rather than a shared one. That is deliberate: merging them would require every app to guarantee a global namespace it does not control, and getting it wrong would attach one member’s body to another member’s channel. Privacy. Each message inherits its channel’s visibility. Nothing you ingest is more visible than the channel you ingest it into.

Batching

One call is one transaction inside one logic-function timeout, capped at 100 messages. Page a provider backfill rather than sending it whole:
A batch is all-or-nothing, and repeating one externalId inside a single batch is rejected — de-duplicate before you send. A message body is capped at 256KB.

Linking participants to People

An unlinked participant pulls the conversation onto no record page, so every participant should end up linked to a Person. There are two ways to get there: let Twenty match handles against a person field, or resolve the person yourself.

Match on a person field

When the provider’s handles are values people already store on a contact — a phone number, a profile URL, a username — declare that field when you create the channel:
Any active person field of type emails, phones, links or text works, including one your app added with defineField (an Instagram username, say). Twenty then:
  • stores handles in the field’s canonical form: phones as + and digits (send E.164 numbers), links lowercased without scheme, www, fragment or trailing slash, with the query string kept as sent since it can identify the profile (linkedin.com/in/ada, facebook.com/profile.php?id=111), emails and text lowercased. The channel’s own handle is stored the same way, and a handle with no usable value is rejected.
  • links each participant you leave unlinked to the person whose field holds that value, primary values first, then the oldest person.
  • re-matches when people change: adding the value to a person links their past conversations, and removing it releases them.
A personId you pass still wins, including when someone else later gains or loses that value. The field is set once, at creation; if it is deleted or deactivated, ingestion into the channel is refused rather than storing handles Twenty can no longer normalize. Pick handles that are both stable and stored on contacts. For LinkedIn, that means the public profile URL rather than the member URN; keep your own mapping if you need the URN to call the provider.

Resolve the person yourself

Without a declared field, your app must say who a participant is. Pass personId alongside the handle:
Resolve the id however your provider’s identity is stored — against Person.linkedinLink, or an identity field your app added with defineField. Unknown ids are rejected up front rather than stored as dangling links. Once a participant is linked, Twenty builds the thread’s targets, and the conversation appears on that Person’s record page and on their company’s, exactly as an email thread does. workspaceMemberId is also accepted, for a participant who is a member of this workspace rather than a contact. It is attribution only: thread targets are built from personId alone — the same as for email — so a participant linked only to a workspace member shows in the thread but pulls the conversation onto no record page. Pass personId when you want the thread to land somewhere.
A late match is not lost. Re-ingesting a message with an identity you have since resolved links the participant that is already there — matched on the message, handle and role, so a display name that changed or was omitted in the meantime does not split it in two. An omitted name keeps the one already stored; a new one replaces it. Sending a different personId corrects an earlier link rather than adding a second one.There is no way to clear an identity yet: a null is treated as “leave it alone”, not “unlink”. Correcting a wrong link means supplying the right one.
Participants you leave unlinked are still stored and still render in the thread — they simply do not pull the conversation onto a record.

Sending replies

A messaging provider declares how an app sends on a channel. It names the connection provider whose accounts it speaks through and the logic function that delivers a reply, so one connection (a Unipile account, say) can back several kinds of messaging, each with its own handler:
src/messaging-providers/linkedin-messaging.ts
Bind a channel to it when you create the channel, or later with updateMessageChannel (passing null unbinds it):
The messaging provider must belong to your app and use the connection provider of the channel’s account; its connection provider cannot change after the first sync. Changing the send function in a later version applies to every channel bound to the provider. Replies are sent with the sendAppChannelMessage mutation, which takes the channel, the thread, the text and an idempotencyKey (a UUID the client generates per reply and reuses when retrying it), and runs the provider’s function:
src/logic-functions/handlers/linkedin-send-message-handler.ts
The payload carries the messagingProviderName, the conversation’s threadExternalId as you ingested it, the text, the idempotencyKey, and the recipients: the participants of the thread’s latest message other than the channel’s own handle, so someone who only took part in an earlier exchange is not messaged. Prefer the provider’s own conversation over recipients when its API sends to a conversation id. Return the provider’s id for the sent message as externalId. Twenty then records the reply in the thread as an outgoing message from the channel, with the same recipients, so it shows immediately. When your webhook later delivers the same message, ingesting it with that externalId changes nothing. The function runs inside the sendAppChannelMessage request. If it throws or returns no externalId, the mutation fails and nothing is recorded. Once your function returns, Twenty keeps the delivery for 24 hours under the member, the channel, the thread and the idempotencyKey. If recording the reply then fails, the mutation fails, and retrying it with the same key records the delivered reply without calling your function again; a retry of a reply already recorded returns it. A retry while the first call is still sending waits for it. A new key is a new reply. Your function can still be called twice for one reply if the client retries with a new key or after those 24 hours, or if your function itself fails after the provider accepted the message, so forward idempotencyKey to the provider when its API supports idempotent sends. A channel with isSyncEnabled: false refuses replies without calling your function. A channel with no messaging provider, or whose provider has no sendMessageLogicFunction or no longer exists, cannot send; its conversations stay as they are. Requires "engines": { "twenty": ">=2.52.0" }.

Listing and updating channels

Changing visibility through updateMessageChannel re-scopes every message already ingested into the channel, not only the ones that follow. Ingesting into a channel with isSyncEnabled: false is rejected, so pausing a channel stops the stream without deleting anything. Your app only ever sees channels created against its own connections. Channels belonging to other apps, and Twenty’s own email channels, are invisible to it. A channel is also only reachable by the person whose connection it speaks through, when that connection is private to them. A run triggered by another member of the workspace cannot see it, change it or ingest into it; a run with nobody behind it — a cron, a webhook, an install hook — acts as the application and reaches every channel the app owns.

Folders

Some providers split one account into sub-streams: inboxes in a shared helpdesk, numbers on a WhatsApp account, channels in a Slack workspace. Declare them as folders on the channel, the same records Twenty uses for mailbox folders and Gmail labels, and tag each message with the folders it belongs to. Folders are optional. A channel without folders works exactly as described above.

Declaring folders on connect

syncMessageFolders takes the channel’s full folder list and makes the stored folders match it, keyed on externalId:
  • an unknown externalId creates a folder
  • a known one is renamed if its name changed
  • a stored folder missing from the list is removed, along with its links to messages. The messages themselves stay.
The natural place to call it is the connection provider’s onConnect hook, right after creating the channel. Here is a complete setup for a helpdesk app, where each inbox becomes a folder:
src/connection-providers/helpdesk-connection.ts
src/logic-functions/helpdesk-on-connect.ts
A few things this example relies on:
  • One channel per connection. The hook reuses the connection’s channel when it already has one, since onConnect is enqueued and may run again. Settings shows the folders of that single channel, so don’t split one connection across several channels.
  • Repeatable sync. Sending the full list again is safe: unchanged folders are left alone. Call syncMessageFolders again whenever the provider’s list may have changed, on a cron or on the provider’s “inbox created” webhook.
  • Backfill per folder. Each synced folder gets its own background job, which pages through the provider’s history and calls ingestMessages.

Choosing which folders to import

Each folder has an isSynced flag. Members turn folders on and off under Folders on the connection’s page (Settings → Applications → your app → the connection), which appears once the channel has folders. A new folder starts synced unless you pass isSynced: false. Omitting isSynced on a later sync keeps the stored value, so a provider refresh never resets what a member picked. Your app can still set the flag itself by passing it to syncMessageFolders.
isSynced is advisory on app channels: Twenty stores it but does not filter ingestion on it, and nothing backfills a folder when it is turned on. Your app reads it with listMessageFolders (filtered by messageChannelId, or across all its channels when called without a filter), skips messages from unsynced folders, and backfills a folder when it becomes synced.

Tagging messages

Pass the folders a message belongs to as folderExternalIds when ingesting:
Every id must belong to a folder already declared on the channel; an unknown one rejects the whole batch. Links add up: re-ingesting a message with another folder adds that folder and keeps the earlier ones, so a conversation moved between inboxes ends up in both.

Retiring a channel

Call deleteMessageChannel(id) from the connection provider’s onDisconnect hook so a channel never outlives the credential it speaks through.
src/logic-functions/handlers/linkedin-disconnect-handler.ts
Deleting a channel deletes the messages ingested through it, and any thread left with no messages. To stop ingesting while keeping the history, set isSyncEnabled: false instead.

What a channel does not do

  • It is not polled. Twenty’s import pipeline polls mailbox providers on a cron. App channels are push-only: nothing fetches on your behalf, so your app is responsible for getting messages in, via a webhook route or a cron logic function.
  • It does not start conversations. Replies go to an existing thread through a messaging provider; a first message to someone new has to be started from your app.
  • It only resolves contacts through a declared field. Without one, Twenty cannot tell who a LinkedIn URN belongs to, so app channels link participants explicitly at ingestion.
  • It does not create contacts. Twenty’s auto-creation derives people and companies from email domains, which is meaningless for most providers, so it is off for app channels. Create the Person yourself, then pass its personId.