Message correlation & tenant routing
If you run a multi-tenant SaaS on platform mode — one integration, many end-customers, each with their own WhatsApp number — you have two correlation problems to solve on your side:
- Tenant routing. A
message.statuswebhook arrives on your single webhook URL. Which end-customer does it belong to? - Message correlation. You sent a message and got back a
msg_…id. The delivery-status webhooks (sent→delivered→read/failed) identify the message by Meta’swamid.…. How do you link the two?
Both are deterministic. This page is the end-to-end recipe. You never need to fall back on the destination number, timestamp, message body, or send order to correlate.
The one identifier that ties it together: phone_number_id
Section titled “The one identifier that ties it together: phone_number_id”Every end-customer connects exactly one WhatsApp number, and that number
has a stable phone_number_id (Meta’s business_phone_number_id).
This is the join key between a tenant and every event about their
messages.
You learn a customer’s phone_number_id the moment they finish
onboarding, from the customer.onboarded
event:
{ "type": "customer.onboarded", "data": { "customer_id": "cus_335T08RM0EAKN9DTE6RD5RWP7B", "account_id": "…", "phone_number_id": "1111475158712095", "phone_number": "+62 857-2516-5424" }}Persist phone_number_id → customer_id in your own database on receipt.
That map is the backbone of every routing decision below.
Routing a message webhook to a tenant
Section titled “Routing a message webhook to a tenant”Message-level events — message.received, message.sent, and
message.status — do not carry a cus_… id in their payload. They
carry phone_number_id, and you resolve the tenant from your own map.
There are two ways to consume them, and you can mix them:
Option A — one webhook URL, route by phone_number_id
Section titled “Option A — one webhook URL, route by phone_number_id”Subscribe once at the org level and read phone_number_id off each
event:
| Event | Where phone_number_id lives |
|---|---|
message.sent | data.meta.phone_number_id (also data.session, an alias) |
message.status | entry[0].changes[0].value.metadata.phone_number_id |
message.received | entry[0].changes[0].value.metadata.phone_number_id |
Look the value up in your phone_number_id → customer_id map. That is
the tenant. This is the simplest setup and scales to any number of
customers on a single endpoint.
Option B — one subscription per customer, scoped by number
Section titled “Option B — one subscription per customer, scoped by number”A webhook subscription can be scoped to specific numbers via
phone_number_ids. Events for other numbers never reach that
subscription, so the subscription that received the event is the tenant
discriminator:
await kirim.webhookSubscriptions.create({ url: 'https://edupiere.example.com/webhooks/school-a', events: ['message.sent', 'message.status', 'message.received'], phone_number_ids: ['1111475158712095'], // School A's number only})Use this when you want hard per-tenant isolation at the delivery layer (separate URLs, separate secrets, separate failure handling). See Webhooks / Overview for the full subscription and per-account filter model.
Correlating msg_… with wamid.…
Section titled “Correlating msg_… with wamid.…”Within one tenant, you still need to link a specific send to its status
updates. The send gives you a msg_… id; the status webhooks speak
wamid.…. The message.sent event is
where the two meet.
-
Send.
POST /v1/{phone_number_id}/messagesreturns{ "id": "msg_…", "status": "pending", … }. There is nowamidyet — the message is queued and Meta hasn’t accepted it. -
message.sentarrives. It carries both ids together:{"type": "message.sent","data": {"message": {"id": "msg_01HXYZABCDEFGHJKMNPQRSTVWX","provider_id": "wamid.HBgN…"},"meta": { "phone_number_id": "1111475158712095" }}}Store the pair
msg_… ↔ wamid.…(scoped to the tenant you resolved fromphone_number_id). -
message.statusarrives (one per transition:sent,delivered,read,failed). It identifies the message bywamidonly:{ "statuses": [{ "id": "wamid.HBgN…", "status": "delivered" }] }Join
statuses[0].idback to yourmsg_…via the pair from step 2.
Ordering is not guaranteed
Section titled “Ordering is not guaranteed”message.status can arrive before message.sent for the same
message — they travel independent paths and webhook delivery order is not
guaranteed. Design for it:
- Key your status buffer on
wamid, not onmsg_…. - If a status arrives for a
wamidyou haven’t mapped yet, store it and reconcile when the matchingmessage.sentlands.
Statuses for a single message can also arrive out of order among
themselves (a read before a delivered). Treat status as a state
machine, not a sequence.
Failed sends
Section titled “Failed sends”Failures split into two cases, and the identifier tells you which:
| Case | statuses[0].id | How to correlate |
|---|---|---|
| Failed after Meta accepted it | wamid.… | Join via the message.sent map, same as any status. |
| Failed before Meta accepted it (validation, quota, compliance, transient outage) | msg_… | No wamid ever existed. The id equals the id from your send response — correlate directly. |
For the pre-Meta case, errors[0].code is Meta’s integer code when the
failure came back from Meta, or the sentinel -1 for a Kirimdev-side
refusal. Map either via the error catalogue. Treat
statuses[0].id as an opaque string that may start with wamid. or
msg_ and you handle both cases with one branch. See
pre-delivery failures for the full
shape.
Retention
Section titled “Retention”The msg_… ↔ wamid.… mapping lives on the message record. Once a message
passes an organization’s retention window and is deleted, that mapping is
no longer available for lookup through the API. Persist your own
msg_… ↔ wamid.… map when message.sent fires and treat it as the
system of record for correlation — don’t rely on the platform to
reconstruct it later. See Message retention.
End-to-end checklist
Section titled “End-to-end checklist”- On
customer.onboarded, persistphone_number_id → customer_id. - Send via
POST /v1/{phone_number_id}/messages; store the returnedmsg_…against the tenant. - On
message.sent, storemsg_… ↔ wamid.…(routed to the tenant viaphone_number_id/data.session). - On
message.status, resolve the tenant viaphone_number_id, then joinstatuses[0].idto yourmsg_…— directly if it’s amsg_…(pre-Meta failure), or via the map if it’s awamid.…. - Buffer any status that outruns its
message.sentand reconcile on arrival.
See also
Section titled “See also”- Onboarding flow — where
customer.onboardedand itsphone_number_idcome from. - Platform → Webhooks — the six
customer.*lifecycle events. - Event Catalogue —
message.sent,message.status, and pre-Meta failures in full. - Payload examples — copy-paste fixtures for your handler tests.
- Message retention — what deletion removes.