Inbound webhooks
Register endpoints that receive third-party events, with HMAC signing, and connect them to a flow.
An inbound webhook is an endpoint Meteor exposes so a third party can POST to it. When an event arrives, Meteor records it and emits an internal event; if you connect a flow to it, every delivery runs that flow. It needs the webhooks:manage scope.
These are inbound webhooks (third party → Meteor). If what you want is for Meteor to notify you when something happens (
run.completed, etc.), jump to Outbound events below.
Create a webhook
const wh = await met.webhooks.create({
name: 'Lead intake',
binding_type: 'generic', // the only value created today
verifier_type: 'hmac_sha256', // 'none' for unsigned
});
console.log(wh.url); // the URL the third party points at
console.log(wh.secret); // ⚠️ shown ONCE only
binding_type: 'task', which ran a task directly, was retired along with task execution (August 31, 2026). Webhooks already created with that value keep receiving and recording every event, but they don't trigger anything: to act on what arrives, connect a flow to them.
Store the secret the moment you receive it: it is never shown again. With it you verify that each delivery really came from your integration.
Verify the signature
The third party signs the body with the secret (HMAC-SHA256) and sends the signature in a header. On Meteor's side, verification is automatic if the webhook is hmac_sha256.
Rotate the secret
If you suspect it leaked:
const rotated = await met.webhooks.rotateSecret(wh.id);
console.log(rotated.secret); // new secret; invalidates the previous one
See deliveries
const events = await met.webhooks.events(wh.id, { limit: 20 });
const sample = await met.webhooks.sample(wh.id); // last payload received
sample is handy for setting up the payload's field mapping.
Connect a flow
A webhook can trigger a flow every time it receives an event. That way an external POST starts a full automation inside Meteor:
await met.webhooks.connectFlow(wh.id, '8f14e45f-ceea-467a-9f0b-4c3d1a2b5e60');
A webhook with no flow receives and records, but does nothing else. disconnectFlow(wh.id) disconnects it.
Outbound events (Meteor → your server)
An outbound event is Meteor POSTing to *you* when something happens in your workspace — for example, when a Run finishes. You register an endpoint, choose which events you want, and Meteor delivers each one signed with HMAC so you can verify it came from Meteor.
Register an endpoint
You register the endpoint from your panel: Developers → Activity → Webhooks. You choose your server's URL and which events you want (or * for all of them). On creation, Meteor shows you the signing secret (whsec_...) once only — store it, you need it to verify every delivery.
Change an endpoint's events
To add or remove events, or change the URL or description, use PATCH /webhook-endpoints/{id}. The signing secret doesn't change: your verification keeps working as is, with nothing to redeploy. enabled_events replaces the whole list, so to add an event send the current list plus the new one:
const [ep] = await met.webhooks.subscriptions.list();
await met.webhooks.subscriptions.update(ep.id, {
enabled_events: [...ep.enabled_events, 'contact.message.failed'],
});
If you subscribed with *, there's nothing to do: new catalog events reach you on their own.
Event catalog
There are 42. The scope column is the permission a key must carry to receive that event: the same one you would read that resource with through the API.
| Event | When it fires | Scope |
|---|---|---|
run.completed | A Run finished successfully. | runs:read |
run.failed | A Run failed. | runs:read |
channel.run.completed | The Met answered a channel message. | runs:read |
channel.run.failed | The Met failed to answer a channel message. | runs:read |
contact.created | A contact was created. | contacts:read |
contact.updated | A contact was updated. | contacts:read |
contact.message.received | A message from a contact came in through a channel (WhatsApp, etc.). | conversations:read |
conversation.handoff | A conversation moved to a human operator. | conversations:read |
task.completed | Deprecated: not emitted since 2026-08-31, when task execution was retired. Leaves the catalog on 2027-04-07. | tasks:read |
billing.threshold | An API key's Energy spend crossed a threshold of its budget (50/80/100%). | billing:read |
app.authorized | A workspace authorized your OAuth app (new grant). | integrations:read |
app.revoked | A workspace revoked your OAuth app's access (grant revoked). | integrations:read |
snapshot.published | One of your templates was approved and published to the marketplace. | snapshots:read |
snapshot.install.completed | A template install finished. | snapshots:read |
snapshot.install.failed | A template install failed. | snapshots:read |
conversion.sent | A conversion was emitted successfully to Meta (CAPI). | conversions:read |
conversion.discarded | A proposed conversion was discarded by the gate (the reason is included). | conversions:read |
automation.run.failed | An automation failed to run. | automations:read |
contact.message.sent | A message went out to a contact, whether the Met or a person wrote it. | conversations:read |
contact.message.failed | A message to a contact couldn't be delivered. Carries the provider's error code. | conversations:read |
contact.deleted | A contact was deleted. | contacts:read |
lead.received | A new lead came in from a Meta form (Lead Ads), with its answers and the contact that was created or updated. See Meta forms. | contacts:read |
broadcast.completed | A broadcast finished sending (includes how many went out and how many failed). | channels:read |
channel.template.approved | Meta approved a WhatsApp template. | channels:read |
channel.template.rejected | Meta rejected a WhatsApp template (includes the reason). | channels:read |
channel.template.status_updated | A WhatsApp template changed status for another reason (paused, disabled, under appeal…): carries Meta's status. | channels:read |
call.missed | A call went unanswered. | conversations:read |
billing.energy.low | The Energy balance crossed below the low threshold. It fires on the crossing, not on every execution. | billing:read |
billing.energy.depleted | The Energy balance ran out. | billing:read |
billing.energy.recharged | An Energy top-up came in (purchase, auto-recharge, or a partner credit). | billing:read |
billing.subscription.deactivated | The workspace subscription was deactivated. | billing:read |
approval.requested | A Met asked for an action that needs a person's approval (publish, charge, invoice…). Carries the full approval. | approvals:read |
approval.decided | A person approved or rejected it (also when they asked for a change or a newer one replaced it). See Human approvals. | approvals:read |
booking.created | An appointment was booked: by a Met, by a person in the panel or by your integration. Carries the appointment. See Scheduling and bookings. | agenda:read |
booking.updated | An appointment was rescheduled or its details were edited. Carries the appointment and, in previous_attributes, the values from before. | agenda:read |
booking.cancelled | An appointment was cancelled. | agenda:read |
channel.line.connected | A business finished connecting its WhatsApp line from a Tech Partner's platform (carries the line and the number). | channels:read |
channel.line.failed | Connecting a WhatsApp line from a Tech Partner's platform failed (carries the error_code). | channels:read |
channel.disconnected | A channel was disconnected (by hand, through the API or because the provider stopped accepting the connection). Carries the reason. | channels:read |
channel.health_updated | A WhatsApp channel's health changed without disconnecting (Meta flagged the number for quality, changed its limit, restricted or reactivated the account). Carries health and reason. | channels:read |
account.access_revoked | Your access to an account attributed to you was revoked. Carries the workspace_id and the date, no merchant data. | channels:read |
account.access_restored | Your access to an account attributed to you was given back. Carries the workspace_id and the date. | channels:read |
video.render.completed | A template video you requested through the API is ready. Carries the render with its url and the file in your Media library. | images:generate |
video.render.failed | A template video you requested through the API could not be produced. Nothing was charged. | images:generate |
* is not an event: it is the "everything in the catalog" wildcard, and it covers the ones we add later too. If you would rather hear about a new event before you start receiving it, subscribe to the explicit list.
Five things that save you a debugging session:
task.completedis deprecated and is not emitted. It was fired by task execution, which was retired on August 31, 2026. It stays in the catalog and inenabled_eventsuntil April 7, 2027 so that no endpoint that lists it fails, but you will not receive any: remove it next time you touch your endpoint. See the changelog.channel.run.*is notrun.*. Arun.*is an API Run, with anidyou can query; achannel.run.*is the Met answering a channel message (WhatsApp and the rest) and leaves no row in Runs. If you only listen forrun.completed, channel replies never reach you.app.authorizedandapp.revokedare delivered to the workspace that owns the OAuth app — yours, the developer's — not to the one that authorizes it.- Test deliveries arrive with
type: "ping", which is not in the catalog. Your handler should ignore what it does not recognise instead of failing. - A full conversation is two events.
contact.message.receivedcarries what the contact writes; the replies —from the Met, from your team in the inbox, or from the owner on WhatsApp Business on their phone— arrive incontact.message.sent. Subscribe only to the first and you never see the replies.contact.message.sentcarriessent_by(metoroperator) andsent_from:met,phone(the owner's phone),flow(a flow or automation) ormeteor(any other send from Met: inbox, broadcast, template or API). For finer detail, useorigin:panel(a person in the app),api(your integration with an API key or SDK),mcp,flow,automation,broadcast,met,phoneorsystem. It's the same field every message carries inGET /contacts/{id}/messages. Internal conversation notes —assignment, status or tag changes— are not sent as messages: they never reach the contact. Both carryattachmentsin the same shape (url,type,name) and, on voice notes,transcriptwith the text: the contact's and also the ones your team records. - A message that didn't arrive gets its own event.
contact.message.failedfires when a message to a contact goes undelivered: the provider rejected it on send, or WhatsApp marked it failed later, usually within minutes. It carries the same fields ascontact.message.sent, pluserror—withcode, WhatsApp's code when there is one, andmessage— andtemplate_namewhen it was a template. The code separates causes that call for different actions:131026, the number can't receive the message (check it);131049, Meta capped marketing templates to that person (the number is fine: space out your sends);132012, a parameter doesn't match the template (fix the send). It fires once per message. The same data stays inmetadata.delivery_statusandmetadata.delivery_erroron each message inGET /contacts/{id}/messages.
The API returns this same table, always current:
const { data } = await met.webhooks.subscriptions.eventTypes();
// [{ type: 'run.completed', description: '…', scope: 'runs:read' }, …]
Verify the signature
Every delivery carries the header X-Met-Signature: t=<ts>,v1=<hmac>. Never process an event without verifying it — use the SDK helper, which does the verification timing-safe and checks the timestamp (anti-replay):
import Met from '@meteor.ia/sdk';
const met = new Met(process.env.MET_API_KEY!);
app.post('/webhooks/met', (req, res) => {
let event;
try {
event = met.webhooks.constructEvent(req.rawBody, req.headers['x-met-signature'], WHSEC);
} catch {
return res.status(400).send('invalid signature');
}
if (event.type === 'run.completed') { /* ... */ }
res.sendStatus(200); // answer 2xx quickly; Meteor retries otherwise.
});
You need the raw body to verify the signature — configure your framework not to re-serialize the JSON before
constructEvent.
Retries
If your endpoint doesn't answer 2xx within 15 seconds, Meteor retries with growing waits: the first attempt, then after 1 min → 5 min → 30 min → 2 h → 6 h, 6 attempts in total (about 8.6 hours from the first). After that it marks the delivery as failed; you can send it again with POST /webhook-endpoints/{id}/deliveries/{deliveryId}/retry or POST /events/{id}/replay. Every retry carries the same event id, also in the X-Met-Event-Id header: use it so you never process the same thing twice. Deliveries go out in parallel, so ordering is not guaranteed: sort by created. The attempt log per endpoint is in your Workbench (Developers section).