# WhatsApp for platforms

If you run a platform with many merchants —restaurants, shops, clinics— you can give each of them their own WhatsApp with a Met that answers, **without your merchants ever leaving your product or having a Meteor user**. This guide puts the four pieces together in the order you use them:

1. **Create the account** for the merchant.
2. **Connect their WhatsApp line** with a single-use link.
3. **Learn the result** by webhook.
4. **Show them their inbox** inside your page, with your theme, your color and your language.

Your backend does steps 1, 2 and 4 with a **partner API key** from an approved Tech Partner. A workspace key won't work for them: their permissions only exist on partner keys, and none of them works with a test key (`met_test_`), because they connect real numbers and answer real chats. Step 3 is different: the webhook is registered on your own Met account, with a workspace key of that account.

| Step | Scope | Route |
|---|---|---|
| Create the account | `workspaces:provision` | `POST /workspaces` |
| Connect WhatsApp | `channels:connect` | `POST /workspaces/{workspaceId}/whatsapp/connect-sessions` |
| Line notifications | `channels:read` (on your Met account) | Webhooks `channel.line.connected` and `channel.line.failed` |
| Embedded inbox | `inbox:embed` | `POST /workspaces/{workspaceId}/inbox-sessions` and `DELETE …/inbox-sessions/{sessionId}` |

Steps 2 and 4 only work on **accounts attributed to you**. On any other account, the API answers `403 workspace_not_attributed`.

> **Before you start, register your origins.** Meteor keeps, per partner, the list of https origins from which your platform opens the connection and the inbox. `return_origin`, the origin of `redirect_url` and `parent_origin` must be on that list; otherwise the API answers `403 origin_not_allowed`. Ask your Meteor contact to add yours.
>
> **The inbox is in pilot.** It works end to end. Write to us before rolling it out to all your merchants.

## 1. Create the merchant's account

```ts
import Met from '@meteor.ia/sdk';

const met = new Met(process.env.MET_PARTNER_KEY!);

const account = await met.workspaces.create(
  {
    business_name: 'La Esquina Restaurant',
    owner_email: 'owner@laesquina.co',
    owner_name: 'Carlos Ruiz',
    owner_phone: '+573001234567',
    country: 'CO',
  },
  { idempotencyKey: `signup-${merchant.id}` },
);
```

The account is born on the Tech Partner plan, **with no energy** and attributed to you. Top it up before its first message: without energy, the Met does not answer. The details —quotas, top-ups, handover— are in [Account provisioning](account-provisioning.html).

## 2. Generate the link to connect WhatsApp

You request a connect session and show the merchant the `connect_url`. There they go through Meta's sign-up and come back to your product.

```ts
const session = await met.workspaces.whatsappConnectSession(
  account.id,
  { return_origin: 'https://app.yourplatform.com' },
  { idempotencyKey: `connect-${merchant.id}` },
);
// session.connect_url  → open it in a popup window
// session.session_id   → keep it to match it with the result
```

```python
session = met.workspaces.whatsapp_connect_session(
    account["id"],
    return_origin="https://app.yourplatform.com",
    idempotency_key=f"connect-{merchant_id}",
)
```

| Field | What it is |
|---|---|
| `connection_type` | `coexistence` (default): the merchant keeps using the WhatsApp Business app on their phone and connects it by scanning a QR code. `dedicated`: a number used only through the API. |
| `return_origin` | The https origin of your page that opens the window. Origin only, no path. It must be on your allowed origins, as must the origin of `redirect_url`. |
| `redirect_url` | The https URL the merchant returns to when done, if you prefer a link over a window. |

Send **at least one** of `return_origin` and `redirect_url` (otherwise `400 missing_return_destination`). If you send both, they must share the same domain (`400 return_destinations_mismatch`).

The `connect_url` works **only once**: it is valid for 24 hours unopened and, from the moment the merchant opens it, for 15 minutes. Don't store it or write it to your logs. Retrying with the same `Idempotency-Key` within 24 hours returns the same session.

### Popup window variant

Open the `connect_url` with `window.open` from a page served on `return_origin`. When it finishes, the window notifies yours by `postMessage`, **only to that origin**:

```ts
const CONNECT_ORIGIN = new URL(session.connect_url).origin;

window.addEventListener('message', (e) => {
  if (e.origin !== CONNECT_ORIGIN) return;
  if (e.data?.type !== 'meteor.whatsapp.connect') return;
  const { status, line_id, phone_number, session_id, error_code } = e.data;
  // status: 'completed' | 'failed'
});
```

