Growth events, pushed to you.
A signed HTTP POST the moment your SaaS hits a milestone, moves on a board, spikes, gets verified, or a data source fails and recovers. Same events as the dashboard and the feed — nothing synthesized from small metric changes.
How it works
Four steps
whsec_…) is shown once.webhook.test event goes through the exact same pipeline, signature and retry log.UserTrack-Event-Id to make your handler idempotent.Event catalog
"milestone": { "id": "m1…", "key": "users:10000", "kind": "users", "metric": "totalUsers", "value": 10000, "title": "10,000 users", "achievedAt": "…" }"rank": { "board": "leaderboard", "window": "30d", "from": 14, "to": 9, "best": 9 }"rank": { "board": "trending", "window": "7d", "from": 22, "to": 6, "score": 81.4 }"spike": { "day": "2026-09-01", "newUsers": 412, "average": 96, "multiple": 4.3, "metric": "newUsers" }"integration": { "id": "i1…", "role": "users", "provider": "postgres", "label": "Postgres", "error": "connection refused", "consecutiveFailures": 6, "lastSuccessAt": "…" }"integration": { "id": "i1…", "role": "users", "provider": "postgres", "label": "Postgres", "unhealthySince": "…", "downForMs": 86400000 }"verification": { "level": "verified", "provider": "clerk", "verifiedAt": "…" }"message": "Test event from UserTrack…", "sample": { "type": "milestone.reached", "milestone": { "kind": "users", "metric": "totalUsers", "value": 10000, "title": "10,000 users" } }Rank events fire at most once per UTC day per board. Milestones and spikes are deduplicated by key, so a re-sync never re-sends them.
Versioned JSON
id— event id, stable across endpoints and retries.type— one of the types above.apiVersion— 2026-09-01. Only additive changes within a version.createdAt— ISO 8601, when the event happened.test— true only on webhook.test.data.project— id, slug, name, url, totalUsers. Always present.
{
"id": "evt_milestone_reached_j57…_users_10000",
"type": "milestone.reached",
"apiVersion": "2026-09-01",
"createdAt": "2026-09-02T08:00:00.000Z",
"data": {
"project": { "id": "j57…", "slug": "acme", "name": "Acme", "url": "https://usertrack.dev/s/acme", "totalUsers": 10000 },
"milestone": { "id": "m1…", "key": "users:10000", "kind": "users", "metric": "totalUsers", "value": 10000, "title": "10,000 users", "achievedAt": "2026-09-02T08:00:00.000Z" }
}
}Trust, but verify
- 01Read the raw request body as bytes — do not parse and re-serialize.
- 02Build the signed string: `${UserTrack-Timestamp}.${rawBody}`.
- 03Compute hex(HMAC-SHA256(secret, signedString)) and prefix with v1=.
- 04Compare with UserTrack-Signature using a constant-time comparison.
- 05Reject if |now − timestamp| > 300 s (5 minutes) to defeat replays.
import { createHmac, timingSafeEqual } from "node:crypto";
// Express: app.post("/hooks", express.raw({ type: "application/json" }), handler)
export function verifyUserTrack(rawBody: Buffer | string, headers: Record<string, string | undefined>) {
const secret = process.env.USERTRACK_WEBHOOK_SECRET;
const timestamp = headers["usertrack-timestamp"] ?? "";
const signature = headers["usertrack-signature"] ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5-minute tolerance
const expected = "v1=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify_usertrack(raw_body: bytes, headers: dict, secret: str) -> bool:
timestamp = headers.get("UserTrack-Timestamp", "")
signature = headers.get("UserTrack-Signature", "")
if abs(time.time() - float(timestamp or 0)) > 300:
return False
expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Schedule
- Retried: 5xx, 408, 425, 429, network errors and timeouts (10 s).
- Final: any other 4xx. Fix the endpoint and use “Retry” in the delivery log.
- After 5 attempts the delivery is exhausted; it can still be retried manually.
- 25 consecutive failures disable the endpoint. Re-enable it from the dashboard; the failure counter resets.
- Retries reuse the same delivery id and event id; the timestamp and signature are fresh on every attempt.
Exercise the whole pipeline
“Send test” in the dashboard (or usertrack_test_webhook over MCP) enqueues a webhook.test event regardless of the endpoint’s subscriptions. It is signed, retried and logged like any other delivery, carries test: true and a sample milestone.reached body under data.sample.
{ "id": "evt_test_…", "type": "webhook.test", "apiVersion": "2026-09-01", "createdAt": "…", "test": true,
"data": { "project": { … }, "message": "Test event from UserTrack…", "endpointId": "…", "sample": { "type": "milestone.reached", "milestone": { … } } } }Two ids, two jobs
One per (endpoint, event). Identical on every retry of that delivery and sent again as Idempotency-Key. Use it to drop duplicate retries when your handler succeeded but the response got lost.
Deterministic per source event, so two endpoints receiving the same milestone see the same id. Dedupe on it if several endpoints feed the same system.
Defaults you cannot switch off
- https only. http URLs and URLs with credentials are rejected at creation.
- Private, loopback, link-local and cloud-metadata ranges are blocked, for literal IPs and for hostnames — DNS is resolved right before each delivery and refused if it points inside.
- Secrets are generated server-side, shown once, and rotatable at any time (“Rotate secret”). Old signatures stop validating immediately.
- Endpoints belong to your account; project-scoped endpoints only ever receive that project’s events. Demo projects never emit webhooks.
- Payloads contain aggregate counts only — no emails, names or per-user rows.
- Creating, rotating and deleting endpoints is recorded in your audit trail on /app/developer.