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.