On `failed` you get `error_code` (for example `meta_connection_failed` or `waba_already_connected`), and `line_id` and `phone_number` come as `null`.

### Link variant

With `redirect_url`, the merchant comes back to your page with the result in the URL: `?status=completed&session_id=…&line_id=…`, or `?status=failed&session_id=…&error_code=…`. Any parameter your URL already had is kept.

In both variants, **don't take the browser's result as the truth**: the merchant may close the window early. That's what the next step is for.

## 3. Learn about each line by webhook

When the connection finishes, Meteor emits two signed events:

| Event | When |
|---|---|
| `channel.line.connected` | The merchant finished connecting their line. |
| `channel.line.failed` | The connection failed. Carries `error_code`, never Meta's raw message. |

They reach the merchant's workspace and, if your partner account is **linked to a Met account of yours**, that account too. That way you register **a single endpoint**, on your own account, and learn about the lines of all your merchants. If your partner account is not linked yet, ask your Meteor contact.

You register it on your Met account with a **workspace** key of that account (scope `webhooks:manage`), not with the partner key:

```ts
const metMyAccount = new Met(process.env.MET_MY_ACCOUNT_KEY!);

await metMyAccount.webhooks.subscriptions.create({
  url: 'https://app.yourplatform.com/met/events',
  enabled_events: ['channel.line.connected', 'channel.line.failed'],
});
```

The `data` of both events:

```json
{
  "object": "whatsapp_line",
  "workspace_id": 501,
  "channel_id": 88,
  "line_id": "1098765432",
  "phone_number": "+57 300 123 4567",
  "connect_session_id": "9f3c…",
  "partner_id": "7b0c…"
}
```

`connect_session_id` is the `session_id` from step 2: with it you know which merchant finished. Each session emits only once per destination. Keep the `line_id`: you will use it to open that line's inbox. Always verify the signature (`X-Met-Signature`) as explained in [Webhooks](webhooks.html).

## 4. Open the inbox inside your product

The inbox is a Met WhatsApp inbox served in an iframe: the merchant reads and answers their chats, **takes** one (the Met goes quiet), **returns** it (the Met resumes) and **pauses the AI** on a chat for 15 minutes, 1 hour or 4 hours. They never see a Meteor screen and don't need a user.

**Replying also pauses the Met.** What a person writes from the inbox counts as a person attending: the Met stops answering that chat, just as if it had been taken, until someone hands it back with **Hand back to Met**. That way the merchant and the Met never answer at the same time.

### Request a session from your backend

```ts
const inbox = await met.workspaces.inboxSession(account.id, {
  parent_origin: 'https://app.yourplatform.com',
  line_id: '1098765432',
  language: 'en',
  theme: 'system',
  brand_color: '#0F7B6C',
  operator: { external_id: user.id, name: user.name },
});
// { object: 'inbox_session', session_id, inbox_url, expires_at }
```

```python
inbox = met.workspaces.inbox_session(
    account["id"],
    parent_origin="https://app.yourplatform.com",
    line_id="1098765432",
    language="en",
)
```

| Field | What it is |
|---|---|
| `parent_origin` | **Required.** The **exact** https origin of the page that holds the iframe. No path, no parameters and no wildcards (`*.domain` is rejected). Only that origin can frame the inbox, and events go only to it. It must be registered on your allowed origins. |
| `line_id` | Shows only that line's chats. Without it, all of the account's chats. |
| `language` | `es` (default) or `en`. |
| `theme` | `light`, `dark` or `system` (default). |
| `brand_color` | Your color as `#RRGGBB`. The inbox builds its palette from it. |
| `hide` | Sections that are not shown: `search`, `ai_controls`, `attachments`. |
| `operator` | `{ external_id, name }`: who on your team or the merchant's opens it. It goes into the trace; it doesn't create a user. |

#### From an assistant, over MCP

If you work with an assistant (an AI-enabled editor or your own agent) connected to the partners MCP server, `https://api.partners.meteor.com.co/mcp`, you can request the same session with the `met_partner_create_inbox_session` tool. It takes the fields in the table plus `workspace_id`, the merchant's account.

- It needs a **live** partner key with the `mcp:use` (to open the MCP session) and `inbox:embed` scopes. With a `met_test_` key the tool doesn't show up.
- The tool calls this same route with your key, so the rules are the same: approved Tech Partner, an account attributed to you and a `parent_origin` registered on your allowed origins.
- It returns `session_id`, `inbox_url` and `expires_at`. The `inbox_url` works **only once**: hand it to whoever puts it in the iframe and don't store it.

### Put it in an iframe

