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.
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:
- Create the account for the merchant.
- Connect their WhatsApp line with a single-use link.
- Learn the result by webhook.
- 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 ofredirect_urlandparent_originmust be on that list; otherwise the API answers403 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.
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.
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 resultsession = 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:
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:
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",
)| 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) andinbox:embedscopes. With amet_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_originregistered on your allowed origins. - It returns
session_id,inbox_urlandexpires_at. Theinbox_urlworks 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:
<iframe id="inbox" src="INBOX_URL" title="WhatsApp inbox"
style="width:100%;height:640px;border:0"></iframe>
- The
inbox_urlworks 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-ancestorsset to yourparent_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:
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
nonceand announces it in its first message —usuallyready; if the session is no longer valid,session.expiredorerror—. From then on, every message, in both directions, carries thatnonce. - 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 thenonce) but the event doesn't exist or the payload doesn't match the table, it isn't applied either and the inbox answerserrorwithcode: "invalid_message"and the rejectedevent. - A message larger than 64 KB is dropped.
- The inbox never sends to
'*': only to yourparent_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:
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:
| 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_actionsreplaces your actions (for every chat).set_relationsreplaces that contact's relations;add_relationadds 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 withset_relations.- Up to 50 relations per contact: with
set_relations, more than 50 is rejected; withadd_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 (
errorwithinvalid_message). Aniconthat 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.