Webhooks
41 signed events, delivered and logged
Tell the rest of your stack the moment a laptop is issued, a booking is confirmed or a warranty falls due — without polling the API on a timer and hoping the interval is short enough.
Does Stackroom support webhooks?
Yes — 41 events across assets, custody, requests, audits, people, warranties, maintenance, licences, bookings, reminders and plan usage. Each delivery is signed with HMAC-SHA256 over a timestamped payload, retried on failure, and recorded in a delivery log you can read in the console.
The envelope
What arrives at your endpoint
Every event has the same shape — { id, event, created_at, data } — so a handler can route on event without knowing every type in advance.
- X-Stackroom-Event
- The event key, e.g. assignment.checked_out.
- X-Stackroom-Delivery
- Unique id for this delivery attempt series — use it to make your handler idempotent.
- X-Stackroom-Timestamp
- Unix seconds. Signed, not merely carried.
- X-Stackroom-Signature
- sha256=<hex>, HMAC-SHA256 over `<timestamp>.<raw body>`.
POST https://your-endpoint.example.com/hooks
Content-Type: application/json
X-Stackroom-Event: assignment.checked_out
X-Stackroom-Delivery: dlv_9f2c41ab
X-Stackroom-Timestamp: 1790000042
X-Stackroom-Signature: sha256=8b1f...c4
{
"id": "evt_4c8a21",
"event": "assignment.checked_out",
"created_at": "2026-09-21T10:14:02.000Z",
"data": {
"asset": { "id": "cl9x...", "name": "MacBook Pro 14\"", "sku": "IT-0421" },
"person": { "id": "cl7a...", "name": "Priya N." },
"due_date": "2026-10-05"
}
}Verification
Verify before you trust a single field
Your endpoint is a public URL, so anyone can post to it. The signature is what separates a real delivery from a forged one, and the timestamp is signed rather than merely carried, so a captured delivery cannot be replayed against you indefinitely.
import crypto from "node:crypto";
const TOLERANCE = 300; // seconds
// The RAW body — parse only after verifying. JSON.stringify(req.body) will not
// reproduce the bytes we signed, and the signature will never match.
export function verify(rawBody, headers, secret) {
const ts = headers["x-stackroom-timestamp"];
const signature = headers["x-stackroom-signature"];
if (!ts || !signature) return false;
// Reject old deliveries so a captured one cannot be replayed forever.
if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE) return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
// Constant-time: a fast string compare leaks the signature one byte at a time.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
TOLERANCE = 300 # seconds
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get("X-Stackroom-Timestamp")
signature = headers.get("X-Stackroom-Signature")
if not ts or not signature:
return False
if abs(time.time() - int(ts)) > TOLERANCE:
return False
expected = "sha256=" + hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)// Mount the raw body for this route only — everything else can stay on express.json().
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.headers, process.env.HOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// Answer first, work afterwards: the delivery times out after 10s.
res.sendStatus(200);
queue.add(event); // idempotent on event.id
});Delivery
Retries, timeouts and the log
3 attempts
The first, then two retries 300ms and 900ms later.
10s timeout
Per attempt. Answer 200 first and do the work afterwards.
Retried on
network errors, 408, 429 and any 5xx
Permanent
every other 4xx — the delivery is marked failed and not retried
Every attempt — status code, response body and timing — is written to the delivery log beside the endpoint in the console. There is no replay queue: if an endpoint was down for an extended period, reconcile with a paged read of the API rather than assuming the gap will fill itself.
Events
All 41, by module
Assets
- asset.created
- asset.updated
- asset.deleted
- asset.restored
- asset.condition_changed
- asset.scanned
Assignments
- assignment.checked_out
- assignment.checked_in
- assignment.overdue
Requests
- request.created
- request.approved
- request.rejected
Audits
- audit.started
- audit.completed
- audit.item_missing
People & members
- employee.created
- employee.offboarded
- member.invited
- member.removed
Warranties & maintenance
- warranty.expiring
- warranty.expired
- maintenance.created
- maintenance.resolved
Licences
- license.created
- license.updated
- license.deleted
- license.seat_assigned
- license.seat_released
- license.expiring
- license.expired
Bookings
- booking.created
- booking.requested
- booking.confirmed
- booking.cancelled
- booking.checked_out
- booking.completed
- booking.overdue
- booking.no_show
Reminders
- reminder.due
Plan & usage
- plan.changed
- plan.limit_reached
Subscribe to any subset per endpoint — most integrations want three or four, not all 41. A quiet endpoint costs nothing, but a handler that has to ignore thirty event types is one that will eventually mishandle one.
FAQ
Webhook questions
How do I verify a Stackroom webhook signature?
Compute HMAC-SHA256 over `<timestamp>.<raw body>` with your endpoint's signing secret, prefix it with "sha256=", and compare it to X-Stackroom-Signature in constant time. Reject anything whose X-Stackroom-Timestamp is more than 300 seconds old.
Why does my signature never match?
Almost always because the body was parsed before it was verified. The signature covers the exact bytes we sent; re-serialising the parsed JSON changes whitespace and key order. Capture the raw body for the webhook route only.
How many times will a delivery be retried?
Up to 3 attempts — the first plus two retries, 300ms and 900ms apart, each with a 10-second timeout. We retry network errors, 408, 429 and any 5xx. Every other 4xx — the delivery is marked failed and not retried.
Can the same event arrive twice?
Yes — a retry after your endpoint answered slowly delivers it again. Make the handler idempotent on the event id, or on X-Stackroom-Delivery if you want to distinguish the attempt series.
Can I see why a delivery failed?
Yes. Every attempt is recorded with its status code, response and timing in the delivery log next to the endpoint in the console — so "why did my webhook not arrive" is answerable without asking us.
Can I test without waiting for something to happen?
Yes. The Test button sends a real ping through the live delivery path — the same signing, retries, timeout and logging — rather than simulating one.
Are webhooks available on every plan?
They start on Pro, alongside API access. The event list and this contract stay public so you can build against them beforehand.
What happens if my endpoint is down for an hour?
Those deliveries fail after their retries and are marked failed in the log. There is no replay queue: reconcile with a paged GET on the API when your endpoint recovers, which is the honest answer rather than pretending nothing was missed.
Wire it into your stack
Point an endpoint at Stackroom, subscribe to the handful of events you care about, and send a test delivery through the real path.