Guide
Webhooks
Instead of polling, register an HTTPS endpoint and Tracker will POST a signed JSON event to it when a site goes down or recovers, or a finding opens or closes. You only receive events for projects you can open, and security findings only if you can view security.
Set up a webhook
From the dashboard: open Profile → Webhooks, enter your HTTPS URL, choose events and projects, and confirm your password. You can send a test event and see recent deliveries there.
With the API: create a webhook. Omit events to receive every type, and projectIds to cover every project you can open, including new ones.
curl -X POST 'https://trackerv2.marketingplanet.global/api/v1/webhooks' \
-H 'Authorization: Bearer tk_live_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://hooks.acmeretail.com/tracker",
"description": "Ops channel",
"events": [
"uptime.down",
"uptime.recovered"
],
"projectIds": [
"6650a4c2e1b3d5f7a9c1e301"
]
}'
const response = await fetch('https://trackerv2.marketingplanet.global/api/v1/webhooks', {
method: 'POST',
headers: {
Authorization: 'Bearer tk_live_YOUR_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://hooks.acmeretail.com/tracker",
"description": "Ops channel",
"events": [
"uptime.down",
"uptime.recovered"
],
"projectIds": [
"6650a4c2e1b3d5f7a9c1e301"
]
}),
});
const result = await response.json();
if (!response.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
console.log(result.data);
import requests
response = requests.post(
"https://trackerv2.marketingplanet.global/api/v1/webhooks",
headers={"Authorization": "Bearer tk_live_YOUR_KEY"},
json={
"url": "https://hooks.acmeretail.com/tracker",
"description": "Ops channel",
"events": [
"uptime.down",
"uptime.recovered",
],
"projectIds": [
"6650a4c2e1b3d5f7a9c1e301",
],
},
)
result = response.json()
if not response.ok:
raise RuntimeError(f"{result['error']['code']}: {result['error']['message']}")
print(result["data"])
The response contains a secret starting with whsec_. It is shown only once (and again if you rotate it) — store it to verify signatures. Webhooks need a plan with API access; you can have up to 10 endpoints, and we email you when one is added.
Events
| Event | Sent when |
|---|---|
uptime.down | A site is confirmed down — after a 5-minute recheck, the same as email alerts. |
uptime.still_down | Reminder while the site stays down, on the project’s alert repeat setting. |
uptime.recovered | The site is back up after a down event. |
finding.opened | A finding of any kind is opened, or a resolved one reopens. |
finding.resolved | A finding is no longer seen and is closed. |
webhook.test | You asked for a test send. Never sent otherwise. |
Payloads
Every event has the same envelope: an id (the same on retries and redeliveries — use it to ignore duplicates), the type, when it was created, the project, and event-specific data.
uptime.down
{
"id": "evt_9b1f2c4d6e8a0b2c4d6e8f01",
"type": "uptime.down",
"createdAt": "2026-10-06T09:30:00.000Z",
"project": {
"id": "6650a4c2e1b3d5f7a9c1e301",
"name": "Acme Retail",
"domain": "acmeretail.com"
},
"data": {
"status": "failed",
"statusCode": 503,
"errorClass": "http_5xx",
"errorMessage": "Server returned 503",
"checkedAt": "2026-10-06T09:20:12.000Z"
}
}
uptime.still_down
{
"id": "evt_2d4f6a8c0e2b4d6f8a0c2e02",
"type": "uptime.still_down",
"createdAt": "2026-10-06T09:30:00.000Z",
"project": {
"id": "6650a4c2e1b3d5f7a9c1e301",
"name": "Acme Retail",
"domain": "acmeretail.com"
},
"data": {
"status": "failed",
"statusCode": 503,
"errorClass": "http_5xx",
"errorMessage": "Server returned 503",
"checkedAt": "2026-10-06T09:20:12.000Z"
}
}
uptime.recovered
{
"id": "evt_7e9a1c3e5a7c9e1b3d5f7a03",
"type": "uptime.recovered",
"createdAt": "2026-10-06T09:30:00.000Z",
"project": {
"id": "6650a4c2e1b3d5f7a9c1e301",
"name": "Acme Retail",
"domain": "acmeretail.com"
},
"data": {
"status": "success",
"statusCode": 200,
"errorClass": null,
"errorMessage": null,
"checkedAt": "2026-10-06T09:25:12.000Z"
}
}
finding.opened
{
"id": "evt_4a6c8e0a2c4e6a8c0e2a4c04",
"type": "finding.opened",
"createdAt": "2026-10-06T09:30:00.000Z",
"project": {
"id": "6650a4c2e1b3d5f7a9c1e301",
"name": "Acme Retail",
"domain": "acmeretail.com"
},
"data": {
"finding": {
"id": "6652c6e4a3d5f7b9c1e3a501",
"pageId": "6651b5d3f2c4e6a8b0d2f401",
"kind": "thin-page-noindex",
"severity": "medium",
"status": "open",
"summary": "Thin page (84 words) — consider noindex or expanding the content",
"finalUrl": null,
"firstSeenAt": "2026-09-28T03:04:00.000Z",
"resolvedAt": null
}
}
}
finding.resolved
{
"id": "evt_6c8e0a2c4e6a8c0e2a4c6e05",
"type": "finding.resolved",
"createdAt": "2026-10-06T09:30:00.000Z",
"project": {
"id": "6650a4c2e1b3d5f7a9c1e301",
"name": "Acme Retail",
"domain": "acmeretail.com"
},
"data": {
"finding": {
"id": "6652c6e4a3d5f7b9c1e3a501",
"pageId": "6651b5d3f2c4e6a8b0d2f401",
"kind": "thin-page-noindex",
"severity": "medium",
"status": "resolved",
"summary": "Thin page (84 words) — consider noindex or expanding the content",
"finalUrl": null,
"firstSeenAt": "2026-09-28T03:04:00.000Z",
"resolvedAt": "2026-10-06T09:30:00.000Z"
}
}
}
webhook.test
{
"id": "evt_3c5e7a9b1d3f5a7c9e1b3d5f",
"type": "webhook.test",
"createdAt": "2026-10-06T09:30:00.000Z",
"project": null,
"data": {
"message": "Test event from Tracker. Your endpoint is receiving webhooks."
}
}
Headers
| Header | Value |
|---|---|
Tracker-Event | The event type, such as uptime.down |
Tracker-Event-Id | Same as id in the body |
Tracker-Delivery-Id | Identifies this delivery: the same across its retries, new when you redeliver the event |
Tracker-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> — see below |
Content-Type | application/json |
User-Agent | Tracker-Webhooks/1.0 |
Verify the signature
Anyone can POST to a public URL, so check that each request really came from Tracker before acting on it:
- Read
tandv1from theTracker-Signatureheader. - Compute an HMAC-SHA256 of
t + "." + raw bodywith your endpoint secret, as hex. - Compare it to
v1in constant time, and reject timestamps more than five minutes old to stop replays. Each retry is signed with a fresh timestamp.
Use the raw request body, not JSON you parsed and re-serialized — any change in spacing breaks the signature.
const crypto = require('crypto');
// rawBody: the request body exactly as received (string or Buffer).
function verifyTracker(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(header || '').split(',').map((p) => p.split('=', 2))
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const given = Buffer.from(parts.v1 || '', 'hex');
const want = Buffer.from(expected, 'hex');
return given.length === want.length && crypto.timingSafeEqual(given, want);
}
// Express: read the raw body so the signature matches byte for byte.
app.post('/hooks/tracker', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyTracker(
req.body.toString('utf8'),
req.get('Tracker-Signature'),
process.env.TRACKER_WEBHOOK_SECRET
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(204); // reply within 10 seconds, then do the work
handleEvent(event); // e.g. dedupe on event.id, then notify your team
});
import hashlib
import hmac
import time
def verify_tracker(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""raw_body: the request body exactly as received."""
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask
@app.post("/hooks/tracker")
def tracker_hook():
if not verify_tracker(
request.get_data(),
request.headers.get("Tracker-Signature", ""),
os.environ["TRACKER_WEBHOOK_SECRET"],
):
abort(401)
event = request.get_json()
handle_event(event) # keep it fast: reply within 10 seconds
return "", 204
Responding and retries
- Reply with any 2xx within 10 seconds, then do slow work afterwards. Redirects are not followed.
- Anything else — another status, a timeout, a redirect — is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.
- Use the event
idto ignore duplicates: retries and redeliveries reuse it. - After 10 events in a row fail all their retries, the endpoint is disabled (
disabledReason: "too-many-failures") and its backlog dropped. Fix the receiver, then turn it back on in the dashboard or update it with"enabled": true. - Changing or resetting your account password pauses all your webhooks (
disabledReason: "password-changed"). Resume them the same way. - Endpoints must use HTTPS with a valid certificate and a public address; private and internal addresses are refused.
Managing endpoints
The last 30 days of delivery attempts are in deliveries, with each attempt’s status code, error and timing. Test sends and redeliveries are limited to one per endpoint every 10 seconds and 20 per minute per account.
-
GET
List webhook endpoints
/api/v1/webhooks -
POST
Create a webhook endpoint
/api/v1/webhooks -
GET
Get a webhook endpoint
/api/v1/webhooks/{webhookId} -
PATCH
Update a webhook endpoint
/api/v1/webhooks/{webhookId} -
DEL
Delete a webhook endpoint
/api/v1/webhooks/{webhookId} -
POST
Rotate the signing secret
/api/v1/webhooks/{webhookId}/rotate-secret -
POST
Send a test event
/api/v1/webhooks/{webhookId}/test -
GET
List recent deliveries
/api/v1/webhooks/{webhookId}/deliveries -
POST
Redeliver an event
/api/v1/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver