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):
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.
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
- 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.