Tenant Inbox & DMs
Build a unified social inbox for each of your customers - webhook-first message ingestion, routing events to the right tenant, replying, and backfills.
You want each customer to see and answer their DMs — Instagram, Facebook, X, and more — inside your product. This recipe shows the architecture that scales: webhooks write your inbox database; API calls are for sending and backfill.
The model
Conversations belong to connected accounts, and accounts live in the customer's profile, so profileId scopes an inbox to one tenant. GET /v1/inbox/conversations aggregates DMs across a tenant's messaging accounts in one call (Facebook, Instagram, X, Bluesky, Reddit, Telegram, WhatsApp, SMS, Slack):
const { data } = await zernio.messages.listInboxConversations({
query: {
profileId: customer.zernioProfileId,
status: 'active',
limit: 50,
},
});
// data.data → conversations; data.pagination.nextCursor → next pagedata = client.messages.list_inbox_conversations(
profile_id=customer.zernio_profile_id,
status="active",
limit=50,
)
# data["data"] → conversations; data["pagination"]["nextCursor"] → next pagecurl "https://zernio.com/api/v1/inbox/conversations?profileId=PROFILE_ID&status=active&limit=50" \
-H "Authorization: Bearer YOUR_API_KEY"WhatsApp conversations run through the same inbox APIs with extra rules (24-hour customer-service window, template messages). See WhatsApp inbox for the specifics.
Webhooks write your inbox, not polling
The list endpoint above is for backfill — not for discovering new messages. Subscribe to the inbox webhooks once, and events write your database the moment they happen. Your tenant UIs read your database; the only API calls left are sends and occasional backfills:
The math is what makes this non-negotiable at multi-tenant scale:
| Architecture | Standing API load with 500 tenants | New-message latency |
|---|---|---|
| Poll each tenant's conversations every 30s | 1,000 req/min — above the 600 req/min tier, almost all of it empty responses | Up to 30s |
| Webhook-first | ~0 — your budget goes to sends and backfills | Push, near-instant |
Handle these events and your inbox stays complete:
| Event | Fires when | What to update |
|---|---|---|
conversation.started | First message of a new thread | Create the thread |
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 / on your message row |
message.failed | A send is rejected | Show the error inline so the tenant can retry |
Routing an event to the right tenant
Inbox payloads carry the account, not the profile:
{
"id": "5f8e2a1c-...",
"event": "message.received",
"message": {
"id": "665f...",
"conversationId": "664a...",
"direction": "incoming",
"text": "Hi! Is this still available?",
"sender": { "name": "Jane", "username": "jane_doe" }
},
"account": { "id": "665f1c2e8b3a4d0012345678", "platform": "instagram" }
}Look up account.id in the accountId → customer mapping you stored when the account was connected. One endpoint serves every tenant:
// Your single webhook endpoint — shared by every tenant
export const POST = async (req: Request) => {
const rawBody = await req.text();
verifySignature(rawBody, req.headers.get('X-Zernio-Signature')); // see the webhooks overview
const event = JSON.parse(rawBody);
// Delivery is at-least-once: dedupe on the event id
if (await alreadyProcessed(event.id)) return Response.json({ ok: true });
// Ack within 5 seconds: enqueue now, process on a worker
await queue.enqueue(event);
return Response.json({ ok: true });
};
// Worker — off the request path
async function handleInboxEvent(event) {
const tenant = await tenantByAccountId(event.account.id); // mapping stored at connect time
switch (event.event) {
case 'conversation.started':
return db.createThread(tenant.id, event.conversation);
case 'message.received':
return db.appendMessage(tenant.id, event.message, { unread: +1 });
case 'message.sent':
case 'message.delivered':
case 'message.read':
return db.updateMessageStatus(tenant.id, event.message);
case 'message.failed':
return db.markFailed(tenant.id, event.message);
}
}# Your single webhook endpoint — shared by every tenant
@app.post("/webhooks/zernio")
async def zernio_webhook(request: Request):
raw = await request.body()
verify_signature(raw, request.headers["X-Zernio-Signature"]) # see the webhooks overview
event = json.loads(raw)
# Delivery is at-least-once: dedupe on the event id
if already_processed(event["id"]):
return {"ok": True}
# Ack within 5 seconds: enqueue now, process on a worker
queue.enqueue(event)
return {"ok": True}
# Worker — off the request path
def handle_inbox_event(event):
tenant = tenant_by_account_id(event["account"]["id"]) # mapping stored at connect time
match event["event"]:
case "conversation.started":
db.create_thread(tenant.id, event["conversation"])
case "message.received":
db.append_message(tenant.id, event["message"], unread=1)
case "message.sent" | "message.delivered" | "message.read":
db.update_message_status(tenant.id, event["message"])
case "message.failed":
db.mark_failed(tenant.id, event["message"])See the webhooks overview for signature verification and the retry schedule — a handler that misses the 5-second window triggers unnecessary redeliveries, which is load on both sides.
Sending replies
Send on the customer's behalf with POST /v1/inbox/conversations/{conversationId}/messages. Two small calls make your embedded inbox feel native: a typing indicator while your customer composes, and mark read when they open the thread.
await zernio.messages.sendInboxMessage({
path: { conversationId: conversation.id },
body: { text: 'Yes, still available! Want me to hold it for you?' },
});client.messages.send_inbox_message(
conversation_id=conversation["id"],
text="Yes, still available! Want me to hold it for you?",
)curl -X POST "https://zernio.com/api/v1/inbox/conversations/CONVERSATION_ID/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "Yes, still available! Want me to hold it for you?" }'The HTTP response means accepted, not delivered — status arrives through the same webhook stream that writes your inbox:
If a tenant triggers a burst of sends (a "reply to all" feature, an automation), run it through a per-tenant queue. Rate limits apply to your key as a whole — one tenant's burst shouldn't consume the budget every other tenant's sends come from. And on a 429, wait for the Retry-After header before retrying, same as the analytics worker.
Polling is for backfill only
Two moments justify calling the list endpoints directly:
- Onboarding a tenant — after their accounts connect, page through conversations with the cursor (and messages per thread) to backfill history into your DB.
- Reconciliation — a periodic sweep to catch anything a missed webhook left behind, same as you would for analytics.
Instagram and Facebook: run the onboarding sweep twice. When one of these accounts connects, Zernio replays the DM history it already has on Meta into the inbox — conversations that started long before the account was connected. That replay runs in the background and can finish after your onboarding sweep has already walked the list.
It also does not arrive at the top. Replayed conversations keep their real lastMessageAt, so a two-year-old thread sorts two years back, on pages you have already read. A single pass right after connect can therefore look complete while silently missing years of history.
Re-run the sweep later (an hour is plenty), or let your reconciliation sweep pick it up. Replayed history fires no webhooks, so the list endpoints are the only way to see it.
When you do, check meta.accountsFailed in the response: accounts listed there have token problems, which is your cue to run the reconnect loop for that customer.
Response-time metrics per tenant
If your product reports inbox performance to its customers (agencies love this), the inbox analytics endpoints — volume, response time, heatmap, source breakdown — accept the same profileId filter, so each tenant gets their own numbers.
Checklist
- One webhook endpoint writes your inbox DB; ack under 5 seconds, process async, dedupe by event
id - Route events via
account.id→ your stored accountId → customer mapping - Send replies through the API; trust
message.sent/message.failedevents for status, not the HTTP response - Per-tenant queues for bursty sends; honor
Retry-Afteron429 - Poll only for onboarding backfill and periodic reconciliation
- Re-run the onboarding sweep for Instagram and Facebook — replayed pre-connect history lands later, in date order, and fires no webhooks
- Watch
meta.accountsFailedand trigger the reconnect loop
Related
- Multi-tenant integration - the core profile-per-customer model
- Tenant 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
Tenant Analytics
Show each of your customers their own social analytics - profileId filters, the tiered sync-worker pattern, request budgets, and data freshness expectations.
SDKs
Official Zernio API client libraries for Node.js, Python, Go, Ruby, Java, PHP, .NET, and Rust. Post to 14+ social platforms.