> For the complete documentation index, see [llms.txt](https://docs.360dialog.com/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.360dialog.com/docs/alpha-usernames/validation-guide.md).

# Validation guide

**Audience:** Developers integrating WhatsApp via 360dialog\
**Status:** Pre-GA. Use this guide to certify your integration *before* usernames reach GA.\
**Companion page:** [\[alpha\] Usernames](/docs/alpha-usernames.md)

***

## Why this guide exists

There is **no live "username-only" user you can create on demand** — Meta enables username adoption per country, on its own schedule, and phone numbers still appear in every webhook until GA.

You don't need to. The breaking change is a **deterministic contract**, not new runtime behavior: at GA, certain phone fields are simply *omitted* from webhooks you already receive. You can validate every part of that contract today by combining:

1. **Live production traffic** — BSUIDs and BSUID-sending are already live, so most of the integration is testable against real data now.
2. **Replayed fixtures** — for the one thing you can't reproduce live (a webhook with the phone field absent), replay the golden payloads in this guide through your own staging pipeline.

Work through the stages in order. Each has a clear **Pass criteria**. At the end, complete the **self-certification checklist** (§10) and you're GA-ready.

> Throughout, the test consumer is `RS.1502576394655843` (BSUID) / `310601600963` (phone). Replace with your own values.

***

## Before you start

You'll need:

* A WhatsApp number connected via 360dialog (`https://waba-v2.360dialog.io`, `D360-API-KEY` header).
* A **staging** webhook endpoint you can POST test payloads to, isolated from production data.
* At least one **recent real inbound webhook** captured from your number (it already contains a BSUID — see Stage 1).
* A way to replay JSON to your webhook endpoint (e.g. `curl`, Postman, or your test harness).

> ⚠️ Replay fixtures only into **staging**. The payloads below use example IDs and must not create real records in production.

{% stepper %}
{% step %}

## Confirm you are capturing the BSUID (live)

The BSUID is already in every production webhook. Verify you persist it.

**Steps**

1. Send a message to your business number from any WhatsApp account.
2. Inspect the inbound webhook. Confirm these fields are present and stored:
   * `contacts[0].user_id`
   * `messages[0].from_user_id`
3. Confirm you store `user_id` **keyed per business portfolio**, not globally.

**Pass criteria:** the BSUID from a real webhook lands in your datastore, associated with the correct portfolio, on the same record as the phone number.
{% endstep %}

{% step %}

## Send a message to a BSUID (early July)

~~Sending to a BSUID works in production today — no allowlist.~~

**Steps**

1. Take a `user_id` from a recent webhook (the user's 24h window must be open, or use a template).
2. Send using the `recipient` field (not `to`):

```http
POST https://waba-v2.360dialog.io/messages
D360-API-KEY: <your-api-key>
Content-Type: application/json

{
  "messaging_product": "whatsapp",
  "recipient_type": "individual",
  "recipient": "RS.1502576394655843",
  "type": "text",
  "text": { "body": "Hello!" }
}
```

3. Confirm your code parses the BSUID-only send response shape:

```json
{ "contacts": [{ "input": "RS.1502576394655843", "user_id": "RS.1502576394655843" }],
  "messages": [{ "id": "wamid.XXX" }] }
```

4. Store the returned `wamid`.

**Pass criteria:** the message sends; your parser reads `messages[0].id` and `contacts[0].user_id` (it does **not** expect `wa_id`); the `wamid` is persisted for status correlation.
{% endstep %}

{% step %}

## Handle phone-withheld webhooks (fixtures — the breaking case)

This is what changes at GA. Replay each fixture below into your staging endpoint and confirm your pipeline routes on the BSUID and never crashes on a missing phone.

#### 3a. Inbound message — BSUID only (no `wa_id`, no `from`)

```json
{
  "contacts": [{
    "profile": { "name": "Nehemiah", "username": "nehemiah.babel" },
    "user_id": "RS.1502576394655843"
  }],
  "messages": [{
    "from_user_id": "RS.1502576394655843",
    "type": "text",
    "text": { "body": "Hello!" }
  }]
}
```

**Pass criteria:** message is ingested and routed to the right conversation using `from_user_id`; no null-pointer/validation error from the missing `from` / `wa_id`; `username` is stored as a display string (no `@`) and **not** used as a key.

#### 3b. Status webhook — BSUID only

```json
{
  "contacts": [{
    "profile": { "name": "Nehemiah", "username": "nehemiah.babel" },
    "user_id": "RS.1502576394655843"
  }],
  "statuses": [{
    "id": "wamid.XXX",
    "status": "delivered",
    "recipient_user_id": "RS.1502576394655843"
  }]
}
```

**Pass criteria:** the status is matched to your sent message by `wamid` (`id`), not by recipient phone; `recipient_user_id` is used when `recipient_id` is absent.

#### 3c. Failed status — no `contacts` array

```json
{
  "statuses": [{
    "id": "wamid.XXX",
    "status": "failed",
    "recipient_user_id": "RS.1502576394655843",
    "errors": [{ "code": 131047, "title": "Message failed to send because more than 24 hours have passed since the customer last replied." }]
  }]
}
```

**Pass criteria:** your handler tolerates a `failed` status that has **no** `contacts` array; the error is logged against the correct message via `wamid`.

> For comparison, keep a **phone-present** copy of 3a/3b on hand (same payloads with `wa_id` / `from` / `recipient_id` added back) and confirm both variants flow through the same code path. See §11.
> {% endstep %}

{% step %}

## Force the BSUID-only path in your own pipeline

Fixtures prove your webhook parser; this proves the rest of your system (routing, CRM lookup, replies) degrades gracefully when the phone is gone.

Pick one:

* **Strip-and-replay:** take a copy of real production traffic and remove `wa_id`, `from`, and `recipient_id` before it reaches your ingestion.
* **Feature flag:** add a staging-only switch that nulls the phone on ingestion.

Then run a normal end-to-end flow (receive → identify → reply).

**Pass criteria:** the conversation still routes, the customer is still identified (by BSUID), and an outbound reply is sent via `recipient` — with no phone number anywhere in the flow.
{% endstep %}

{% step %}

## Error-path drills

Trigger each deliberately and confirm graceful handling.

| Drill                              | How to trigger                                                                              | Pass criteria                                                                       |
| ---------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| **`131062`** — BSUID not supported | Send a one-tap / zero-tap / copy-code **authentication** template to a `recipient` (BSUID). | Error caught; you fall back to recovering the phone (Stage 6) before retrying auth. |
| **`131047`** — window closed       | Send a free-form (non-template) message to a BSUID whose 24h window is closed.              | Error caught; you switch to a template instead of retrying free-form.               |
| **Failed w/o contacts**            | Replay fixture §3c.                                                                         | No crash on missing `contacts`; error logged by `wamid`.                            |
| {% endstep %}                      |                                                                                             |                                                                                     |

{% step %}

## Stage the phone-recovery flow (`REQUEST_CONTACT_INFO`)

> ⚠️ **Build, but don't depend on it yet.** Meta now lists this as starting early July 2026.

**Steps**

1. Create the utility template (the `text` field is optional and the button label cannot be customized; if you include it, any value other than `Share Contact Info` is rejected with subcode `2388153`):

```json
{
  "name": "request_number",
  "category": "utility",
  "language": "en_US",
  "components": [
    { "type": "BODY", "text": "Please share your phone number so we can continue." },
    { "type": "BUTTONS", "buttons": [
      { "type": "REQUEST_CONTACT_INFO"}
    ]}
  ]
}
```

2. Validate your handler for the on-tap response by replaying this fixture:

```json
{
  "messages": [{
    "from_user_id": "RS.1502576394655843",
    "type": "contacts",
    "origin": "contact_request",
    "contacts": [{
      "phones": [{ "phone": "+31 06 0160 0963", "wa_id": "310601600963" }]
    }]
  }]
}
```

**Pass criteria:** you read the shared number from `messages[0].contacts[0].phones[0]`, normalize on `phones[0].wa_id` (digits only, no `+`/spaces), and map it to the existing BSUID record. Handle both `origin: "contact_request"` (button tap, **no** vCard) and `origin: "other"` (manual share, vCard included).
{% endstep %}

{% step %}

## Username & business username plumbing

**User username (display only):** confirm you store `contacts[0].profile.username` as a changeable display string and never use it as a key (see §3a).

**Business username (your number's handle):**

1. List suggestions: `GET /username_suggestions`
2. Claim: `POST /username` with `{ "username": "yourbrand" }`
3. Verify: `GET /username` → `{ "username": "...", "status": "..." }`
4. Subscribe each app to the `business_username_update` webhook and confirm you receive `approved` / `reserved` / `deleted` events.

**Pass criteria:** you can claim/verify a reserved handle and react to `business_username_update`. Remember: a business username does **not** hide your phone number.
{% endstep %}

{% step %}

## Data-model & migration checks

Review (code review, not runtime) that:

* The consumer phone number is **nullable** everywhere — schema, validation, UI.
* A **BSUID ↔ phone mapping table** exists; phone is treated as enrichment, not identity.
* BSUID is keyed **per portfolio**; you never compare or dedupe BSUIDs across clients.
* Identity routing is **by field name** (`wa_id` = phone, `user_id` = BSUID) — no generic E.164 validation on identity fields.
* You tolerate unknown/new fields (`username`, `parent_user_id`, `recipient_parent_user_id`, `from_user_id`).
* If you're a multi-portfolio managed business, you've evaluated **parent BSUID** (`parent_user_id`, format includes `ENT`) enrollment with your 360dialog contact.

**Pass criteria:** all boxes hold under review.
{% endstep %}

{% step %}

## Deploy early and watch real traffic

Because phone numbers remain in webhooks through the entire pre-GA window, you can validate in production with zero risk:

1. Deploy your BSUID-storing, phone-nullable code to **production** now.
2. Watch real traffic for several weeks — confirm BSUIDs are captured on every conversation and that sends correlate by `wamid`.
3. By GA, the only change is *fewer phone fields* — a path you already proved with the fixtures in §3.

**Pass criteria:** production shows BSUIDs persisted on 100% of recent conversations, with no parsing errors.
{% endstep %}
{% endstepper %}

***

## Self-certification checklist

Sign off each line before GA. Every item maps to a stage above.

**Capture & send**

* [ ] BSUID stored from every webhook, keyed per portfolio (§1)
* [ ] Can send to a `recipient` (BSUID) and parse the `{ input, user_id }` response (§2)
* [ ] `wamid` stored on every send and used to correlate statuses (§2)

**Phone-withheld contract**

* [ ] Inbound BSUID-only webhook routes correctly, no crash on missing `from`/`wa_id` (§3a)
* [ ] Status BSUID-only webhook matched by `wamid` / `recipient_user_id` (§3b)
* [ ] `failed` status with no `contacts` array handled (§3c)
* [ ] Whole pipeline (identify + reply) works with phone forced absent (§4)

**Errors**

* [ ] `131062` handled — fall back to phone recovery for auth templates (§5)
* [ ] `131047` handled — switch to a template on closed window (§5)

**Recovery & usernames**

* [ ] `REQUEST_CONTACT_INFO` handler built and fixture-tested; not relied on in production yet (§6)
* [ ] `username` stored as display-only, never a key (§3a, §7)
* [ ] Business username claimed; subscribed to `business_username_update` (§7)

**Data model**

* [ ] Phone nullable everywhere; BSUID↔phone mapping table exists (§8)
* [ ] Routing by field name, no generic E.164 validation on identity fields (§8)
* [ ] Parent BSUID evaluated (multi-portfolio only) (§8)

**Production**

* [ ] BSUID-storing code live in production and verified against real traffic (§9)

***

## Fixture appendix — phone-present variants

Keep these alongside the BSUID-only fixtures and confirm both flow through the **same** code path.

**Inbound — phone present:**

```json
{
  "contacts": [{
    "profile": { "name": "Nehemiah", "username": "nehemiah.babel" },
    "wa_id": "310601600963",
    "user_id": "RS.1502576394655843"
  }],
  "messages": [{
    "from": "310601600963",
    "from_user_id": "RS.1502576394655843",
    "type": "text",
    "text": { "body": "Hello!" }
  }]
}
```

**Status — phone present:**

```json
{
  "contacts": [{
    "profile": { "name": "Nehemiah", "username": "nehemiah.babel" },
    "wa_id": "310601600963",
    "user_id": "RS.1502576394655843"
  }],
  "statuses": [{
    "id": "wamid.XXX",
    "status": "delivered",
    "recipient_id": "310601600963",
    "recipient_user_id": "RS.1502576394655843"
  }]
}
```

***

## Need help?

* Bespoke or high-complexity integration? Ask your 360dialog account manager about a deep-dive review session.
* Found a discrepancy between this guide and live behavior? Report it — pre-GA behavior is still being confirmed with Meta engineering.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.360dialog.com/docs/alpha-usernames/validation-guide.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
