Skip to content

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.

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 server
const 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.

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.

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.