Worked examples
Each example runs against a real server in LawFive’s tests, as written here. They’re in JavaScript, and the steps are the same in any language.
Keep a copy up to date
Section titled “Keep a copy up to date”Follow every change with GET /v1/changes, deletions included, without missing any. The key needs read access to each collection you want; the others are left out.
Keep the cursor with your copy, and save both together, so a crash repeats a page rather than skipping one. Applying a change twice does no harm, because each one is the whole record as it is now.
const LAWFIVE_URL = process.env.LAWFIVE_URL;const LAWFIVE_KEY = process.env.LAWFIVE_KEY;
// Your own storage: a record by collection and id, and the cursor. Start the cursor at 0.const copy = new Map();let cursor = 0;
async function syncOnce() { let more = true; while (more) { const res = await fetch(`${LAWFIVE_URL}/v1/changes?since=${cursor}&limit=500`, { headers: { Authorization: `Bearer ${LAWFIVE_KEY}` }, }); if (res.status === 429) { await new Promise((done) => setTimeout(done, Number(res.headers.get("retry-after")) * 1000)); continue; } if (!res.ok) throw new Error(`changes: ${res.status} ${(await res.json()).error}`); const page = await res.json(); for (const { collection, record } of page.changes) { const key = `${collection}/${record.id}`; if (record.deletedAt) copy.delete(key); else copy.set(key, record); } cursor = page.cursor; more = page.more; }}
await syncOnce();Run syncOnce on a timer, or when a webhook says something changed. A record changed several times appears once, as it is now. Tracks and photos aren’t in this feed: a match report’s track has its own endpoints.
Read matches with their venues and officials
Section titled “Read matches with their venues and officials”List someone’s upcoming matches, with the names a match only refers to by id. The key needs matches:read, venues:read and referees:read.
A match holds ids rather than copies: venueId points to a venue, pitchId to one of that venue’s pitches, and each crew member has either a refereeId or a typed name. Both can be missing, so check.
const LAWFIVE_URL = process.env.LAWFIVE_URL; // https://api.lawfive.app, or the person's own serverconst LAWFIVE_KEY = process.env.LAWFIVE_KEY; // lf_...
async function get(path) { const res = await fetch(`${LAWFIVE_URL}${path}`, { headers: { Authorization: `Bearer ${LAWFIVE_KEY}` } }); if (!res.ok) throw new Error(`${path}: ${res.status} ${(await res.json()).error}`); return res.json();}
const [{ items: matches }, { items: venues }, { items: referees }] = await Promise.all([ get("/v1/matches"), get("/v1/venues"), get("/v1/referees"),]);const venueById = new Map(venues.map((v) => [v.id, v]));const refereeById = new Map(referees.map((r) => [r.id, r]));
const upcoming = matches .filter((m) => m.status === "scheduled" && m.kickoff && new Date(m.kickoff) > new Date()) .sort((a, b) => new Date(a.kickoff) - new Date(b.kickoff)) .map((m) => ({ id: m.id, title: m.name ?? `${m.home.name ?? "Home"} v ${m.away.name ?? "Away"}`, kickoff: m.kickoff, venue: venueById.get(m.venueId)?.name, crew: m.crew.map((c) => ({ role: c.role, name: refereeById.get(c.refereeId)?.name ?? c.name })), }));The list leaves out deleted records. kickoff is optional, and it carries its own UTC offset, so keep that offset when showing the time. A key without one of those scopes gets 403, and the error says which it needs.
Receive changes as they happen
Section titled “Receive changes as they happen”Check that a webhook delivery is really from LawFive, then act on it once. The person makes the webhook in the website’s Settings → Webhooks, chooses its topics and gets its whsec_... secret once.
Check the signature against the raw body before parsing it. It’s the hex HMAC-SHA256 of <t>.<body>, sent as X-LawFive-Signature: t=<unix seconds>,v1=<signature>. A delivery can arrive more than once, so remember its id.
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromLawFive(rawBody, header, secret, maxAgeSeconds = 300) { const fields = Object.fromEntries(header.split(",").map((part) => part.split("="))); const age = Math.abs(Date.now() / 1000 - Number(fields.t)); if (!(age <= maxAgeSeconds)) return false; const expected = createHmac("sha256", secret).update(`${fields.t}.${rawBody}`).digest(); const given = Buffer.from(fields.v1 ?? "", "hex"); return given.length === expected.length && timingSafeEqual(given, expected);}
const seen = new Set(); // Keep these somewhere lasting, for at least a few hours.
// Returns the HTTP status to answer with. Answer within 10 seconds, so queue anything slow.function handleDelivery(rawBody, signatureHeader, secret, act) { if (!signatureHeader || !isFromLawFive(rawBody, signatureHeader, secret)) return 401; const delivery = JSON.parse(rawBody); if (seen.has(delivery.id)) return 200; seen.add(delivery.id); if (!delivery.test) act(delivery.event, delivery.data); return 200;}event is the record and what happened, such as match.updated or venue.deleted, and data is the record, or only its id, version and when it was deleted. Anything but a 2xx is tried again after 1 minute, 5 minutes, 30 minutes and 2 hours. Deliveries can arrive out of order, so compare version before replacing what you have. To catch up after an outage, use GET /v1/changes.
Save a record safely
Section titled “Save a record safely”Create, change and delete a match without overwriting a change made elsewhere, such as on the person’s watch. The key needs matches:read and matches:write.
You choose a new record’s id, so repeating a create after a lost connection can’t make a second copy. PUT replaces the whole record, so read it first and send every field back, not only the ones you changed. Every record has a version: send the one you last saw, and the server answers 409 with the record as it is now if it has changed since. The format block holds every setting a match’s format needs.
const LAWFIVE_URL = process.env.LAWFIVE_URL;const LAWFIVE_KEY = process.env.LAWFIVE_KEY;
async function call(method, path, body) { const res = await fetch(`${LAWFIVE_URL}${path}`, { method, headers: { Authorization: `Bearer ${LAWFIVE_KEY}`, "Content-Type": "application/json" }, body: body && JSON.stringify(body), }); return { status: res.status, body: await res.json() };}
// Create: no version. 201 means it's new.const id = crypto.randomUUID();const created = await call("PUT", `/v1/matches/${id}`, { status: "scheduled", kickoff: "2030-03-09T10:00:00-08:00", timeZone: "America/Los_Angeles", home: { name: "Strikers" }, away: { name: "Rovers" }, crew: [], format: { periods: 2, periodSec: 2700, breakSec: 900, halfwayBreak: false, halfwayBreakSec: 120, teamSize: 11, clock: "up", trackStoppage: false, breakStoppage: true, goalScorers: true, goalTypes: false, extraTime: false, extraTimePeriods: 2, extraTimePeriodSec: 900, penalties: false, },});if (created.status === 400) throw new Error(JSON.stringify(created.body.issues));
// Update: apply your change to the latest record and send its version. If someone got there first,// apply it again to the record the 409 hands back. The server sets updatedAt, so leave it out.async function update(id, change) { let { body: latest } = await call("GET", `/v1/matches/${id}`); for (let tries = 0; tries < 3; tries++) { const { updatedAt, deletedAt, ...fields } = latest; const saved = await call("PUT", `/v1/matches/${id}`, change(fields)); if (saved.status !== 409) return saved; if (!saved.body.current) throw new Error(saved.body.error); latest = saved.body.current; } throw new Error("It keeps changing; try again later");}const updated = await update(id, (match) => ({ ...match, notes: "Bring spare flags." }));
// Delete: it stays as a marker with deletedAt, so other devices hear of it.const deleted = await call("DELETE", `/v1/matches/${id}?version=${updated.body.version}`);A 400 lists what’s wrong in issues. A 409 with no current means the id belongs to another kind of record, or the save would duplicate another, such as a second match with the same external id. Saving a deleted record brings it back.