Webhooks
A webhook subscription tells jtWMS to send an HTTPS POST to your endpoint whenever an event you
chose happens in your tenant: a product is created, a facility changes, a storage node is deleted.
Each delivery is signed, so you can check that it came from jtWMS and wasn’t changed on the way.
Every endpoint on this page needs the webhooks.subscriptions.manage permission, with a user token
or an API key whose scope includes it. The Tenant Admin and Warehouse Manager roles hold it. Anyone
else gets 403. See Permissions and scopes.
Subscriptions
Section titled “Subscriptions”| Endpoint | Does |
|---|---|
POST /api/v1/webhook-subscriptions |
Creates a subscription. Returns 201 with the signing secret. |
GET /api/v1/webhook-subscriptions |
Lists subscriptions, paged, optionally filtered by status. |
GET /api/v1/webhook-subscriptions/{id} |
Reads a subscription. |
PUT /api/v1/webhook-subscriptions/{id} |
Replaces its url, eventTypes and description. |
POST /api/v1/webhook-subscriptions/{id}/deactivate |
Stops deliveries. 409 if already inactive. |
POST /api/v1/webhook-subscriptions/{id}/activate |
Resumes deliveries. 409 if already active. |
POST /api/v1/webhook-subscriptions/{id}/rotate-secret |
Replaces the signing secret. Returns the new secret. |
DELETE /api/v1/webhook-subscriptions/{id} |
Deletes the subscription. Returns 204. It can’t be undone. |
GET /api/v1/webhook-subscriptions/{id}/deliveries |
Lists its deliveries, paged, optionally filtered by status. |
POST /api/v1/webhook-deliveries/{id}/redeliver |
Queues a dead-lettered delivery again. Returns the delivery. |
An unknown ID, another tenant’s subscription or delivery, or a deleted subscription is 404. When
two writes to one subscription race (a deactivate and a delete, or two secret rotations), the one
that saves second gets 409 with errorCode concurrency_conflict and changes nothing: read the
subscription again and retry. See Concurrency and deletes.
Create a subscription
Section titled “Create a subscription”curl -X POST "https://<your-jtwms-host>/api/v1/webhook-subscriptions" \ -H "X-Api-Key: <api-key>" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/jtwms", "eventTypes": [ "jtWMS.Catalog.Events.*", "jtWMS.WarehouseConfig.Events.V1.FacilityCreated" ], "description": "ERP product and facility sync" }'| Field | Rules |
|---|---|
url |
Required. An absolute https URL, up to 2,048 characters, with no user name, password or fragment. Its host must be a public IP address or a fully qualified public domain name: localhost, loopback, private and other non-public IP addresses, names ending in .local, .internal, .lan and similar, and single-label names are refused. |
eventTypes |
Required, 1 to 100 entries. Each is an exact event type, such as jtWMS.Catalog.Events.V1.ProductCreated, or a domain wildcard (see below). An unknown name is a 400 keyed by its zero-based index, such as EventTypes[1]. |
description |
Optional, up to 500 characters. |
Event types are matched ignoring case. jtWMS stores them in their canonical spelling and removes duplicates. It stores the URL in a canonical form too (lowercase host, international names in Punycode, no default port), and the response shows that form.
The host name isn’t looked up when you save the subscription. jtWMS looks it up again on every delivery, and never connects to a non-public address (see Dead letters).
A domain wildcard subscribes to every event of one domain, including events added to that domain later:
| Wildcard | Events |
|---|---|
jtWMS.WarehouseConfig.Events.* |
Facility, zone, storage, location and equipment events |
jtWMS.Catalog.Events.* |
Product events |
The response is the subscription, including its full signing secret:
{ "id": "1e7c2b4a-9d3f-4c5e-8a6b-0f1d2e3c4b5a", "url": "https://hooks.example.com/jtwms", "eventTypes": [ "jtWMS.Catalog.Events.*", "jtWMS.WarehouseConfig.Events.V1.FacilityCreated" ], "description": "ERP product and facility sync", "status": "Active", "secret": "whsec_<43 characters>", "secretLastFour": "Xy9Q", "consecutiveDeadLetters": 0, "deactivationReason": null, "deactivatedAt": null, "createdAt": "2026-10-19T08:00:00+00:00", "updatedAt": "2026-10-19T08:00:00+00:00"}A subscription receives only the events that happen while it is active. Events that happen while it is inactive are not delivered later.
The signing secret
Section titled “The signing secret”The secret is whsec_ followed by 43 URL-safe characters. It is returned only by the create and
rotate-secret responses; every other read shows just secretLastFour. Store it in a secret store
as soon as you receive it. If you lose it, rotate it.
Rotating replaces the secret immediately, with no overlap: from then on, every delivery,
including retries of earlier events, is signed with the new secret. Update your receiver right
after rotating. Until you do, your receiver still checks with the old secret, so the deliveries
fail verification and it answers 401. They are retried, so none is lost if your receiver is
updated within the retry window.
Deliveries
Section titled “Deliveries”jtWMS sends each event as a POST with a JSON body:
POST /jtwms HTTP/1.1Host: hooks.example.comContent-Type: application/jsonUser-Agent: jtWMS-Webhooks/1.0X-Jtwms-Event-Type: jtWMS.Catalog.Events.V1.ProductStatusChangedX-Jtwms-Delivery-Id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5dX-Jtwms-Timestamp: 1792396800X-Jtwms-Signature: sha256=5d0f3c...e81a
{"id":"3f2504e0-4f89-11d3-9a0c-0305e82c3301","type":"jtWMS.Catalog.Events.V1.ProductStatusChanged","occurredAt":"2026-10-19T08:00:00+00:00","tenantId":"0b6f1d2e-7c1a-4b8e-9a51-2f6c3d4e5a6b","data":{"ProductId":"8c1f4b9e-2d3a-4f5b-9c6d-7e8f9a0b1c2d","Sku":"PAL-0042","Name":"Pallet wrap","Status":"Inactive","PreviousStatus":"Active","NewStatus":"Inactive","ChangedAt":"2026-10-19T08:00:00+00:00"}}The body is UTF-8 JSON. The data above is shortened. Events carry the full state of the record at
the time of the event, not just the fields that changed.
| Body member | Meaning |
|---|---|
id |
The event’s ID. The same event delivered to two subscriptions has the same id. |
type |
The event type, as in X-Jtwms-Event-Type. |
occurredAt |
When the event happened, in UTC. |
tenantId |
Your tenant’s ID. |
data |
The event’s payload, exactly as jtWMS published it. Its field names are in PascalCase (ProductId, NewStatus), unlike the camelCase of the REST API. |
| Header | Meaning |
|---|---|
X-Jtwms-Event-Type |
The event type. |
X-Jtwms-Delivery-Id |
The delivery’s ID: one event to one subscription. The same on every retry and redelivery. |
X-Jtwms-Timestamp |
When this attempt was signed, in Unix seconds. New on every attempt. |
X-Jtwms-Signature |
sha256= followed by the signature, in lowercase hex. |
Responding
Section titled “Responding”- Answer with any
2xxstatus within 10 seconds to acknowledge the delivery. jtWMS ignores the response body. - Any other status, a timeout or a connection error counts as a failure, and the delivery is retried.
- Redirects are not followed: a
3xxis a failure. Subscribe the final URL. - Acknowledge first, then process. Put the event on your own queue and answer
2xxat once, rather than doing slow work before you answer.
Verifying signatures
Section titled “Verifying signatures”The signature is an HMAC-SHA256 of the timestamp, a full stop and the raw body:
signature = "sha256=" + lowercase_hex( HMAC_SHA256( key = secret, message = timestamp + "." + body ) )secretis the whole secret,whsec_prefix included, as UTF-8 bytes.timestampis theX-Jtwms-Timestampheader, exactly as received.bodyis the raw request body, byte for byte. Verify it before parsing it: re-serializing parsed JSON changes the bytes, and the signature no longer matches.
To verify a delivery:
- Compute the expected signature, and compare it with
X-Jtwms-Signaturein constant time. - Check that
X-Jtwms-Timestampis recent. jtWMS doesn’t enforce a time limit; that check is yours, and it stops an attacker replaying a delivery they captured. Five minutes is a good limit. Keep your server’s clock synchronised. - If either check fails, answer
401and ignore the delivery.
Each sample below does both checks, with a tolerance of 300 seconds.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from 'node:crypto';
/** * Checks a jtWMS webhook delivery's signature and timestamp. * * @param {string} secret The subscription's signing secret, `whsec_` prefix included. * @param {string | undefined} timestamp The `X-Jtwms-Timestamp` header. * @param {Buffer | string} rawBody The request body exactly as received. * @param {string | undefined} signature The `X-Jtwms-Signature` header. * @param {{ toleranceSeconds?: number, nowSeconds?: number }} [options] * @returns {boolean} Whether the delivery is authentic and recent. */export function verifyJtwmsSignature(secret, timestamp, rawBody, signature, options = {}) { const { toleranceSeconds = 300, nowSeconds = Math.floor(Date.now() / 1000) } = options;
if (typeof signature !== 'string' || typeof timestamp !== 'string' || !/^[0-9]+$/.test(timestamp)) { return false; } if (Math.abs(nowSeconds - Number(timestamp)) > toleranceSeconds) { return false; }
const expected = Buffer.from( 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'), ); const received = Buffer.from(signature); return expected.length === received.length && timingSafeEqual(expected, received);}With Express, read the body raw on the webhook route:
import express from 'express';import { verifyJtwmsSignature } from './verify-jtwms-signature.js';
const app = express();
app.post('/jtwms', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyJtwmsSignature( process.env.JTWMS_WEBHOOK_SECRET, req.get('X-Jtwms-Timestamp'), req.body, // a Buffer, because of express.raw() req.get('X-Jtwms-Signature'), ); if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8')); // Queue the event for processing, keyed by req.get('X-Jtwms-Delivery-Id'). res.sendStatus(204);});using System.Globalization;using System.Linq;using System.Security.Cryptography;using System.Text;
public static class JtwmsWebhookSignature{ /// <summary>Checks a jtWMS webhook delivery's signature and timestamp.</summary> public static bool Verify( string secret, string? timestamp, byte[] rawBody, string? signature, TimeSpan? tolerance = null, DateTimeOffset? now = null) { if (string.IsNullOrEmpty(signature) || !long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)) { return false; }
var age = Math.Abs((now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds() - seconds); if (age > (tolerance ?? TimeSpan.FromMinutes(5)).TotalSeconds) return false;
var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray(); var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed); var expected = Encoding.ASCII.GetBytes("sha256=" + Convert.ToHexString(mac).ToLowerInvariant()); return CryptographicOperations.FixedTimeEquals(expected, Encoding.UTF8.GetBytes(signature)); }}In an ASP.NET Core minimal API, read the body as bytes before anything parses it:
app.MapPost("/jtwms", async (HttpRequest request, IConfiguration config) =>{ using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); var body = buffer.ToArray();
var ok = JtwmsWebhookSignature.Verify( config["Jtwms:WebhookSecret"]!, request.Headers["X-Jtwms-Timestamp"].ToString(), body, request.Headers["X-Jtwms-Signature"].ToString()); if (!ok) return Results.Unauthorized();
// Queue the event for processing, keyed by the X-Jtwms-Delivery-Id header. return Results.NoContent();});Python
Section titled “Python”import hashlibimport hmacimport time
def verify_jtwms_signature( secret: str, timestamp: str | None, raw_body: bytes, signature: str | None, tolerance_seconds: int = 300, now: float | None = None,) -> bool: """Checks a jtWMS webhook delivery's signature and timestamp.""" if not signature or not timestamp or not (timestamp.isascii() and timestamp.isdigit()): return False current = time.time() if now is None else now if abs(int(current) - int(timestamp)) > tolerance_seconds: return False
mac = hmac.new(secret.encode("utf-8"), timestamp.encode("ascii") + b"." + raw_body, hashlib.sha256) expected = "sha256=" + mac.hexdigest() return hmac.compare_digest(expected.encode("ascii"), signature.encode("utf-8"))With Flask, request.get_data() returns the raw body:
import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/jtwms")def jtwms_webhook(): ok = verify_jtwms_signature( os.environ["JTWMS_WEBHOOK_SECRET"], request.headers.get("X-Jtwms-Timestamp"), request.get_data(), request.headers.get("X-Jtwms-Signature"), ) if not ok: return "", 401
# Queue the event for processing, keyed by the X-Jtwms-Delivery-Id header. return "", 204At-least-once delivery
Section titled “At-least-once delivery”jtWMS records one delivery for each event and matching subscription, and retries it until your endpoint acknowledges it. A delivery can still arrive more than once: for example, when your endpoint processed it but its answer was lost or came after the 10-second timeout.
- Deduplicate by
X-Jtwms-Delivery-Id. Store the IDs you have processed and skip repeats. - Don’t rely on order. Deliveries are sent in parallel and retried independently, so they can
arrive out of order. Use
occurredAt, and the full state each event carries, to decide which is newer.
Retries
Section titled “Retries”A failed delivery is retried with exponential backoff, up to 8 attempts in all. The delays after each failed attempt are about:
| After attempt | Next attempt in about |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 8 minutes |
| 4 | 32 minutes |
| 5 | 2 hours 8 minutes |
| 6 | 8 hours 32 minutes |
| 7 | 12 hours |
Each delay varies randomly by up to 20% either way, so retries from many deliveries don’t arrive together. No delay is longer than 12 hours. All 8 attempts span roughly a day.
If your endpoint answers 429 or 503 with a Retry-After header, jtWMS waits at least that long
before the next attempt, up to 12 hours. Retry-After never shortens the delay.
Dead letters
Section titled “Dead letters”A delivery whose last attempt fails is dead-lettered: jtWMS stops trying it. A delivery is also dead-lettered at once, without further attempts, when its host name resolves only to loopback, private or other non-public addresses. Deliveries still waiting when you deactivate or delete their subscription are dead-lettered without being sent.
Automatic deactivation
Section titled “Automatic deactivation”When 5 deliveries in a row to a subscription are dead-lettered, jtWMS deactivates the
subscription. It then has status Inactive, with deactivationReason and deactivatedAt set,
and receives no more events until you activate it again. Any successful delivery resets the count,
which is shown as consecutiveDeadLetters. Deliveries dead-lettered because you deactivated the
subscription don’t count.
Once your endpoint is fixed, call POST /api/v1/webhook-subscriptions/{id}/activate. That clears
the count and the reason. Redeliver its dead-lettered deliveries to catch up. Events that happened
while the subscription was inactive were never recorded for it, so they can’t be redelivered.
Redelivery
Section titled “Redelivery”POST /api/v1/webhook-deliveries/{id}/redeliver queues a dead-lettered delivery again. It is sent
straight away, with a fresh budget of 8 attempts, and keeps its X-Jtwms-Delivery-Id and attempt
history. The delivery must be dead-lettered and its subscription active; otherwise the request gets
409 Conflict. If two redeliveries of the same delivery race, the second gets 409 with
errorCode concurrency_conflict.
Delivery history
Section titled “Delivery history”GET /api/v1/webhook-subscriptions/{id}/deliveries lists a subscription’s deliveries, newest first.
Filter by status: Pending, Sending, Delivered or DeadLettered. Sort by createdAt or
nextAttemptAt (the default is -createdAt). Each delivery shows its eventId, eventType,
attemptCount, nextAttemptAt, lastAttemptAt, completedAt, the latest responseStatusCode,
durationMs and error, and an attempts list with every attempt, oldest first. The request body
isn’t included; it is the same on every attempt.
Event types
Section titled “Event types”The events you can subscribe to, by domain. Every name starts with jtWMS.<Domain>.Events.V1., for
example jtWMS.Catalog.Events.V1.ProductCreated. The events reference describes
each event’s data payload, property by property.
| Domain | Events |
|---|---|
| WarehouseConfig: facilities | FacilityCreated, FacilityUpdated, FacilityStatusChanged, CodePatternChanged |
| WarehouseConfig: zones | ZoneProfileCreated, ZoneProfileUpdated, ZoneProfileStatusChanged, ZoneProfileActivated, ZoneProfileDeactivated, ZoneCreated, ZoneUpdated, ZoneStatusChanged, ZoneLocationsAssigned, ZoneLocationUnassigned |
| WarehouseConfig: storage and locations | StorageNodeCreated, StorageNodeUpdated, StorageNodeStatusChanged, StorageNodeCascadeDeleted, LocationCreated, LocationUpdated, LocationStatusChanged |
| WarehouseConfig: equipment | EquipmentNodeCreated, EquipmentNodeUpdated, EquipmentNodeStatusChanged, EquipmentNodeConfigUpdated, EquipmentNodePlcAddressChanged, EquipmentEdgeCreated, EquipmentEdgeBulkCreated, EquipmentEdgeStatusChanged |
| Catalog | ProductCreated, ProductUpdated, ProductStatusChanged |
| Inventory | InventoryReceived, InventoryAdjusted, InventoryMoved, InventoryHeld, InventoryReleased, InventoryAllocated, InventoryPicked, InventoryAllocationReleased, InventoryIssued, LpnCreated, LpnStatusChanged |
There is no separate “deleted” event. Deleting a product or a facility, for example, publishes its
...StatusChanged event with NewStatus PendingDelete. A storage node cascade delete publishes
one StorageNodeCascadeDeleted event listing every node and location it deleted.