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.
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:
| scope | what for |
|---|---|
webhooks:manage | registering the endpoint that receives events |
conversations:read | receiving contact.message.received |
runs:execute | running the Met |
channels:send | replying to the contact |
handoff:manage | turning 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
- Every event's schema, the retries and the delivery log: Webhooks.
- Replying live, word by word, instead of waiting for the whole run: Show a run live in your UI.
- Letting the Met read your own data while it answers: Data: collections and items.