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}.
- Node SDK
- Python
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
},
);
import hashlib, hmac, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["MINETECH_WEBHOOK_SECRET"]
TOLERANCE = 300
@app.post("/webhooks/minetech")
def receive():
header = request.headers.get("x-mt-signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, received = parts.get("t"), parts.get("v1")
if not timestamp or not received:
abort(400, "malformed signature header")
if abs(int(time.time()) - int(timestamp)) > TOLERANCE:
abort(400, "timestamp outside tolerance")
# request.get_data() — the RAW bytes, before any JSON parsing.
signed = f"{timestamp}.".encode() + request.get_data()
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
abort(400, "signature mismatch")
return "", 200
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.