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’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
Twenty matches email participants to People by email address. That cannot match a LinkedIn URN or a phone number, so an app must say who a participant is — an unlinked thread does not appear on anyone’s record page. 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.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.
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 send. Outbound delivery is not yet routed to apps; a reply composed in Twenty cannot reach your provider. Until it is, link out to the provider’s own UI for replies.
- It does not resolve contacts. Twenty matches email participants to People by email address, which cannot match a LinkedIn URN, so app channels link participants explicitly at ingestion instead.
- 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.