Webhooks
A signed POST to your endpoint when stock moves, something is handed over or returned, stock runs low or a loan goes overdue.
Add an endpoint
In ItemTally, open Settings → Webhooks and add the URL that should receive events. Pick the events you want, or leave the list empty for all of them. You’ll get a signing secret that starts with whsec_; keep it with your endpoint.
Endpoints are managed in the app only. An API key can’t add or change them, so a leaked key can’t send your data somewhere new. Webhooks are free on every plan. You can also choose a Slack endpoint, which posts a readable sentence to a channel instead of JSON.
What arrives
A POST with a JSON body:
{
"id": "5959a49f-bb0e-495d-b458-b638f468aea2",
"type": "movement.posted",
"createdAt": "2026-10-16T07:59:46.539Z",
"data": {
"type": "adjust",
"delta": 24,
"itemId": "7f1c2a4e-0b7d-4c55-9a51-2f0e8d3c6b10",
"reason": "Delivery",
"quantity": 72
}
}
and these headers:
| Header | Value |
|---|---|
ItemTally-Event | The event type, movement.posted |
ItemTally-Event-Id | The event id, same as id in the body |
ItemTally-Delivery | The delivery id. Retries reuse it, so dedupe on it. |
ItemTally-Signature | t=1792137682,v1=b98707… |
data carries ids and the few fields that changed. Fetch the full record with GET /items/{itemId} or GET /people/{personId} when you need more.
Events
| Event | When |
|---|---|
item.created | An item was created, in the app, by import or over the API |
item.updated | An item’s details changed |
item.archived | An item was archived |
item.checked_out | Something was handed to a person (itemId, personId, dueAt) |
item.checked_in | Something came back |
movement.posted | Stock was adjusted, or items moved to another place |
stock.loaded | A van or site was loaded from the store |
stock.low | An item fell to its low-stock line. Sent once per dip. |
count.posted | A stock count was posted |
count.due | A folder’s count reminder came round |
custody.overdue | Something passed its due date. Sent once per loan. |
person.created, person.updated, person.archived | Changes to people |
reservation.created, reservation.archived | A reservation was made or cancelled |
The API reference shows an example body for each one.
Check the signature
Before you trust a delivery, recompute the signature. It’s an HMAC-SHA256 of t, a dot, and the raw request body, keyed with your endpoint secret. Check it before you parse the JSON: re-serialising changes the bytes.
import crypto from "node:crypto";
export function verifyItemTally(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
A small receiver with Node’s built-in server:
import http from "node:http";
const seen = new Set(); // use your database in production
http
.createServer((req, res) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
const ok = verifyItemTally(raw, req.headers["itemtally-signature"] ?? "", process.env.ITEMTALLY_WEBHOOK_SECRET);
if (!ok) return res.writeHead(401).end();
res.writeHead(200).end(); // answer fast, work after
const delivery = req.headers["itemtally-delivery"];
if (seen.has(delivery)) return;
seen.add(delivery);
const event = JSON.parse(raw);
console.log(event.type, event.data);
});
})
.listen(3000);
The t timestamp is when the attempt was signed. Rejecting old ones (five minutes here) stops someone replaying a captured request.
Retries
- Answer with any 2xx within 10 seconds and the delivery is done.
- Anything else, or a timeout, is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours. After the sixth attempt, about 2 hours 45 minutes in, the delivery is marked failed.
- Delivery is at least once. A retry, or a slow answer that we treat as a failure, can bring the same delivery twice. Dedupe on
ItemTally-Delivery. - Order isn’t guaranteed across events. Use
createdAt, or read the current state from the API, rather than assuming the last delivery is the newest. - Settings → Webhooks shows recent deliveries for each endpoint with their status and attempts.
Stuck on an integration? Email [email protected] with the request you sent and the response you got. The person who built the API answers, usually within a business day.