Reference
Webhooks
The portal POSTs signed JSON to your URL when an event you subscribed to fires. Subscriptions are managed under /settings/webhooks. The secret is shown once at creation; store it server-side.
Event payload
Every delivery has the same envelope. Inspect type to dispatch and data for the event-specific body.
{
"id": "evt_01HW7ZG6F3T3Q4BX8YH2K9V8M5",
"type": "lap.uploaded",
"teamId": "abcd1234-...",
"createdAt": "2026-05-05T12:34:56.000Z",
"data": {
"lapId": "9c5d...",
"driverUserId": "...",
"lapTimeMs": 73422,
"trackLayoutId": "...",
"carClass": "Hypercar"
}
}Signature header
Every request carries an X-LMU-Signature header. The format is two comma-separated key/value pairs:
X-LMU-Signature: t=1735300000000,v1=4f83a2f9b4e0e2a3...
t— milliseconds since the unix epoch when we signed.v1— lowercase hex SHA-256 HMAC of<t>.<raw body>using your subscription secret.
Reject any request whose t is more than 5 minutes from now (replay protection) and whose v1 doesn’t match the recomputed HMAC. Always read the raw body before any JSON parser touches it — re-serialising will break the signature.
Verifying in your language
Node.js
// Node.js — verify an LMU webhook signature.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLmuSignature(opts: {
body: string; // raw request body
header: string; // value of X-LMU-Signature
secret: string; // your subscription's secret
toleranceMs?: number; // default 5 minutes
}): boolean {
const tolerance = opts.toleranceMs ?? 5 * 60 * 1000;
const parts = Object.fromEntries(
opts.header.split(",").map((kv) => kv.trim().split("="))
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() - t) > tolerance) return false;
const expected = createHmac("sha256", opts.secret)
.update(`${parts.t}.${opts.body}`)
.digest("hex");
// Constant-time compare.
if (expected.length !== v1.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Python
# Python 3 — verify an LMU webhook signature.
import hmac, hashlib, time
def verify_lmu_signature(body: str, header: str, secret: str, tolerance_ms: int = 300_000) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
now_ms = int(time.time() * 1000)
if abs(now_ms - t) > tolerance_ms:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{parts['t']}.{body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)Go
// Go — verify an LMU webhook signature.
package lmuwebhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func Verify(body, header, secret string, tolerance time.Duration) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
kv = strings.TrimSpace(kv)
i := strings.IndexByte(kv, '=')
if i < 0 {
return false
}
parts[kv[:i]] = kv[i+1:]
}
tStr, v1 := parts["t"], parts["v1"]
t, err := strconv.ParseInt(tStr, 10, 64)
if err != nil || v1 == "" {
return false
}
if abs(time.Now().UnixMilli()-t) > tolerance.Milliseconds() {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.%s", tStr, body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }Idempotency
Treat id on the envelope as the idempotency key. Store processed ids for at least 24 hours and short-circuit duplicates with a 200. We retry on any non-2xx response and on connection errors, so a slow consumer that times out partway through processing should expect to see the same event twice.
Retry schedule
We retry failures on a fixed back-off (relative to the initial attempt):
| Attempt | Delay after first attempt |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
After attempt 5, the delivery is moved to the dead-letter queue and the subscription is paused if more than 50 % of the last 100 deliveries failed. You can replay any delivery from the management UI.
Event types
| Type | Description |
|---|---|
| lap.uploaded | A new lap landed via Companion or manual import. |
| lap.pb_set | A driver beat their personal best for the layout + class. |
| lap.invalidated | An admin marked a previously valid lap as invalid. |
| race.created | A race was scheduled. |
| race.signups_changed | A driver signed up or withdrew from a race. |
| race.results_posted | Race results were published. |