Production deploy completed (DigitalOcean Droplet, self-hosted Supabase, Caddy + Let's Encrypt, GitHub Actions auto-deploy on push to main, daily pg_dumpall backups). This commit lands the work that came out of the deep audit pass and the open-source release prep. Audit fixes: - Webhook signing secret is now displayed in the settings UI with show/hide and copy-to-clipboard; the broken external docs.hackerspace.sh link is replaced with a link to the new local docs. - Settings updateSpaceSettings parameter type now includes mission_statement so the field persists. - members page enforces member_directory_visibility for non-admin viewers (admin_only, board_visible, members_visible). - Mobile responsiveness pass: Projects Kanban grid, Members + Payments tables (column hiding on small screens), Proposal tally + voting buttons (2-col on mobile). Open-source release scaffolding: - LICENSE (MIT). - CONTRIBUTING.md with dev setup, conventions, PR flow. - SECURITY.md with disclosure policy. - CHANGELOG.md. - docs/WEBHOOKS.md with Node and Python signature verification examples. - README.md rewritten for an open-source audience. Removed v0/Vercel references: - @vercel/analytics dependency removed. - app/layout.tsx no longer imports Analytics, drops generator: 'v0.app'. - Canonical deployment docs (docs/DEPLOYMENT.md, docs/README.md, docs/ARCHITECTURE.md, DATABASE.md, CLAUDE.md, README.md) now describe the self-hosted Droplet target only. - e2e console-error check no longer filters [v0] noise. Landing page: - GitHub icon link to the public repo in the nav and footer.
5.1 KiB
Webhooks
Webhooks let an external service react to events happening inside a space (a new member, a payment recorded, a proposal opened, an incident filed, etc). Each space has one webhook endpoint and one signing secret.
Status: the webhook envelope, signing secret rotation, and the verification contract documented here are stable. Per-event delivery, retry policy with backoff, and the delivery history UI are tracked in
docs/AUDIT.mdanddocs/PRODUCTION_AUDIT.mdand may land in a follow-up release.
Configuring the endpoint
- Sign in as a space admin.
- Go to Settings → API → Webhooks.
- Paste your endpoint URL into the Endpoint URL field. The endpoint must be HTTPS and reachable from the internet. Local development URLs (e.g.
ngroktunnels) are fine for testing. - Save.
- Click Show secret to reveal the signing secret, then Copy to copy it. Store it in your receiver's environment, never in source control.
- To rotate the secret, click Rotate. A new value is generated and shown immediately. The previous secret stops verifying as soon as you rotate.
Payload envelope
Every delivery is a JSON POST with the following structure:
{
"id": "evt_01HZ8KQ8X1T2W3Y4Z5A6B7C8D9",
"type": "payment.recorded",
"created_at": "2026-05-14T22:00:00Z",
"space_id": "8f3a...",
"data": {
"...": "event-specific payload"
}
}
Fields:
id— a stable identifier you can use for de-duplication. Format: ULID prefixed withevt_.type— dotted event type. Current vocabulary:member.created,member.updated,member.removed,payment.recorded,payment.failed,proposal.created,proposal.closed,incident.created,policy.published.created_at— ISO 8601 UTC.space_id— UUID of the space that owns the event.data— event-specific payload. See the per-type schema indocs/API_REFERENCE.md.
Signing and verification
Every request includes two headers:
X-Hackerspace-Timestamp: 1747257600
X-Hackerspace-Signature: t=1747257600,v1=<hex-hmac-sha256>
The signature is computed as:
v1 = HMAC_SHA256(secret, f"{timestamp}.{raw_request_body}")
To verify a delivery, your receiver should:
- Read the raw request body before any JSON parsing.
- Extract the
tandv1values fromX-Hackerspace-Signature. - Reject the request if
|now - t| > 300seconds (replay protection). - Compute the expected signature using your stored secret.
- Compare with a constant-time comparison.
Node.js example
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/hackerspace",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.header("X-Hackerspace-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const t = Number(parts.t);
const sig = parts.v1;
if (!t || !sig) return res.status(400).end();
if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).end();
const payload = `${t}.${req.body.toString("utf8")}`;
const expected = crypto
.createHmac("sha256", process.env.HACKERSPACE_WEBHOOK_SECRET!)
.update(payload)
.digest("hex");
const ok =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// handle event
res.status(204).end();
}
);
app.listen(3000);
Python example
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["HACKERSPACE_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/hackerspace")
def handle():
header = request.headers.get("X-Hackerspace-Signature", "")
parts = dict(p.split("=") for p in header.split(","))
t = int(parts.get("t", "0"))
sig = parts.get("v1", "")
if abs(time.time() - t) > 300:
abort(401)
body = request.get_data()
payload = f"{t}.".encode() + body
expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json(force=True)
# handle event
return "", 204
Retries and backoff
A delivery is considered successful when the receiver returns any 2xx status within 10 seconds. Non-2xx responses or timeouts trigger up to five retries with exponential backoff (15s, 1m, 5m, 30m, 2h). After the fifth failed attempt the delivery is dropped and logged on the Webhooks → Recent deliveries panel.
The delivery history panel and retry policy editor are scheduled for a follow-up release. Until then, retries follow the fixed policy above.
Operational notes
- Webhook deliveries originate from your Droplet's outbound IP. Allowlist that IP if your receiver enforces source restrictions.
- The signing secret is stored encrypted at rest in
public.integrations(per space). Rotating it invalidates the previous value immediately. - Webhook configuration is per space. Multi-space tenants must configure each space individually.