Inbox and DMs
Build one inbox per customer, written by webhooks and read from your database, with replies and backfills through the inbox API.
When you finish this page each customer reads and answers their DMs from Instagram, Facebook, X and the other messaging platforms inside your product. You need each customer's profile, created with their externalId as in the core model, and one webhook endpoint. Webhooks write your inbox database; API calls are for sending and backfill. Conversations belong to accounts, and accounts live in the customer's profile, so profileId scopes an inbox to one customer.
Polling each of 500 customers' conversations every 30 seconds is 1,000 requests per minute of mostly empty responses, above the 600 per minute rate limit, with up to 30 seconds of latency. Webhook-first costs about 0 standing requests and delivers on push.
Step 1: Write your inbox from webhooks
Subscribe once to the inbox events and handle each one:
| Event | Fires when | What to update |
|---|---|---|
conversation.started | First message of a new conversation | Create the conversation |
message.received | An incoming DM arrives | Append the message, bump the unread count |
message.sent | A message goes out from the connected account | Append or confirm the message |
message.delivered / message.read | The platform reports receipts | Mark the message row delivered or read |
message.failed | A send is rejected | Show the error inline so the customer can retry |
Inbox payloads carry the account, not the profile. A message.received delivery:
{
"id": "evt_5f8e2a1c",
"event": "message.received",
"message": {
"id": "66d4a1b2c3e4f5a6b7c8d9e1",
"conversationId": "66c3d2ae7b4f6c8d0e1f2a3b",
"platform": "instagram",
"direction": "incoming",
"text": "Hi, is this still available?",
"sender": { "id": "17841400000000000", "name": "Jane", "username": "jane_doe" }
},
"conversation": { "id": "66c3d2ae7b4f6c8d0e1f2a3b", "status": "active" },
"account": { "id": "66b2e19d8c3f5a7e9d0b1c2d", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "profileId": "66a1f0c2a4b9d3e8f1a2b3c4", "platform": "instagram" },
"profiles": [{ "id": "66a1f0c2a4b9d3e8f1a2b3c4", "externalId": "cus_8f3a2" }],
"timestamp": "2027-01-04T14:00:03Z"
}The customer is profiles[0].externalId, the id you set on their profile in the core model; account.id tells you which of their accounts received the message. One endpoint serves every customer. Delivery is at least once, so dedupe on the event id, and acknowledge within 5 seconds by enqueueing the event and processing it on a worker; a slower handler triggers redeliveries. X-Zernio-Signature is the hex HMAC-SHA256 of the raw body keyed by your endpoint's secret, so compare it before you parse; the webhooks overview covers the secret and the retry schedule.
import crypto from 'crypto';
// Your single webhook endpoint, shared by every customer
export const POST = async (req: Request) => {
const secret = process.env.ZERNIO_WEBHOOK_SECRET;
if (!secret) return new Response('No secret provided.', { status: 401 });
const rawBody = await req.text();
const computedSignature = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
if (req.headers.get('X-Zernio-Signature') !== computedSignature) {
return new Response('Invalid signature', { status: 400 });
}
const event = JSON.parse(rawBody);
if (await alreadyProcessed(event.id)) return Response.json({ ok: true });
await queue.enqueue(event);
return Response.json({ ok: true });
};
// Worker, off the request path
async function handleInboxEvent(event) {
const customer = await customerById(event.profiles[0].externalId);
switch (event.event) {
case 'conversation.started':
return db.createConversation(customer.id, event.conversation);
case 'message.received':
return db.appendMessage(customer.id, event.message, { unread: +1 });
case 'message.sent':
case 'message.delivered':
case 'message.read':
return db.updateMessageStatus(customer.id, event.message);
case 'message.failed':
return db.markFailed(customer.id, event.message);
}
}WhatsApp conversations run through the same inbox API with extra rules (the 24-hour customer service window, template messages). See WhatsApp inbox.
Step 2: Send replies
Call POST /v1/inbox/conversations/{conversationId}/messages with accountId and message. Send an Idempotency-Key header, one UUID per reply, so a retry after a lost response replays the original 200 instead of sending the message twice; the idempotency guide has the full rules. The Python client's send_inbox_message takes no idempotency key, so a retry from Python sends a second message. A typing indicator while the customer composes and mark read when they open the conversation make the embedded inbox feel native.
import { randomUUID } from 'crypto';
import Zernio from '@zernio/node';
const zernio = new Zernio();
const { data: sent } = await zernio.messages.sendInboxMessage({
path: { conversationId: '66c3d2ae7b4f6c8d0e1f2a3b' },
headers: { 'Idempotency-Key': randomUUID() },
body: {
accountId: '66b2e19d8c3f5a7e9d0b1c2d',
message: 'Yes, still available. Want me to hold it for you?',
},
});
console.log(sent.data.messageId);Response (200):
{
"success": true,
"data": {
"messageId": "aWdfZAG1faXRlbToxOklHTWVzc2FnZAUlEOjE3ODQx",
"conversationId": "66c3d2ae7b4f6c8d0e1f2a3b"
}
}The 200 means accepted. Delivery status arrives through the same webhook stream that writes your inbox:
Run a customer's burst of sends (a "reply to all" feature, an automation) through a queue per customer, because the rate limit applies to your key as a whole. On a 429, wait for the Retry-After header before retrying, as the analytics worker does.
Step 3: Backfill with the list endpoint
Call GET /v1/inbox/conversations with profileId when a customer's accounts connect, and page with cursor; then messages per conversation. Run the same sweep on a schedule to catch anything a missed webhook left behind. The endpoint aggregates DMs across the customer's messaging accounts (Facebook, Instagram, X, Bluesky, Reddit, Telegram, WhatsApp, SMS, Slack).
const { data: conversations } = await zernio.messages.listInboxConversations({
query: {
profileId: '66a1f0c2a4b9d3e8f1a2b3c4',
status: 'active',
limit: 50,
},
});
console.log(conversations.pagination.nextCursor);Response (200):
{
"data": [
{
"id": "66c3d2ae7b4f6c8d0e1f2a3b",
"platform": "instagram",
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"participantName": "Jane",
"lastMessage": "Hi, is this still available?",
"updatedTime": "2027-01-04T14:00:03Z"
}
],
"pagination": { "hasMore": true, "nextCursor": "eyJ1cGRhdGVkVGltZSI6..." },
"meta": { "accountsQueried": 3, "accountsFailed": 0, "failedAccounts": [] }
}Check meta.accountsFailed on every sweep. Accounts listed in meta.failedAccounts have token problems, which is your cue to run the reconnect loop for that customer.
Run the onboarding sweep twice for Instagram and Facebook. When one of these accounts connects, Zernio replays the DM history it already holds on Meta into the inbox, in the background, and that replay can finish after your sweep has walked the list. Replayed conversations keep their real lastMessageAt, so a 2-year-old conversation sorts 2 years back, on pages you already read, and the replay fires no webhooks. Re-run the sweep an hour later, or let the scheduled sweep pick it up.
To report inbox performance to a customer (volume, response time, heatmap, source breakdown), the inbox analytics endpoints take the same profileId filter.
If it fails
A 400 from Step 2 with code: "PLATFORM_LIMITATION" means the platform does not offer what you sent, for example an attachment on a Bluesky or Reddit DM:
{
"error": "Attachments are not supported for bluesky conversations",
"code": "PLATFORM_LIMITATION"
}Send the message without the attachment.
A 500 means the platform rejected or failed the send, and Zernio never retries a send internally. It is ambiguous: the platform may already have accepted the message, and the 500 releases your Idempotency-Key, so a retry with the same key sends for real. Reconcile before you retry. List the conversation's messages first and treat an empty result as inconclusive rather than as proof nothing was sent. The key covers the other case, where the send succeeded and the response never reached you: that retry replays the original 200 with Idempotent-Replayed: true.
Related
- Build a platform: the profile-per-customer model.
- Analytics dashboards: the same architecture applied to metrics.
- Inbox webhooks: full payload schemas for every message event.
- WhatsApp inbox: templates and the 24-hour window.