Webhooks
Signed webhooks let Space Works call your HTTPS endpoint whenever something happens in a makerspace: a loan is accepted, a booking is confirmed, a printer goes offline, a payment is received. They use the same notification matrix, retry logic and delivery log as the Slack, Discord, Mattermost and Telegram channels, but deliver structured, signed JSON instead of a chat message.
Set up a webhook
Section titled “Set up a webhook”-
Enable the module. Turn on Signed webhooks (module key
webhook) for the makerspace. See Modules. -
Add a destination. In the staff console’s notification settings, add a destination on the Signed webhook channel with your endpoint URL and a signing secret. Or use the API (requires the manage makerspace permission):
Terminal window curl -s https://spaces.example.org/api/v1/admin/makerspace/7/notification-destinations \-H "Authorization: Bearer <staff-access-token>" \-H "Content-Type: application/json" \-d '{"channel": "webhook","label": "Inventory sync","webhook_url": "https://hooks.example.org/spaceworks","signing_secret": "<a-random-secret-of-16-or-more-characters>","payload_version": 2}' -
Route events to it. Every event family starts with the webhook channel switched off. Turn on the events you want in the notification matrix (staff console, or
PATCH /api/v1/admin/makerspace/{id}/notification-rules). See Notifications. -
Send a test.
POST /api/v1/integrations/makerspace/{id}/delivery-testwith{"destination_id": <id>}sends asystem.delivery_testevent so you can check your receiver end to end.
Rules for the endpoint URL:
- It must be an absolute
https://URL with no embedded credentials and no fragment. - Its hostname must resolve to a public address. Private, loopback and other non-public addresses are refused when you save the destination and again at every send, and the connection is pinned to the address that was checked. To test against a laptop, expose it through a public HTTPS tunnel.
- The signing secret is between 16 and 512 characters. It is stored encrypted and never shown again; the API only reports
signing_secret_set: true. To change it,PATCHthe destination with a newsigning_secret.
Payload formats
Section titled “Payload formats”Each destination has a payload_version. Version 1 is the default for existing destinations; version 2 is the structured format recommended for new integrations. You can switch a destination at any time by updating payload_version. Both versions use the same signature.
Version 2 (structured)
Section titled “Version 2 (structured)”{ "version": 2, "id": "42", "event": "hardware_requests.accepted", "makerspace_id": 7, "occurred_at": "2026-10-11T00:00:00+00:00", "data": { "request_id": 123 }}| Field | Type | Meaning |
|---|---|---|
version |
integer, always 2 |
Payload format version. |
id |
string | The delivery ID. Stable across retries of the same delivery. |
event |
string | <family>.<event>, for example bookings.confirmed. |
makerspace_id |
integer | The makerspace the event belongs to. |
occurred_at |
date-time | When the event happened (captured before queueing, so it is stable across retries). |
data |
object | Positive integer IDs of the records involved. Missing IDs are omitted. |
Version 2 deliberately carries only identifiers: no chat text, names, email addresses, phone numbers, payment-provider handles, download links or free-form notes. Fetch the details you need from the API with those IDs. The envelope and data reject unknown fields, so a version-2 payload never grows silently; new data would come with a new payload version.
A complete JSON Schema (Draft 2020-12) with one definition per event is published at docs/webhooks-v2.schema.json. Use it to validate payloads in tests.
| Family | data properties |
|---|---|
hardware_requests |
request_id, payment_id |
printing |
machine_service_request_id, request_id, machine_id |
events |
event_id, series_id, registration_id |
bookings |
booking_id, space_id |
maintenance |
machine_id, schedule_id, maintenance_log_id |
members |
membership_request_id, membership_id |
api_access |
api_key_request_id |
devices |
none |
payments |
payment_id, refund_id, request_id, booking_id, registration_id, membership_id |
stock |
to_buy_item_id, pool_id |
certifications |
certification_grant_id, certification_type_id, membership_id |
reports |
schedule_id |
system.delivery_test |
none |
Version 1 (legacy)
Section titled “Version 1 (legacy)”Version 1 sends the same message a chat room would get, plus IDs:
{ "id": 42, "event": "accepted", "feature": "hardware_requests", "makerspace_id": 7, "text": "Hardware request #12 has been accepted.", "data": { "request_id": 12, "occurred_at": "2026-10-10T01:02:03+00:00" }, "created_at": "2026-10-10T01:02:04+00:00", "structured": { "version": 1, "event": "hardware_requests.accepted", "ids": { "request_id": 12 }, "makerspace": { "id": 7 }, "occurred_at": "2026-10-10T01:02:03+00:00" }}Here event is the bare event key and id is an integer. If you are on version 1, automate on structured.event and structured.ids rather than parsing text.
Event catalogue
Section titled “Event catalogue”| Family | Events |
|---|---|
hardware_requests |
submitted, accepted, rejected, issued, partially_returned, returned, closed_with_issue, return_reminder, deposit_raised, late_fee_raised, overdue_escalated, back_available |
printing |
submitted, accepted, started, rejected, completed, failed, collected, reprinted |
events |
published, cancelled, completed, registration_created, registration_pending_approval, registration_waitlisted, registration_approved, registration_rejected, registration_cancelled, registration_promoted, registration_attended, series_published, series_cancelled, series_completed, series_generation_failed, reminder |
bookings |
created, confirmed, rejected, cancelled, completed, no_show, reminder |
maintenance |
schedule_created, schedule_updated, schedule_deactivated, logged, schedule_completed, due_soon, overdue |
members |
request_pending, member_joined, approved, declined, renewal_due, lapsed, renewal_failed, awaiting_payment, payment_due, renewed, grace_started, cancelled, broadcast |
api_access |
api_key_request_resolved |
devices |
offline, recovered |
payments |
charge_raised, payment_due_soon, payment_due_today, payment_overdue, payment_received, refund_issued, payment_failed |
stock |
low_stock, procurement_received |
certifications |
granted, revoked, expiring, expired |
reports |
scheduled_report (sent when a scheduled report is delivered to this destination) |
system |
delivery_test |
An event only reaches your endpoint if its module is enabled for the makerspace and its webhook cell is switched on in the matrix.
The request
Section titled “The request”Space Works sends a POST with these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
SpaceWorks-Webhook/2 (or SpaceWorks-Webhook/1 for version-1 payloads) |
X-SpaceWorks-Event |
Version 2: <family>.<event>. Version 1: the bare event key. |
X-SpaceWorks-Delivery |
The delivery ID. Same as the body’s id. |
X-SpaceWorks-Signature |
t=<unix seconds>,v1=<hex signature> |
The body is compact JSON with sorted keys. Do not rely on that formatting; verify against the raw bytes you receive.
Verifying signatures
Section titled “Verifying signatures”The signature is the lowercase hex HMAC-SHA256 of the timestamp, a dot, and the raw body:
v1 = hex( HMAC-SHA256( signing_secret, "<t>." + raw_body_bytes ) )The v1 label names the signature algorithm, not the payload version; it is v1 for both payload formats.
To verify:
- Parse
tandv1fromX-SpaceWorks-Signature. - Reject the request if
tis too far from your clock (five minutes is a good tolerance). - Recompute the HMAC over the raw body bytes, before any JSON parsing.
- Compare with a constant-time comparison.
Python (Flask)
Section titled “Python (Flask)”import hashlibimport hmacimport time
from flask import Flask, abort, request
app = Flask(__name__)SIGNING_SECRET = "<your-signing-secret>"TOLERANCE_SECONDS = 300
def verify(header: str, raw_body: bytes, secret: str) -> bool: try: parts = dict(item.split("=", 1) for item in header.split(",")) timestamp = int(parts["t"]) received = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, received)
@app.post("/spaceworks")def spaceworks_webhook(): raw = request.get_data() # raw bytes, before parsing if not verify(request.headers.get("X-SpaceWorks-Signature", ""), raw, SIGNING_SECRET): abort(401) event = request.get_json() if already_processed(event["id"]): # your own de-duplication store return "", 204 handle(event["event"], event["data"]) # keep this fast; queue slow work mark_processed(event["id"]) return "", 204JavaScript (Node.js and Express)
Section titled “JavaScript (Node.js and Express)”import express from "express";import { createHmac, timingSafeEqual } from "node:crypto";
const SIGNING_SECRET = process.env.SPACEWORKS_SIGNING_SECRET;const TOLERANCE_SECONDS = 300;
function verify(header = "", rawBody, secret) { const parts = Object.fromEntries( header.split(",").map((p) => { const i = p.indexOf("="); return [p.slice(0, i), p.slice(i + 1)]; }), ); const t = Number.parseInt(parts.t, 10); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? ""); return a.length === b.length && timingSafeEqual(a, b);}
const app = express();
// express.raw keeps the body as a Buffer so the signature is checked on the exact bytes.app.post("/spaceworks", express.raw({ type: "application/json" }), (req, res) => { if (!verify(req.get("X-SpaceWorks-Signature"), req.body, SIGNING_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString("utf8")); // De-duplicate on event.id, then handle event.event / event.data. res.sendStatus(204);});
app.listen(3000);Test vector
Section titled “Test vector”With secret change-me-to-a-long-secret, timestamp 1760000000 and this exact body:
{"data":{"request_id":123},"event":"hardware_requests.accepted","id":"42","makerspace_id":7,"occurred_at":"2026-10-11T00:00:00+00:00","version":2}the header is:
t=1760000000,v1=b12c8b2414217d1cb3b506c0642370b455973d224fad90eb4f9f9f432ddfe2ccDelivery, retries and failures
Section titled “Delivery, retries and failures”- Success is any
2xxresponse within 5 seconds. Respond quickly and do slow work in the background. - Redirects (
301,302,303,307,308) are followed up to three hops; each new location is checked against the same public-address rules. A redirect without aLocation, or a fourth hop, is a failure. - Anything else (a non-2xx status, a timeout, a TLS or connection error) fails the attempt. The delivery worker retries up to 3 more times with exponential backoff: roughly 1, 2 and 4 minutes, plus a little random jitter.
- After the last retry the delivery is marked failed in the makerspace’s Delivery logs. A space manager can retry it from there (or with
POST /api/v1/integrations/makerspace/{id}/delivery-logs/channel/{log_id}/retry), and a Super Admin can retry it from/control/. A manual retry re-sends the same delivery with the same ID.
Each retry gets a fresh timestamp and signature, but the body (including id and occurred_at) is unchanged.
Deliveries are sent by the background worker shortly after the event. If the makerspace has quiet hours set in its notification timing policy, a delivery can be held until they end; occurred_at still records when the event happened.
Idempotency
Section titled “Idempotency”Delivery is at least once, not exactly once. A worker can crash after your server accepted a request but before Space Works recorded the success, and that delivery will be sent again.
- De-duplicate on the delivery ID (
idin the body,X-SpaceWorks-Deliveryin the headers). If you receive webhooks from more than one Space Works deployment, key on deployment plus ID. - Each destination gets its own delivery ID for the same event, so two destinations pointing at the same URL will each deliver.
- Make handlers safe to re-run: “set status to accepted” rather than “increment a counter”.
- Use
occurred_at, not the time you received the request, when ordering events. Retries can arrive out of order.
Related
Section titled “Related”- Notifications: the notification matrix and other channels.
- API clients: calling Space Works from your server.
- API overview