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):

AttemptDelay after first attempt
1immediate
21 minute
35 minutes
415 minutes
51 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

TypeDescription
lap.uploadedA new lap landed via Companion or manual import.
lap.pb_setA driver beat their personal best for the layout + class.
lap.invalidatedAn admin marked a previously valid lap as invalid.
race.createdA race was scheduled.
race.signups_changedA driver signed up or withdrew from a race.
race.results_postedRace results were published.