Skip to content

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.

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.

Terminal window
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 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.

jtWMS sends each event as a POST with a JSON body:

POST /jtwms HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: jtWMS-Webhooks/1.0
X-Jtwms-Event-Type: jtWMS.Catalog.Events.V1.ProductStatusChanged
X-Jtwms-Delivery-Id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
X-Jtwms-Timestamp: 1792396800
X-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.
  • Answer with any 2xx status 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 3xx is a failure. Subscribe the final URL.
  • Acknowledge first, then process. Put the event on your own queue and answer 2xx at once, rather than doing slow work before you answer.

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 ) )
  • secret is the whole secret, whsec_ prefix included, as UTF-8 bytes.
  • timestamp is the X-Jtwms-Timestamp header, exactly as received.
  • body is 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:

  1. Compute the expected signature, and compare it with X-Jtwms-Signature in constant time.
  2. Check that X-Jtwms-Timestamp is 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.
  3. If either check fails, answer 401 and ignore the delivery.

Each sample below does both checks, with a tolerance of 300 seconds.

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();
});
import hashlib
import hmac
import 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 "", 204

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.

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.

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.

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.

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.

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.

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.