Skip to main content

Webhooks

Webhooks push changes to you as they happen. They replace polling, which is the most common way integrations exhaust their rate limit.

Registering an endpoint

Developers → Webhooks → Add endpoint. Choose an environment, a URL, and the events you want. The signing secret is shown once.

Live endpoints must be https and publicly routable. localhost, private ranges, link-local addresses (including cloud metadata endpoints) and reserved ranges are rejected. Sandbox endpoints may use http and localhost so a tunnel works.

Payload

{
"id": "evt_9f8b7a6c5d4e3f2a1b0c",
"type": "safety.incident.reported",
"createdAt": "2026-03-14T09:21:04.512Z",
"environment": "live",
"data": {
"id": "inc_7d6c5b4a",
"title": "Loose ground at face 3",
"severity": "HIGH"
}
}

id is stable across retries — use it to deduplicate. environment lets one receiver serve both sandbox and production safely.

Verifying signatures

Every delivery carries:

x-mt-signature: t=1735689600,v1=<hex HMAC-SHA256>
x-mt-event: safety.incident.reported
x-mt-delivery: evt_9f8b7a6c5d4e3f2a1b0c
x-mt-attempt: 1

The signed payload is {timestamp}.{rawBody}.

import express from 'express';
import { constructEvent, SignatureVerificationError } from '@minetech/node/webhooks';

const app = express();

app.post(
'/webhooks/minetech',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = await constructEvent({
payload: req.body,
signatureHeader: req.header('x-mt-signature'),
secret: process.env.MINETECH_WEBHOOK_SECRET!,
});
} catch (error) {
if (error instanceof SignatureVerificationError) {
return res.status(400).send(error.reason);
}
throw error;
}

res.sendStatus(200); // acknowledge first
void handle(event); // then work
},
);
Verify against the raw body

The digest covers the exact bytes we sent. A parsed-and-re-serialised body will not match — key order and number formatting both drift. In Express mount express.raw({ type: 'application/json' }) on the webhook route only; in Flask use request.get_data(), never request.json.

Retries and replay

A delivery succeeds on any 2xx. Anything else — including a timeout at 10 seconds — is retried:

immediately → 1m → 5m → 30m → 2h → 12h → 24h

Seven attempts over roughly 40 hours, then the delivery is marked exhausted and you are alerted once. Alerting only on exhaustion is deliberate: a single transient failure is noise, a permanently failing receiver is signal.

After 10 consecutive failures the endpoint is automatically disabled so a dead receiver stops consuming delivery capacity. Re-enable it in the portal.

You can replay any delivery from Developers → Webhooks → Deliveries. Replays are re-signed with a current timestamp, so a replay days later still passes a correctly-implemented tolerance check.

Building a reliable receiver

Acknowledge fast, work asynchronously. Deliveries time out at 10 seconds. A slow handler is recorded as failed and retried even though it succeeded.

Deduplicate on event.id. Delivery is at-least-once; a retry after a timeout can deliver an event you already processed.

Do not assume ordering. Independent retry schedules mean a later event can arrive first. Order by createdAt if sequence matters.

Handle unknown event types without failing. New event types are added over time; a receiver that throws on an unrecognised type will break on a platform update. The SDK's typed union deliberately stays open-ended for this reason.

Treat the payload as a notification, not the source of truth. For anything critical, re-read the resource through the API — the payload is a snapshot from when the event fired.

Testing

Send test ping delivers a webhook.test event through the identical signing and delivery path as a real event, so a passing test ping genuinely proves your verification works.

Event types

Read them from the API rather than hardcoding a list:

const groups = await client.developer.listEventTypes();

See the event reference.