- A site the Hub answers INSTALL_NOT_FOUND for drops its install and licence, so it no longer shows "Active" on the old token. - Unlicensed is read-only like suspended; read-only also blocks editing events, options and variants. LICENCE_CHECK_DISABLED (ignored in production) for e2e. - Heartbeat 30 s after the server starts and after a licence key is connected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.7 KiB
Hub protocol (version 1)
The contract between a Hope Events install (the church's site) and the Hope Events
Hub (events-hub.crosscode.co.za). A copy of this file lives in both repositories
(joshua/hope-events → docs/hub-protocol.md, joshua/events-hub → docs/hub-protocol.md);
change both together.
Principles:
- Install → Hub only. The install always starts the conversation; the Hub never calls into an install. Works behind firewalls, and the Hub has no access to church data.
- No personal information is ever sent — counts, versions and health only. Income is sent as a range, never an amount.
- A Hub outage never switches a church off. Licences are signed tokens the install verifies offline with the Hub's public key.
- Every request carries
protocolVersion. The Hub accepts the current and the previous version, so installs waiting to update keep working.
All bodies are JSON (Content-Type: application/json). Times are ISO 8601 UTC strings.
Errors are { "message": "...", "code": "..." } with a 4xx/5xx status.
1. Public key
GET /api/public-key
→ 200 { "protocolVersions": [1], "keyId": "k1", "algorithm": "Ed25519", "publicKey": "-----BEGIN PUBLIC KEY-----\n…" }
Installs ship with the Hub's public key built in (and may refresh it from here). The private key exists only on the Hub server.
2. Register an install
Sent once, at the end of /setup (licence step) or from Site settings → "Connect to Hub".
POST /api/installs/register
{
"protocolVersion": 1,
"licenceKey": "CEH-7K2M-9QXA-P4RT-WD8C",
"install": {
"name": "Hope Family Church",
"url": "https://events.hopefamily.co.za",
"adminContact": { "name": "Jane Admin", "email": "jane@hopefamily.co.za" },
"version": "1.41.0",
"timezone": "Africa/Johannesburg",
"server": { "node": "20.18.0", "postgres": "16.4", "os": "linux 6.8" },
"features": { "whatsapp": true, "paymentProviders": ["yoco", "payfast"], "campuses": false }
}
}
→ 201 {
"installId": "inst_…",
"installSecret": "…", // shown once; the install stores it encrypted
"licence": "<licence token>",
"heartbeatIntervalMinutes": 15,
"latestVersion": "1.41.0"
}
404 LICENCE_NOT_FOUND— unknown key.403 LICENCE_REVOKED— revoked licences can't register.- One licence = one install. Registering again from the same URL re-registers (new
secret, same
installId). From a different URL →409 LICENCE_IN_USEuntil Joshua releases the licence in the Hub (e.g. after a move to a new domain). - The
adminContactis the one piece of personal information: a work contact for the church's admin, so Joshua can reach them. It is not used for anything else. - Rate-limited per IP.
3. Heartbeat
Every heartbeatIntervalMinutes (15) from the install's job queue.
POST /api/installs/heartbeat
X-Hub-Install: inst_…
X-Hub-Timestamp: 2026-10-24T08:15:00.000Z
X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
{
"protocolVersion": 1,
"version": "1.41.0",
"health": {
"uptimeSeconds": 86400,
"db": true,
"jobs": { "mode": "queue", "failedLast24h": 0 },
"lastBackupAt": "2026-10-24T00:00:12.000Z", // null if none
"disk": { "freeBytes": 21474836480, "totalBytes": 53687091200 }, // null if unknown
"errorsLast24h": 0, // null if not tracked
"pendingMigrations": 0
},
"stats": {
"users": 412,
"events": { "active": 3, "upcoming": 5, "total": 61 },
"registrations": { "last30d": 88, "total": 2310 },
"tickets": { "last30d": 140, "total": 3901 },
"checkIns": { "last30d": 96 },
"messages": { "email30d": 610, "whatsapp30d": 233 },
"paymentProviders": ["yoco", "payfast"],
"campuses": false,
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
}
}
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
- The signature covers the exact bytes sent. Requests more than 5 minutes off the Hub's
clock are refused (
401 STALE_REQUEST); a bad signature is401 BAD_SIGNATURE; an unknown or released install is404 INSTALL_NOT_FOUND(the install should show "Reconnect to Hub"). - Income bands (worked out on the install; the amount never leaves it):
R0–R1 000,R1 001–R5 000,R5 001–R20 000,R20 001–R50 000,R50 001–R100 000,over R100 000. - The Hub keeps every heartbeat for 30 days, then one per install per day.
4. Licence token
hel1.<payload>.<signature> — payload is base64url JSON, signature is the base64url
Ed25519 signature of the ASCII string hel1.<payload>.
{
"v": 1,
"licenceId": "lic_…",
"installId": "inst_…",
"customer": "Hope Family Church",
"plan": "standard",
"status": "active", // active | suspended | revoked
"licenceExpiresAt": "2027-10-31T21:59:59.000Z", // null = no end date
"graceDays": 14,
"issuedAt": "2026-10-24T08:15:00.000Z",
"validUntil": "2026-11-23T08:15:00.000Z", // issuedAt + 30 days
"limits": { "aiMonthlyCapCents": 0 }
}
What the install does with it
The install keeps the newest valid token (signature checks out, installId matches) and works
out its state:
| State | When | What happens |
|---|---|---|
| active | status is active and now is before min(licenceExpiresAt, validUntil) |
Everything works |
| grace | after that date, for up to graceDays |
Everything works; only site admins see a banner "Licence needs renewing — X days left" |
| suspended | status is suspended, or grace has run out |
Read-only: events can't be created or edited; no new registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work |
| revoked | status is revoked |
Same as suspended, with a permanent banner |
| unlicensed | never connected, or the Hub answered INSTALL_NOT_FOUND (the install drops its token) |
Same as suspended (events can't be created or edited either); admins see "Connect to Hub" |
validUntil is refreshed by every heartbeat, so a Hub outage only matters after 30 days (then
grace starts). Blocking the Hub can't keep a suspended licence active for more than 30 days.
5. Versioning
protocolVersiongoes up only for changes an older install can't handle. Adding optional fields to a heartbeat or a token doesn't need a new version — both sides ignore fields they don't know.- The Hub accepts protocol
NandN-1; anything else is400 UNSUPPORTED_PROTOCOL.
6. Later (not in v1)
- AI proxy (Hope Events 2.0):
POST /api/ai/complete, signed like the heartbeat; the Hub holds the Claude API key, meters each call in rand and enforceslimits.aiMonthlyCapCents.