Webhooks API
Receive LinkTrail installs and events as signed webhooks: register an endpoint, the event types, the payload, and verifying the X-LinkTrail-Signature HMAC.
A webhook pushes installs and in-app events to your server as they happen. Webhooks are on the Growth and Scale plans (other plans get 402), and reading or managing them needs developer, admin or owner.
Every endpoint on this page needs Authorization: Bearer <accessToken> and X-Workspace-Id: <id> — see Authentication. Paths are relative to https://backend.linktrail.io/v1.
| Method | Path | What it does |
|---|---|---|
| GET | /webhooks | List endpoints (never includes the secret) |
| POST | /webhooks | Register an endpoint; returns the signing secret once |
| GET | /webhooks/:webhookId | Load one |
| PATCH | /webhooks/:webhookId | Change the URL, the events or enabled |
| DELETE | /webhooks/:webhookId | Remove it |
| POST | /webhooks/:webhookId/test | Send a sample payload (10 a minute per workspace) |
{ "url": "https://example.com/hooks/linktrail", "events": ["install.attributed"] }{ "id": 1, "url": "https://example.com/hooks/linktrail", "events": ["install.attributed"],
"enabled": true, "consecutiveFailures": 0, "lastDeliveryAt": null, "lastError": null,
"secret": "whsec_…" }Store secret when you create the webhook. It is never returned again, and it is what proves a delivery came from LinkTrail.
Events
| Event | Sent when |
|---|---|
install.recorded | Any install is recorded |
install.attributed | That install matched a click |
install.fraud | The install was flagged as fraud: click injection on every plan; on Scale also click flooding, a country mismatch, IP velocity or a repeated device |
event.recorded | A post-install event (purchase, sign-up, custom) is recorded |
An empty events array subscribes to all of them. Only installs and events recorded with consent exist to be sent.
Payload
{ "event": "install.attributed",
"data": { "id": 91, "platform": "iOS", "country": "GB", "linkId": 4, "campaignId": 2,
"attributed": true, "matchType": "deterministic", "reengagement": false,
"fraud": false, "createdAt": "2026-06-28T10:00:00Z" },
"deliveredAt": "2026-06-28T10:00:00Z" }| Header | Value |
|---|---|
X-LinkTrail-Event | The event name |
X-LinkTrail-Timestamp | Unix time in seconds |
X-LinkTrail-Signature | sha256=<hex> |
Verify the signature
The signature is an HMAC-SHA256 of <timestamp>.<raw body> keyed with your secret, in hex. Compute it over the raw request body — before any JSON parsing — compare in constant time, and reject a timestamp older than a few minutes. The timestamp is inside the signed string, so a captured delivery cannot be replayed with a fresh one.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string or Buffer, not parsed JSON).
export function verifyLinkTrailWebhook(
rawBody: string | Buffer,
headers: Record<string, string | undefined>,
secret: string, // whsec_…
toleranceSeconds = 300,
): boolean {
const timestamp = headers["x-linktrail-timestamp"];
const signature = headers["x-linktrail-signature"];
if (!timestamp || !signature?.startsWith("sha256=")) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = signature.slice("sha256=".length);
return (
given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected))
);
}Delivery rules
- The URL must be public
https. Private, loopback and link-local addresses are rejected with 400, and redirects are not followed — a 3xx counts as a failed delivery. - Answer with a 2xx within 5 seconds. A delivery is sent once and is not retried: a slow or failing endpoint misses that notification, never the install.
- After 20 consecutive failures the webhook is disabled.
PATCHit with{ "enabled": true }to turn it back on and reset the count. POST /webhooks/:webhookId/testanswers{ "delivered": true|false, "lastError": null|"…" }. A 429 from it means nothing was sent.- Payloads never carry the raw IP address.