Receive signed events in your backend
An endpoint that receives your workspace's events, verifies the signature and survives retries without processing anything twice.
Polling the API to find out whether something happened is expensive and always late. With a subscription, Meteor POSTs to you when the thing occurs. This recipe builds the whole endpoint: signature verified, fast response, and processing that holds up under retries.
Before you start
Your key needs webhooks:manage, plus the scope of whatever event you want to hear — runs:read for run.completed, contacts:read for contact.created, and so on. Ask for an event whose scope you don't hold and it simply won't arrive.
1. Subscribe
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: ['run.completed', 'run.failed', 'task.completed'],
description: 'Results processor',
});
console.log(sub.secret); // whsec_… — shown ONCE
enabled_events: ['*'] gets you everything. It's worth listing the ones you actually process: an endpoint that receives seventeen types and acts on three is an endpoint that falls over because of an event nobody looked at.
The available types come from the API, so there's nothing to guess:
const types = await met.webhooks.subscriptions.eventTypes();
2. The endpoint
import express from 'express';
const app = express();
// RAW body. The signature is computed over the exact bytes Meteor sent: if your
// framework parses and re-serializes the JSON, it stops matching.
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 {
// Bad signature or stale timestamp. Don't retry: it won't get better.
return res.status(400).send('invalid signature');
}
// 2xx first, work second: Meteor treats a delivery that doesn't answer as
// failed, and your processing can take longer than its timeout.
res.sendStatus(200);
await enqueue(event);
});
app.listen(3000);
constructEvent verifies the signature in constant time and checks the timestamp in the X-Met-Signature: t=<ts>,v1=<hmac> header, which is what stops someone from replaying an old, legitimate delivery at you.
3. Surviving retries
If your endpoint doesn't answer 2xx, Meteor retries with growing backoff — 1 min, 5 min, 30 min, 2 h, 6 h, 24 h — up to six times. That means the same event can reach you more than once, and it can also arrive late and out of order.
Record the event id before you act:
async function enqueue(event) {
const isNew = await yourDb.insertIfMissing('met_events', { id: event.id });
if (!isNew) return; // already processed: this delivery is a retry
await process(event);
}
The rule that saves you a bad afternoon: arrival order is not the order things happened. If your logic depends on sequence, order by the event's created — a seconds-epoch — and not by when you received it.
Every delivery has this shape:
{
"id": "evt_9f2c…",
"object": "event",
"type": "run.completed",
"created": 1786886400,
"livemode": true,
"data": { }
}
4. Testing it before anything real happens
await met.webhooks.subscriptions.test(sub.id); // a test delivery, signed the same way
And against real traffic, without deploying:
met listen --forward http://localhost:3000/webhooks/met --types run.completed,run.failed
If a delivery failed and you've already fixed the cause, you can retry it by hand from the delivery log, in your dashboard under Developers → Activity.
Next
- The full event catalog and the retry detail: Webhooks.
- If what you want is to react within the same second rather than on the next delivery: Live events.