Bring messages from a custom channel
Record conversations from your own messaging channel (an in-app inbox, a marketplace, a partner platform) on Kustomer timelines, thread them correctly, and deliver agent replies back.
Kustomer has native email, chat, SMS, voice and social channels. If your customers also talk to you somewhere else, such as an in-app inbox, a marketplace's messaging system or a partner platform, you can bring those conversations into Kustomer so agents see everything on one timeline.
This guide covers the inbound path (customer messages into Kustomer), threading, and the outbound path (agent replies back to your channel).
Diagram source
sequenceDiagram autonumber participant Ch as Your channel participant S as Your integration participant K as Kustomer API participant A as Agent Ch->>S: New customer message S->>K: Find or create customer (externalId) S->>K: Find conversation for thread (externalId) or create one S->>K: POST message (direction: in) A->>K: Agent replies in Kustomer K-->>S: Webhook kustomer.message.create (direction: out) S->>Ch: Deliver reply
How a custom channel maps to Kustomer
Messages created through the API are records: Kustomer doesn't deliver them anywhere. Your integration is responsible for delivering agent replies to your channel.
1. Find or create the customer
async function customerFor(user: { id: string; name: string; email?: string }) {
const found = await kfetch(`/v1/customers/externalId=${encodeURIComponent(user.id)}`)
if (found.ok) return (await found.json()).data.id
const created = await kfetch('/v1/customers', {
method: 'POST',
body: JSON.stringify({ externalId: user.id, name: user.name, emails: user.email ? [{ type: 'home', email: user.email }] : [] }),
}).then((r) => r.json())
return created.data.id
}2. Find or create the conversation
Use your thread ID as the conversation's externalId, so every message in the thread lands in the same conversation:
/v1/conversations/externalId={externalId}
post/v1/customers/{id}/conversations
async function conversationFor(customerId: string, thread: { id: string; subject: string }) {
const found = await kfetch(`/v1/conversations/externalId=${encodeURIComponent(thread.id)}`)
if (found.ok) return (await found.json()).data.id
const created = await kfetch(`/v1/customers/${customerId}/conversations`, {
method: 'POST',
body: JSON.stringify({ externalId: thread.id, name: thread.subject, direction: 'in', tags: ['acme-inbox'] }),
}).then((r) => r.json())
return created.data.id
}Tag or set a custom attribute on these conversations (for example custom.sourceChannelStr: "acme-inbox") so routing rules, reports and your webhook handler can recognize them.
3. Create the inbound message
post/v1/conversations/{id}/messages
{
"channel": "chat",
"app": "acme-inbox",
"direction": "in",
"preview": "Hi, the seller hasn't shipped my order yet. Can you help?",
"externalId": "msg_01HF3Q8Y2V",
"sentAt": "2026-10-21T14:02:11Z",
"meta": { "threadUrl": "https://inbox.acme.example/t/88123" }
}previewholds the message text, up to 10,240 characters.externalIdlets you skip messages you've already imported: check get message by external ID before creating.metais free-form. Put channel-specific details there.- Attachments: upload them first, then pass them in
attachments. See Upload attachments.
When a new inbound message arrives on a conversation that's done, Kustomer's normal reopen and routing rules apply.
4. Deliver agent replies
Subscribe an outbound webhook to kustomer.message.create, then deliver outbound messages that belong to your channel:
app.post('/kustomer/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifySignature(req)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString())
res.sendStatus(200)
if (event.name !== 'kustomer.message.create') return
const msg = event.data
if (msg.attributes.direction !== 'out' || msg.attributes.app === 'acme-inbox') return // ignore inbound and our own records
const conversation = await kfetch(`/v1/conversations/${msg.relationships.conversation.data.id}`).then((r) => r.json())
if (!conversation.data.attributes.tags?.includes('acme-inbox')) return // not our channel
await acmeInbox.send(conversation.data.attributes.externalId, msg.attributes.preview)
})5. Import history
To backfill old threads, use bulk message creation and set importedAt on each message. Imported records skip the per-customer create limit (120 per minute), and sentAt keeps the original timeline order. Create customers and conversations first, then their messages.
Checklist
- Customer, conversation and message all have
externalIdset. - Every message uses the same
appvalue. - Inbound handler is idempotent (it checks
externalIdbefore creating). - Webhook handler verifies signatures, acknowledges within a few seconds and ignores its own records.
- Historical imports set
importedAt.