Request the session **every time you are about to show the inbox** and put the `inbox_url` in the `src`, from a page served on `parent_origin`:

```html
<iframe id="inbox" src="INBOX_URL" title="WhatsApp inbox"
        style="width:100%;height:640px;border:0"></iframe>
```

- The `inbox_url` works **once** and is valid for **10 minutes** unopened (`expires_at`). Don't store or reuse it: if two tabs open the same URL, only one gets the inbox.
- Once open, it stays valid **while in use**: 30 minutes idle close it, and it never lasts more than 8 hours.
- If someone opens it from another site, the browser blocks it: the iframe is served with `frame-ancestors` set to your `parent_origin`.
- When your page reloads, request another session. The inbox uses no cookies.

To close it earlier (the user signed out of your product, for example), revoke it:

```ts
await met.workspaces.revokeInboxSession(account.id, inbox.session_id);
```

The open inbox stops answering within seconds and its live connection is cut in under a minute. Revoking an already revoked session answers the same.

What the open inbox does doesn't count against your plan's daily API quota.

### Talk to the inbox with `postMessage`

The inbox and your page notify each other by `postMessage`. Each message is a string with the `meteor.inbox:` prefix followed by a JSON:

```
meteor.inbox:{"version":1,"nonce":"…","event":"ready","payload":{"version":1}}
```

- The inbox generates a `nonce` and announces it in its **first** message —usually `ready`; if the session is no longer valid, `session.expired` or `error`—. From then on, **every** message, in both directions, carries that `nonce`.
- The inbox silently drops anything that comes from another origin, another window, with another version or without the `nonce`. If the message is yours (it carries the `nonce`) but the event doesn't exist or the payload doesn't match the table, it isn't applied either and the inbox answers `error` with `code: "invalid_message"` and the rejected `event`.
- A message larger than 64 KB is dropped.
- The inbox never sends to `'*'`: only to your `parent_origin`. In turn, send only to the API's origin.

**From the inbox to your page:**

| Event | Payload | When |
|---|---|---|
| `ready` | `{ version }` | The inbox opened and can receive messages. |
| `conversation.opened` | `{ contact_id, phone }` | A chat was opened. `phone` is the contact's number, or `null`. |
| `handoff.taken` | `{ contact_id }` | Someone took the chat: the Met goes quiet. |
| `handoff.returned` | `{ contact_id }` | The chat went back to the Met. |
| `ai.paused` | `{ contact_id, minutes, until }` | The AI was paused on a chat. |
| `unread.changed` | `{ total }` | The total of unread messages changed (for your badge). |
| `session.expired` | `{}` | The session expired or was revoked. |
| `customer_panel.opened` | `{ contact_id, phone }` | The customer panel shows that contact: send its relations. |
| `customer_panel.closed` | `{ contact_id }` | The customer panel was closed. |
| `action.clicked` | `{ contact_id, phone, action_id }` | One of your actions in the customer panel was tapped. Open your modal. |
| `relation.clicked` | `{ contact_id, phone, relation_id, type }` | One of your relations was tapped. Open its detail. |
| `error` | `{ code, message, event? }` | Something failed. Branch on `code`; today `message` comes empty. With `invalid_message`, `event` names the message of yours that was rejected. |

**From your page to the inbox:**

| Event | Payload |
|---|---|
| `init` | `{ theme?, language?, brand_color?, customer_panel? }` — `customer_panel`: `open` or `closed` (default) |
| `set_theme` | `{ theme?, brand_color? }` (at least one) |
| `set_language` | `{ language }` |
| `open_conversation` | `{ contact_id }` |
| `set_actions` | `{ actions }` — up to 6 actions, see below |
| `set_relations` | `{ contact_id, relations }` — up to 50 relations for that contact |
| `add_relation` | `{ contact_id, relation }` — one new relation |

Through `postMessage` only the theme, the color and the language change; `hide` is set when the session is created.

A complete host, retry included:

