On this page
Guides / An agent that answers WhatsApp

An agent that answers WhatsApp

From an incoming WhatsApp message to a Met's reply, with your own logic in the middle and a handoff to a person when it's needed.

Updated View .md
StackNode · Express · TypeScript SDK
Endpointscontact.message.received · runs.create · contacts.sendMessage · contacts.setAutopilot

With autopilot on, Meteor already answers WhatsApp messages by itself. This recipe is for the other case: when you want your own logic in the middle — checking your inventory before replying, escalating based on the customer, writing to your CRM, deciding when the Met goes quiet and a person takes over.

Before you start

Connect the WhatsApp channel to your workspace from the dashboard (Channels → WhatsApp). The API doesn't connect channels: that happens once and needs Meta's approval.

Your key needs five scopes, one per thing the recipe does:

scopewhat for
webhooks:manageregistering the endpoint that receives events
conversations:readreceiving contact.message.received
runs:executerunning the Met
channels:sendreplying to the contact
handoff:manageturning autopilot off and on

1. Subscribe to incoming messages

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

const met = new Met(process.env.MET_API_KEY!, {
  workspaceId: Number(process.env.MET_WORKSPACE_ID),
});

const sub = await met.webhooks.subscriptions.create({
  url: 'https://your-server.com/webhooks/met',
  enabled_events: ['contact.message.received'],
});

console.log(sub.secret);   // whsec_… — shown ONCE

Store the secret the moment you get it: you need it to verify every delivery and it is never shown again.

2. Receive the message and reply

The event arrives with this body:

{
  "contact_id": 4821,
  "message_id": 99312,
  "channel_id": 12,
  "channel_type": "whatsapp",
  "content": "Do you still have the blue bike?",
  "attachments": []
}
import express from 'express';

const app = express();
// The RAW body is mandatory: if your framework re-serializes the JSON before you
// verify, the signature stops matching even though the content is identical.
app.use('/webhooks/met', express.raw({ type: 'application/json' }));

app.post('/webhooks/met', async (req, res) => {
  let event;
  try {
    event = met.webhooks.constructEvent(
      req.body,
      req.headers['x-met-signature'] as string,
      process.env.MET_WEBHOOK_SECRET!,
    );
  } catch {
    return res.status(400).send('invalid signature');
  }

  // Answer NOW. Meteor retries when it doesn't see a 2xx, and a Met can take
  // several seconds: replying after thinking gets you duplicate messages.
  res.sendStatus(200);

  if (event.type !== 'contact.message.received') return;

  const { contact_id, content } = event.data;
  const run = await met.runs.create({ input: content });
  await met.contacts.sendMessage(contact_id, run.output);
});

app.listen(3000);

A run created through the API isn't bound to the contact: it gets the text you pass and nothing else. If you want the Met to answer knowing who it's talking to, build the input yourself — with met.contacts.retrieve(contact_id) and met.contacts.messages(contact_id), for instance — or use conversation_id so the conversation keeps memory across runs.

3. Hand it to a person when it's needed

A Met that doesn't know something has to be able to let go of the conversation. With autopilot off, messages keep arriving in your inbox but the Met stops replying to that contact until you turn it back on.

if (/talk to (someone|a person|an agent)/i.test(content)) {
  await met.contacts.setAutopilot(contact_id, false);
  await met.contacts.sendMessage(contact_id, 'Connecting you with someone on the team 👋');
  await met.contacts.createNote(contact_id, 'Asked for a human.');
  return;
}

To give control back to the Met: met.contacts.setAutopilot(contact_id, true).

The 24-hour window

This isn't a Meteor limit and there's no way around it: WhatsApp only lets you write freely for 24 hours after the person's last message. Past that window, the only thing that gets through is a Meta-approved template:

await met.contacts.sendTemplate(contact_id, {
  name: 'appointment_reminder',
  language: 'en',
  namespace: process.env.WA_TEMPLATE_NAMESPACE!,   // required
  params: { '1': 'Ana', '2': 'Tuesday at 3' },
});

If your flow replies late — a queue, an overnight retry — that window is the first thing to check, before the key or the scopes.

Trying it without deploying anything

The CLI forwards your workspace's real events to your machine, so you can write the handler against actual traffic before you have a server:

met listen --forward http://localhost:3000/webhooks/met --types contact.message.received

Next