If your business talks to customers on WhatsApp, Instagram, and Telegram, you probably already have three different "versions" of the same person. On WhatsApp they're a phone number. On Instagram they're an IGSID (Instagram-Scoped ID). On Telegram they're a numeric
API access to the three channels: a WhatsApp Business Platform account (via Meta or a BSP), an Instagram Professional account connected to a Facebook Page with instagram_manage_messages permission, and a Telegram bot token from BotFather.
A backend that can receive webhooks (Node.js/Express, Python/FastAPI, or similar) with HTTPS endpoints.
A database that supports flexible schema evolution — Postgres with JSONB columns works well; a document store like MongoDB also fits.
Basic familiarity with webhook signature verification (each platform signs its payloads differently).
A queue (SQS, RabbitMQ, or even Postgres-as-a-queue) to decouple ingestion from profile-matching, since matching logic can get slow under load.
The Core Problem: Three Identity Systems, Zero Overlap
Picture a customer named Amara. She messages you on Instagram to ask about a delayed order, then two days later texts your WhatsApp number about the same order because it's faster. Without a unified profile:
Your Instagram bot has no memory of the WhatsApp conversation.
Your support agent on WhatsApp re-asks for the order number Amara already gave on Instagram.
Any automation ("send a satisfaction survey after resolution") fires twice, once per channel, annoying her further.
The fix isn't "pick one channel." It's building a resolution layer that sits underneath all three channel integrations and exposes one customer_id to everything downstream — your CRM, your analytics, your automation rules.
Each channel's webhook lands in the same ingestion layer, gets normalized into a common event shape, and is handed to a resolution service that decides "have I seen this human before, under a different identity?"
Designing the Unified Profile Schema
The key design decision is separating identities (channel-specific handles) from the profile (the merged human). Never store channel IDs as your primary key — you'll paint yourself into a corner the first time a customer uses a second channel.
{
"customer_id": "cust_8f21ac",
"created_at": "2026-08-02T10:14:00Z",
"display_name": "Amara Chen",
"verified_contact": {
"phone": "+15551234567",
"email": null
},
"identities": [
{
"channel": "whatsapp",
"channel_user_id": "15551234567",
"linked_at": "2026-08-02T10:14:00Z",
"confidence": "verified"
},
{
"channel": "instagram",
"channel_user_id": "179384756201",
"username": "amara.c",
"linked_at": "2026-08-04T09:02:11Z",
"confidence": "high"
},
{
"channel": "telegram",
"channel_user_id": "623910442",
"username": "amarac",
"linked_at": null,
"confidence": "unlinked"
}
],
"attributes": {
"order_ids": ["ORD-90213"],
"language": "en",
"tags": ["vip", "delayed-order"]
}
}
Two fields do most of the work here:
identities[] — every channel handle ever seen for this profile, each with a confidence level so downstream systems know how certain the match is.
confidence — not every match is equally trustworthy (more on this below). Automations that send money or sensitive data should only trust verified links; a chatbot pulling up order history can act on high confidence.
Identity Resolution: Matching Channels to a Person
Resolution is a ranked set of signals, tried in order of reliability:
Explicit link (verified) — the customer typed the same phone number into a WhatsApp chat and an Instagram chat, or completed an OTP flow that ties an IGSID to a phone number. This is the only tier you should call "verified."
Deterministic match (high) — same phone number appears in Instagram's ig-connected-account field (available if the customer linked accounts), or the customer's Telegram username matches a value already stored against another channel.
Contextual match (medium) — same order number, same support ticket ID, or the customer says "I messaged you on WhatsApp about this" inside an Instagram thread. Worth flagging for a human to confirm, not for full auto-merge.
No match — create a new profile.
async function resolveIdentity(event) {
const { channel, channelUserId, extractedPhone, extractedOrderId } = event;
// Tier 1: verified phone match
if (extractedPhone) {
const existing = await db.profiles.findOne({
"verified_contact.phone": extractedPhone
});
if (existing) return attachIdentity(existing.customer_id, channel, channelUserId, "verified");
}
// Tier 2: deterministic username/ID match
const usernameMatch = await db.profiles.findOne({
"identities.username": event.username,
"identities.channel": { $ne: channel }
});
if (usernameMatch) return attachIdentity(usernameMatch.customer_id, channel, channelUserId, "high");
// Tier 3: contextual match, queued for human review
if (extractedOrderId) {
const orderMatch = await db.profiles.findOne({ "attributes.order_ids": extractedOrderId });
if (orderMatch) {
await flagForReview(orderMatch.customer_id, channel, channelUserId, extractedOrderId);
return attachIdentity(orderMatch.customer_id, channel, channelUserId, "medium");
}
}
// No match — new profile
return createProfile(channel, channelUserId);
}
Order numbers, tracking IDs, and email addresses mentioned in message text are strong contextual signals — a lightweight regex or NER pass over inbound message text (before it hits your bot) is usually enough to extract them without a full NLP stack.
Ingesting Events From Each Channel
Each platform's webhook payload has a different shape, so normalize at the door. Here's a minimal Express handler pattern for the three inbound webhooks:
app.post('/webhooks/whatsapp', verifyMetaSignature, (req, res) => {
const msg = req.body.entry[0].changes[0].value.messages?.[0];
if (msg) enqueue(normalize('whatsapp', msg.from, msg));
res.sendStatus(200);
});
app.post('/webhooks/instagram', verifyMetaSignature, (req, res) => {
const msg = req.body.entry[0].messaging[0];
enqueue(normalize('instagram', msg.sender.id, msg));
res.sendStatus(200);
});
app.post('/webhooks/telegram', verifyTelegramSecret, (req, res) => {
const msg = req.body.message;
enqueue(normalize('telegram', String(msg.from.id), msg));
res.sendStatus(200);
});
function normalize(channel, channelUserId, raw) {
return {
channel,
channelUserId,
text: extractText(channel, raw),
timestamp: Date.now(),
raw
};
}
Each of the three verify* middlewares matters more than it looks — Meta signs WhatsApp and Instagram payloads with an X-Hub-Signature-256 HMAC, while Telegram relies on a secret token in the URL path or the X-Telegram-Bot-Api-Secret-Token header. Skipping verification means anyone who finds your endpoint URL can inject fake events into a real customer's profile.
Handling Conflicts and Merges
Two situations will break a naive implementation:
Same phone number, different humans. Shared family phones or reissued numbers mean a verified phone match isn't always the same person over time. Store a linked_at timestamp per identity and consider expiring verified status after a configurable window of inactivity (say, 12 months) so a stale link doesn't silently misattribute a new person's messages to someone else's history.
Two profiles need merging after the fact. If your resolution service later discovers that cust_8f21ac and cust_44a1b0 are the same person (e.g., a human agent confirms it), merges should be append-only and reversible:
async function mergeProfiles(primaryId, secondaryId, mergedBy) {
const secondary = await db.profiles.findOne({ customer_id: secondaryId });
await db.profiles.updateOne(
{ customer_id: primaryId },
{
$push: { identities: { $each: secondary.identities } },
$set: { [`merge_history.${secondaryId}`]: { mergedBy, mergedAt: new Date() } }
}
);
await db.profiles.updateOne(
{ customer_id: secondaryId },
{ $set: { merged_into: primaryId, active: false } }
);
}
Never hard-delete the secondary profile — keeping it as a soft-redirect (merged_into) means any system still holding the old customer_id in cache or in an old support ticket can be resolved forward without breaking references.
Privacy and Retention Considerations
A few design choices to bake in from day one rather than retrofit later:
Data minimization. Don't pull more profile data from each platform's API than your use case needs — fetching a full Instagram profile (bio, follower count) for every inbound DM is usually unnecessary and adds compliance surface area.
Right-to-erasure support. Structuring identities as an array on one profile document (rather than scattered across per-channel tables) makes it straightforward to delete or anonymize one customer_id and every identity attached to it in a single operation.
Consent per channel. A customer messaging you on Instagram hasn't necessarily consented to being contacted on WhatsApp, even if you've matched the identities. Keep a contactable_channels list separate from identities so matching for context doesn't silently become permission to message.
Audit trail on merges. Keep the merge_history field shown above — when (not if) a merge turns out to be wrong, you need to know who approved it and unwind it cleanly.
Conclusion
A single customer profile isn't a single database table it's a resolution pipeline: normalize events from each channel, score identity matches by confidence tier, store identities as a list rather than a key, and treat merges as reversible operations with an audit trail. Get that foundation right and everything downstream — your support inbox, your chatbot's memory, your analytics gets simpler instead of harder as you add more messaging channels.
The channels will keep multiplying (WhatsApp, Instagram, Telegram today; whatever's next tomorrow). The identity-resolution layer is the piece that keeps your customer data sane no matter how many of them you plug in.

Top comments (0)