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"
  ]
}'

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

EventSent when
uptime.downA site is confirmed down — after a 5-minute recheck, the same as email alerts.
uptime.still_downReminder while the site stays down, on the project’s alert repeat setting.
uptime.recoveredThe site is back up after a down event.
finding.openedA finding of any kind is opened, or a resolved one reopens.
finding.resolvedA finding is no longer seen and is closed.
webhook.testYou 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
POST body
{
  "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
POST body
{
  "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
POST body
{
  "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
POST body
{
  "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
POST body
{
  "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
POST body
{
  "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

HeaderValue
Tracker-EventThe event type, such as uptime.down
Tracker-Event-IdSame as id in the body
Tracker-Delivery-IdIdentifies this delivery: the same across its retries, new when you redeliver the event
Tracker-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> — see below
Content-Typeapplication/json
User-AgentTracker-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:

  1. Read t and v1 from the Tracker-Signature header.
  2. Compute an HMAC-SHA256 of t + "." + raw body with your endpoint secret, as hex.
  3. Compare it to v1 in 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
});

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 id to 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.