Overview
A messaging channel lets your app put conversations from an external provider — LinkedIn, WhatsApp, SMS, an in-app inbox — into the sameMessage 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’sonConnect 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.
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: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: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.
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. PasspersonId alongside the handle:
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.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
updateMessageChannel (passing null unbinds it):
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
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
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
externalIdcreates a folder - a known one is renamed if its
namechanged - a stored folder missing from the list is removed, along with its links to messages. The messages themselves stay.
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
- One channel per connection. The hook reuses the connection’s channel when it already has one, since
onConnectis 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
syncMessageFoldersagain 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 anisSynced 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 asfolderExternalIds when ingesting:
Retiring a channel
CalldeleteMessageChannel(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
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.