Webhooks and MCP Last updated

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:

HeaderValue
ItemTally-EventThe event type, movement.posted
ItemTally-Event-IdThe event id, same as id in the body
ItemTally-DeliveryThe delivery id. Retries reuse it, so dedupe on it.
ItemTally-Signaturet=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

EventWhen
item.createdAn item was created, in the app, by import or over the API
item.updatedAn item’s details changed
item.archivedAn item was archived
item.checked_outSomething was handed to a person (itemId, personId, dueAt)
item.checked_inSomething came back
movement.postedStock was adjusted, or items moved to another place
stock.loadedA van or site was loaded from the store
stock.lowAn item fell to its low-stock line. Sent once per dip.
count.postedA stock count was posted
count.dueA folder’s count reminder came round
custody.overdueSomething passed its due date. Sent once per loan.
person.created, person.updated, person.archivedChanges to people
reservation.created, reservation.archivedA 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.