```ts
const INBOX_ORIGIN = new URL(inboxUrl).origin; // https://api.met.meteor.com.co
const PREFIX = 'meteor.inbox:';
const frame = document.getElementById('inbox') as HTMLIFrameElement;
let nonce: string | null = null;
let attempts = 0;
let closed = false; // true when YOU revoke the session (sign-out, switching merchant…)
let wait: ReturnType<typeof setTimeout> | undefined;

function send(event: string, payload: Record<string, unknown> = {}) {
  if (!nonce) return; // the first message hasn't arrived yet
  frame.contentWindow!.postMessage(PREFIX + JSON.stringify({ version: 1, nonce, event, payload }), INBOX_ORIGIN);
}

window.addEventListener('message', (e) => {
  if (e.origin !== INBOX_ORIGIN || e.source !== frame.contentWindow) return;
  if (typeof e.data !== 'string' || !e.data.startsWith(PREFIX)) return;
  let m;
  try {
    m = JSON.parse(e.data.slice(PREFIX.length));
  } catch {
    return; // not an inbox message
  }
  if (m?.version !== 1) return;
  if (!nonce) nonce = m.nonce;  // the first message sets the nonce
  if (m.nonce !== nonce) return;

  switch (m.event) {
    case 'ready':
      clearTimeout(wait);
      attempts = 0;
      break;
    case 'unread.changed':
      updateBadge(m.payload.total);
      break;
    case 'session.expired':
      // If you revoked it, don't reopen it: it was closed on purpose.
      if (!closed) retry();
      break;
  }
});

// Growing wait (2 s, 4 s, 8 s…) and a cap: if the inbox doesn't open after
// 5 attempts, show a notice on your page instead of insisting.
function retry() {
  if (closed || attempts >= 5) return showInboxNotice();
  attempts++;
  clearTimeout(wait);
  setTimeout(openInbox, 1_000 * 2 ** attempts);
}

async function openInbox() {
  clearTimeout(wait);
  nonce = null;
  try {
    const { inbox_url } = await fetch('/api/my-backend/inbox', { method: 'POST' }).then((r) => {
      if (!r.ok) throw new Error(String(r.status));
      return r.json();
    });
    frame.src = inbox_url;
    // If `ready` doesn't arrive within ~10 s, the session didn't open: request another one.
    wait = setTimeout(retry, 10_000);
  } catch {
    retry();
  }
}

async function closeInbox() {
  closed = true;
  clearTimeout(wait);
  await fetch('/api/my-backend/inbox', { method: 'DELETE' }); // your backend revokes the session
}

openInbox();
```

`openInbox` and `closeInbox` call **your** backend, which holds the partner key and creates the session. The key never goes to the browser.

To open a specific chat —for example, when someone clicks a notification in your product—, send `open_conversation` with the contact's `contact_id` in Met (the one carried by the `contact.message.*` webhook events). If that chat doesn't belong to the account, or to the session's line, the inbox answers `error` with `code: "conversation_not_found"`.

### The customer panel: your actions and your relations

Tapping the contact's name in the chat header opens the **customer panel** on the right: their avatar, name and number; your **actions** (for example "Take order" or "New booking"), and the **relations** your product has with that customer, grouped by type (Orders, Bookings…). You send the actions and relations by `postMessage`; the inbox draws them.

**The inbox only notifies.** Tapping an action or a relation opens nothing: it sends you `action.clicked` or `relation.clicked` with the contact and the id, and **your page** opens its modal or its detail. The inbox doesn't call your backend or the Met API with that data.

**Your data is not stored in Meteor.** Actions and relations live only in the memory of that inbox tab. If it reloads or you open another session, send them again.

To have the panel start open, say so in `init`; otherwise it starts closed and opens when the name is tapped. On screens narrower than 720 px the panel covers the chat, so opening a chat doesn't open it by itself: it opens when the name is tapped.

```ts
// Inside the host's `switch (m.event)` above:
case 'ready':
  send('init', { customer_panel: 'open' });
  send('set_actions', { actions: [
    { id: 'take-order', label: 'Take order', icon: 'shopping-bag', primary: true },
    { id: 'new-booking', label: 'New booking', icon: 'calendar-plus' },
  ] });
  break;
case 'customer_panel.opened': {
  // The panel shows a contact: send what you have for that customer.
  const { contact_id, phone } = m.payload;
  findOrdersByPhone(phone).then((orders) => send('set_relations', { // your backend
    contact_id,
    relations: orders.map((o) => ({
      id: o.id, type: 'Orders', title: `Order #${o.number}`, status: o.status, subtitle: o.date,
    })),
  }));
  break;
}
case 'action.clicked':
  openYourModal(m.payload.action_id, m.payload.contact_id);
  break;
case 'relation.clicked':
  openYourDetail(m.payload.type, m.payload.relation_id);
  break;

