Skip to content
Reference

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:

  1. Tenant routing. A message.status webhook arrives on your single webhook URL. Which end-customer does it belong to?
  2. Message correlation. You sent a message and got back a msg_… id. The delivery-status webhooks (sentdeliveredread / failed) identify the message by Meta’s wamid.…. 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.

Message-level events — message.received, message.sent, and message.statusdo 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:

EventWhere phone_number_id lives
message.sentdata.meta.phone_number_id (also data.session, an alias)
message.statusentry[0].changes[0].value.metadata.phone_number_id
message.receivedentry[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.

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.

  1. Send. POST /v1/{phone_number_id}/messages returns { "id": "msg_…", "status": "pending", … }. There is no wamid yet — the message is queued and Meta hasn’t accepted it.

  2. message.sent arrives. 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 from phone_number_id).

  3. message.status arrives (one per transition: sent, delivered, read, failed). It identifies the message by wamid only:

    { "statuses": [{ "id": "wamid.HBgN…", "status": "delivered" }] }

    Join statuses[0].id back to your msg_… via the pair from step 2.

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 on msg_….
  • If a status arrives for a wamid you haven’t mapped yet, store it and reconcile when the matching message.sent lands.

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.

Failures split into two cases, and the identifier tells you which:

Casestatuses[0].idHow to correlate
Failed after Meta accepted itwamid.…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.

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.

  1. On customer.onboarded, persist phone_number_id → customer_id.
  2. Send via POST /v1/{phone_number_id}/messages; store the returned msg_… against the tenant.
  3. On message.sent, store msg_… ↔ wamid.… (routed to the tenant via phone_number_id / data.session).
  4. On message.status, resolve the tenant via phone_number_id, then join statuses[0].id to your msg_… — directly if it’s a msg_… (pre-Meta failure), or via the map if it’s a wamid.….
  5. Buffer any status that outruns its message.sent and reconcile on arrival.