Stackroom

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>`.
A delivery
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.

Node
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);
}
Python
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)
Express — raw body, answer fast
// 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.