// And when your modal creates a new order, add it: it goes first in its type, marked "New".
send('add_relation', {
  contact_id,
  relation: { id: '4821', type: 'Orders', title: 'Order #4821', subtitle: '2 lunches · $48.000' },
});
```

**An action:**

| Field | What it is |
|---|---|
| `id` | **Required.** Text (up to 64) or an integer. Comes back in `action.clicked`. |
| `label` | **Required.** Up to 40 characters. |
| `icon` | Optional. A name from the list below, or an SVG (see "Icons"). |
| `primary` | Optional. `true` fills it with your brand color; the rest are outlined. |

**A relation:**

| Field | What it is |
|---|---|
| `id` | **Required.** Text (up to 64) or an integer. Comes back in `relation.clicked`. |
| `type` | **Required.** The group and its label: `Orders`, `Bookings`… (up to 40). Groups show in the order each type first appears. |
| `title` | **Required.** Up to 120 characters. |
| `subtitle` | Optional. Up to 160. |
| `status` | Optional. Up to 40; goes before the subtitle ("Delivered · Oct 26"). |
| `badge` | Optional. A short badge, up to 24 ("Paid"). |
| `icon` | Optional, as in actions. |

- `set_actions` **replaces** your actions (for every chat). `set_relations` **replaces** that contact's relations; `add_relation` adds one at the start of its type (moving it if it was already there) and marks it "New". The mark lasts the whole session, even if you later resend the list with `set_relations`.
- Up to **50 relations per contact**: with `set_relations`, more than 50 is rejected; with `add_relation`, the last row of the list (the one in the last group) drops out.
- Everything is drawn **as text**: if you send HTML, the HTML shows as written, it isn't interpreted. Control characters and text-direction marks are removed.
- A field that isn't in the tables, an over-long value or a wrong type rejects the whole message (`error` with `invalid_message`). An `icon` that isn't recognized rejects nothing: the row shows without an icon.

**Icons.** Use a name from this list: `shopping-bag`, `shopping-cart`, `calendar`, `calendar-plus`, `receipt`, `package`, `truck`, `utensils`, `credit-card`, `dollar-sign`, `file-text`, `clipboard-list`, `tag`, `star`, `gift`, `heart`, `user`, `users`, `bell`, `clock`, `check`, `plus`, `map-pin`, `phone`, `mail`, `message-square`, `external-link`. If you need your own, send the SVG as text (up to 2 KB). The inbox **doesn't insert your SVG**: it reads it and redraws it using only shapes (`path`, `circle`, `rect`, `line`, `polyline`, `polygon`, `ellipse`, `g`) and their measurements. Scripts, styles, links, images, events, gradients and colors are dropped: the icon takes the text color, in light and dark.

## 5. Customize the inbox

| What | How |
|---|---|
| Theme | `theme` when creating the session (`light`, `dark` or `system`); live, `set_theme`. |
| Brand color | `brand_color` when creating the session; live, `set_theme` with `brand_color`. |
| Language | `language` when creating the session (`es` or `en`); live, `set_language`. |
| Hide sections | `hide` when creating the session: `search` (the search box), `ai_controls` (take, return and pause) and `attachments`. |
| Customer panel | `customer_panel` in `init`: `open` to start open, `closed` (default) to open it when the name is tapped. |

There is no free-form CSS: theme, color and language are what can change, so the inbox keeps working well in light, in dark and with any color.

With `hide: ['ai_controls']`, **Take over**, **Hand back to Met** and **Pause AI** disappear, but replying still pauses the Met. Without those controls nobody can hand the chat back from the inbox, so use it only for operators who read and don't reply, or when your product hands chats back to the Met some other way.

## Errors

| Code | What happened |
|---|---|
| `403 scope_not_allowed_for_owner` | You are using a workspace key; these scopes only exist on partner keys |
| `403 missing_scope` | Your partner key doesn't have the route's scope (`channels:connect` or `inbox:embed`) |
| `403 partner_key_required` | The request arrived without a partner key (for example, with a panel session) |
| `403 test_mode_restricted` | You are using a `met_test_` key; these operations are real |
| `403 tech_partner_required` | Your partner account doesn't have the approved Tech Partner capability |
| `403 workspace_not_attributed` | The account is not attributed to you |
| `403 origin_not_allowed` | The origin (`parent_origin`, `return_origin` or the one of `redirect_url`) is not on your allowed origins |
| `400 invalid_parent_origin` | `parent_origin` is not an exact https origin (it has a path, parameters or wildcards) |
| `400 invalid_return_origin` / `invalid_redirect_url` | Not a valid https URL |
| `400 missing_return_destination` | `return_origin` or `redirect_url` is missing |
| `400 return_destinations_mismatch` | `return_origin` and `redirect_url` are on different domains |
| `404 inbox_session_not_found` | That inbox session doesn't exist on that account, or isn't yours |

For the full detail on retries and idempotency, see [Errors and idempotency](errors-and-idempotency.html).
