On this page
Guides / WhatsApp for platforms

WhatsApp for platforms

For Tech Partners: create the merchant's account, connect their WhatsApp, get the result by webhook and show them their inbox inside your product.

Updated View .md

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.

StepScopeRoute
Create the accountworkspaces:provisionPOST /workspaces
Connect WhatsAppchannels:connectPOST /workspaces/{workspaceId}/whatsapp/connect-sessions
Line notificationschannels:read (on your Met account)Webhooks channel.line.connected and channel.line.failed
Embedded inboxinbox:embedPOST /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

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.

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.

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
session = met.workspaces.whatsapp_connect_session(
    account["id"],
    return_origin="https://app.yourplatform.com",
    idempotency_key=f"connect-{merchant_id}",
)
FieldWhat it is
connection_typecoexistence (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_originThe 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_urlThe 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.

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:

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.

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:

EventWhen
channel.line.connectedThe merchant finished connecting their line.
channel.line.failedThe 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:

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:

{
  "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.

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

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 }
inbox = met.workspaces.inbox_session(
    account["id"],
    parent_origin="https://app.yourplatform.com",
    line_id="1098765432",
    language="en",
)
FieldWhat it is
parent_originRequired. 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_idShows only that line's chats. Without it, all of the account's chats.
languagees (default) or en.
themelight, dark or system (default).
brand_colorYour color as #RRGGBB. The inbox builds its palette from it.
hideSections 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.

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:

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

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

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}}

From the inbox to your page:

EventPayloadWhen
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:

EventPayload
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:

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.

// 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:

FieldWhat it is
idRequired. Text (up to 64) or an integer. Comes back in action.clicked.
labelRequired. Up to 40 characters.
iconOptional. A name from the list below, or an SVG (see "Icons").
primaryOptional. true fills it with your brand color; the rest are outlined.

A relation:

FieldWhat it is
idRequired. Text (up to 64) or an integer. Comes back in relation.clicked.
typeRequired. The group and its label: Orders, Bookings… (up to 40). Groups show in the order each type first appears.
titleRequired. Up to 120 characters.
subtitleOptional. Up to 160.
statusOptional. Up to 40; goes before the subtitle ("Delivered · Oct 26").
badgeOptional. A short badge, up to 24 ("Paid").
iconOptional, as in actions.

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

WhatHow
Themetheme when creating the session (light, dark or system); live, set_theme.
Brand colorbrand_color when creating the session; live, set_theme with brand_color.
Languagelanguage when creating the session (es or en); live, set_language.
Hide sectionshide when creating the session: search (the search box), ai_controls (take, return and pause) and attachments.
Customer panelcustomer_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

CodeWhat happened
403 scope_not_allowed_for_ownerYou are using a workspace key; these scopes only exist on partner keys
403 missing_scopeYour partner key doesn't have the route's scope (channels:connect or inbox:embed)
403 partner_key_requiredThe request arrived without a partner key (for example, with a panel session)
403 test_mode_restrictedYou are using a met_test_ key; these operations are real
403 tech_partner_requiredYour partner account doesn't have the approved Tech Partner capability
403 workspace_not_attributedThe account is not attributed to you
403 origin_not_allowedThe origin (parent_origin, return_origin or the one of redirect_url) is not on your allowed origins
400 invalid_parent_originparent_origin is not an exact https origin (it has a path, parameters or wildcards)
400 invalid_return_origin / invalid_redirect_urlNot a valid https URL
400 missing_return_destinationreturn_origin or redirect_url is missing
400 return_destinations_mismatchreturn_origin and redirect_url are on different domains
404 inbox_session_not_foundThat inbox session doesn't exist on that account, or isn't yours

For the full detail on retries and idempotency, see Errors and idempotency.