Webhooks
Get a signed HTTP POST the moment something happens, instead of polling for it. Available on Agency and Business.
Events
| Event | Fires when |
|---|---|
| click.created | Someone clicks a tracked link or loads a pixel |
| link.created | A link is created, in the dashboard or via the API |
| link.updated | A link's name, destination, slug or state changes |
| link.deleted | A link is deleted |
click.created is a firehose. A busy link can produce thousands of events an hour. Subscribe to it only if your endpoint can absorb that, and expect bursts rather than a steady trickle.
Payload
Every delivery has the same envelope. id is stable per delivery — use it to make your handler idempotent, because a retry reuses it.
{
"id": "5a1f8c2e-...",
"event": "click.created",
"created_at": "2026-08-28T09:14:22.104Z",
"data": {
"click_id": "c_9f2...",
"link_id": "3ddb1d53-...",
"slug": "summer",
"created_at": "2026-08-28T09:14:22.061Z",
"is_unique": true,
"country": "Germany",
"country_code": "DE",
"city": "Berlin",
"device_type": "Mobile",
"os": "iOS",
"browser": "Safari",
"referrer": null,
"utm_source": "newsletter",
"utm_medium": "email",
"utm_campaign": "august"
}
}Click payloads carry no IP address and no precise coordinates, even when we hold them. link.* payloads carry the link's id, slug, name, type, destination and active state.
Headers
X-Tracklink-Event: click.created
X-Tracklink-Delivery: 5a1f8c2e-... # stable across retries
X-Tracklink-Signature: t=1756371262,v1=9c4f...
User-Agent: TrackLink-Webhooks/1.0Verify the signature
Your endpoint is a public URL. Without verification, anyone who learns it can post fake events to you. Sign over `${t}.${rawBody}` with the signing secret shown once when you created the endpoint.
import { createHmac, timingSafeEqual } from "crypto";
// rawBody must be the raw request STRING. If your framework has already
// parsed it into an object, re-serialising will not reproduce the bytes
// that were signed, and every signature will fail.
export function verify(rawBody, header, secret) {
const m = /t=(\d+),v1=([a-f0-9]+)/.exec(header || "");
if (!m) return false;
// Reject anything older than five minutes. The timestamp is inside the
// signed material precisely so a captured request cannot be replayed
// at you later.
if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${m[1]}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(m[2]);
// Constant-time: a plain === leaks how much of the digest matched.
return a.length === b.length && timingSafeEqual(a, b);
}In Express, get the raw body with express.raw({ type: 'application/json' }) on that route. In Next.js route handlers, use await req.text().
Responding
Return any 2xx as soon as you have the payload — do the work afterwards. We wait 10 seconds, and a slow endpoint delays your own later events.
| You return | We do |
|---|---|
| 2xx | Done. Nothing is recorded. |
| 4xx (not 429) | Treated as permanent — you understood and rejected it. No retry. |
| 5xx, 429, timeout | Retried 5 times with exponential backoff from 10s. |
When an endpoint dies
After 15 consecutive deliveries exhaust their retries, the endpoint is disabled automatically and stops receiving events. A single blip will not do it — only an exhausted delivery counts, so a brief outage costs you nothing. Fix the endpoint and press Enable on the Developers page; that clears the counter.
Failed deliveries are listed per endpoint with the status code and response body, for 7 days. Successful ones are not recorded — at click volume that log would be the largest thing we store.
Endpoint requirements
- Must be https.
- Must be publicly reachable. Private, loopback and link-local addresses are rejected — both when you save the endpoint and again at the moment we connect, so a hostname that later resolves to an internal address is refused.
- Redirects are not followed. Give us the final URL.
- Up to 10 endpoints per workspace.