Webhooks
Instead of polling the reporting API, register an HTTPS endpoint and Toolbox Minute POSTs events to you as they happen: a worker finishes a lesson, a badge nears expiry. Every delivery is signed with your endpoint's secret so you can verify it really came from us.
Configuring endpoints
Webhook endpoints are managed in the admin portal under Admin > Settings, in the Webhooks section:
- Enter your endpoint URL (e.g.
https://wms.example.com/webhooks/toolboxminute) and tick the event types you want. - On save, a signing secret of the form
whsec_...is generated and shown exactly once. Copy it into your receiver's configuration immediately. - Use Ping to queue a
webhook.pingdelivery and confirm your receiver verifies and responds correctly.
Disabling or deleting an endpoint stops sends immediately; deliveries already queued for it are marked dead rather than retried.
Event types
| Event | Fires when |
|---|---|
lesson.completed |
An employee completes their daily lesson. Emitted from the completion feed by a dispatcher that runs about once a minute. |
badge.expiring |
A subtopic badge enters the 14-day expiry window. Evaluated by a daily job, so each badge produces one event when it first enters the window, not one per day. |
webhook.ping |
You press the Ping button in Admin > Settings. Not subscribable; used only to test an endpoint. |
The delivery request
Deliveries are HTTP POSTs with a JSON body. Every event uses the same envelope:
event, createdAtUtc, and an event-specific data object.
POST /webhooks/toolboxminute HTTP/1.1 Host: wms.example.com Content-Type: application/json X-TBM-Event: lesson.completed X-TBM-Delivery-Id: 5b8a2c9e-4f1d-4e6a-9c3b-7d2e8f0a1b4c X-TBM-Signature: sha256=6c1d0f8e2a94b37d5e0c81f6a2d94b7e3f5a0c68d1e29b74f8a3c50e6b17d2f4 { "event": "lesson.completed", "createdAtUtc": "2026-07-14T13:05:22.4185536+00:00", "data": { "externalId": "E-1001", "lessonDate": "2026-07-14", "lessonTitle": "Sharing Aisles With Forklifts", "quizScorePercent": 100, "completedAtUtc": "2026-07-14T13:05:21.9042215+00:00" } }
| Header | Meaning |
|---|---|
X-TBM-Event | The event type, same value as event in the body. |
X-TBM-Delivery-Id | Unique id (UUID) for this delivery. Retries reuse the same id, so this is your deduplication key. |
X-TBM-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your endpoint secret. |
lesson.completed data
| Field | Type | Notes |
|---|---|---|
externalId | string or null | The employee's external id (employee code). Null if the employee no longer has one. |
lessonDate | string | The scheduled lesson day, YYYY-MM-DD, in the employee's local calendar. |
lessonTitle | string or null | Title of the completed lesson. |
quizScorePercent | integer or null | 0 to 100. |
completedAtUtc | string | ISO 8601 timestamp of the completion. |
badge.expiring data
{
"event": "badge.expiring",
"createdAtUtc": "2026-07-14T09:00:03.1170446+00:00",
"data": {
"externalId": "E-1005",
"subTopic": "Forklift Pedestrian Safety",
"expiresAtUtc": "2026-07-27T14:12:40.0000000+00:00"
}
}
webhook.ping data
{
"event": "webhook.ping",
"createdAtUtc": "2026-07-14T16:41:07.2280913+00:00",
"data": {
"message": "Test ping from the Toolbox Minute admin portal."
}
}
Verifying the signature
Compute HMAC-SHA256 over the raw request body bytes using your
whsec_... secret as the key, hex-encode it lowercase, prefix
sha256=, and compare against X-TBM-Signature with a constant-time
comparison. Verify before you parse: any re-serialization of the JSON changes the bytes and
invalidates the signature.
// C# (ASP.NET Core minimal API) // using System.Security.Cryptography; using System.Text; app.MapPost("/webhooks/toolboxminute", async (HttpRequest request) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); // the exact bytes that were signed var secret = app.Configuration["Webhooks:ToolboxMinuteSecret"]!; // whsec_... var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(body)); var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant(); var actual = request.Headers["X-TBM-Signature"].ToString(); var valid = CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(actual)); if (!valid) return Results.Unauthorized(); var deliveryId = request.Headers["X-TBM-Delivery-Id"].ToString(); // dedupe key // Record deliveryId, queue the real work, and return 2xx quickly. return Results.Ok(); });
// Node (Express) const crypto = require("crypto"); // Use the raw body for the HMAC; JSON.parse only after verifying. app.post("/webhooks/toolboxminute", express.raw({ type: "application/json" }), (req, res) => { const expected = "sha256=" + crypto .createHmac("sha256", process.env.TBM_WEBHOOK_SECRET) // whsec_... .update(req.body) // Buffer of the exact bytes sent .digest("hex"); const actual = req.get("X-TBM-Signature") || ""; const ok = actual.length === expected.length && crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected)); if (!ok) return res.status(401).end(); const eventType = req.get("X-TBM-Event"); const deliveryId = req.get("X-TBM-Delivery-Id"); // dedupe key const payload = JSON.parse(req.body); // Acknowledge fast; do the real work asynchronously. res.status(200).end(); });
Delivery, timeouts, and retries
- Success is any 2xx response within 10 seconds. A non-2xx status, a timeout, or a connection error all count as one failed attempt.
- Failed attempts are retried on a backoff ladder. After the fifth failed attempt the delivery goes dead and is never retried.
- The dispatcher runs about once a minute, so actual retry timing can trail the schedule by up to a minute.
| Failed attempt | Next retry |
|---|---|
| 1 | after 1 minute |
| 2 | after 5 minutes |
| 3 | after 30 minutes |
| 4 | after 2 hours |
| 5 | none; the delivery is dead |
At-least-once delivery
Delivery is at-least-once: in rare cases (for example a crash between your
2xx response and our bookkeeping) the same delivery is sent again. Retries carry the same
X-TBM-Delivery-Id and a byte-identical body (the payload is frozen when the
event is queued, so the signature stays valid), which makes deduplication by delivery id
safe and simple.
Receiver checklist
- Verify the signature before parsing the body.
- Store
X-TBM-Delivery-Idand skip ids you have already processed. - Return 2xx fast and process asynchronously; slow handlers risk the 10 second timeout and needless retries.
- Return 2xx for event types you do not handle, so future event types never pile up as failures.