On this page
Guides / Receive signed events in your backend

Receive signed events in your backend

An endpoint that receives your workspace's events, verifies the signature and survives retries without processing anything twice.

Updated View .md
StackNode · Express · TypeScript SDK
Endpointswebhooks.subscriptions.create · webhooks.constructEvent · subscriptions.test

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