Appearance
Receiving Webhooks ​
This page is the practical guide to building the endpoint that receives outbound events from ChronosHub: what arrives, how to verify that it really came from ChronosHub, and how to respond.
What arrives ​
ChronosHub sends an HTTP POST with a JSON body to your webhook URL. The request carries these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | ChronosHub-Integration-Framework/1.0 |
X-Hub-Signature-256 | sha256= followed by the lowercase hex HMAC-SHA256 signature, see Verify the signature. |
X-Event-Type | The event type, for example manuscript.uploaded. |
X-Event-Id | The event's unique id (a UUID). The same event keeps the same id on every delivery attempt. |
X-Timestamp | Unix time in seconds when this attempt was signed. It is set again on every attempt. |
The body is the event payload. Its shape is described on the event's page, for example Manuscript Uploaded.
Verify the signature ​
Verify every request before you act on it. The signature is an HMAC-SHA256 with your webhook secret as the key, over this exact string:
{payload_length}:{payload}|{event_type}|{event_id}|{timestamp}payloadis the raw request body, decoded as UTF-8. Do not parse and re-serialize the JSON first, because any change in whitespace or key order changes the signature.payload_lengthis the length of that string in UTF-16 code units, which is whatString.Lengthgives in C# and Java andstring.lengthgives in JavaScript. For text with only characters from the Basic Multilingual Plane it equals the number of characters. Characters outside it, such as emoji, count as two. Python'slen()counts code points instead, so the Python example below converts.event_type,event_idandtimestampare the values of theX-Event-Type,X-Event-IdandX-Timestampheaders, as received.- The message is encoded as UTF-8, and so is the secret.
- The result is the lowercase hex digest. The header is
sha256=<hex>. Compare the hex part, with a constant-time comparison, and reject the request when the prefix is missing or the values differ.
The samples below were checked against the same algorithm ChronosHub uses to sign. With the secret whsec_test_secret, event type manuscript.uploaded, event id d4e5f6a7-b8c9-0123-4567-89abcdef0123, timestamp 1740000000 and the body {"title":"Example Manuscript"}, the header is:
X-Hub-Signature-256: sha256=d7f344a01a7930d39fa511f3805711b4fb096785ef38bfd6742e357130a519d1With the body {"title":"Café 😀"} and the same other values it is sha256=ce41b60496c40bec54e793b643e20c53f031023c11921194e57c9f0d73a4eb5f. Use these two as test vectors for your own implementation.
C# ​
csharp
using System.Security.Cryptography;
using System.Text;
static bool VerifySignature(
string payload, string secret, string eventType, string eventId, string timestamp, string signatureHeader)
{
const string Prefix = "sha256=";
if (!signatureHeader.StartsWith(Prefix, StringComparison.Ordinal))
{
return false;
}
var message = $"{payload.Length}:{payload}|{eventType}|{eventId}|{timestamp}";
var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(message));
var expected = Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signatureHeader[Prefix.Length..]));
}Node.js ​
javascript
const crypto = require("node:crypto");
// rawBody is the request body decoded as UTF-8. JavaScript string length counts UTF-16 code units, as required.
function verifySignature(rawBody, secret, eventType, eventId, timestamp, signatureHeader) {
const prefix = "sha256=";
if (!signatureHeader.startsWith(prefix)) {
return false;
}
const message = `${rawBody.length}:${rawBody}|${eventType}|${eventId}|${timestamp}`;
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(message, "utf8").digest("hex"));
const received = Buffer.from(signatureHeader.slice(prefix.length));
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}In Express, read the raw body, for example with express.raw({ type: "application/json" }), and call rawBody.toString("utf8").
Python ​
python
import hashlib
import hmac
def verify_signature(raw_body: bytes, secret: str, event_type: str, event_id: str, timestamp: str, signature_header: str) -> bool:
prefix = "sha256="
if not signature_header.startswith(prefix):
return False
payload = raw_body.decode("utf-8")
# UTF-16 code units, not Python code points: they differ for characters such as emoji.
payload_length = len(payload.encode("utf-16-le")) // 2
message = f"{payload_length}:{payload}|{event_type}|{event_id}|{timestamp}"
expected = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header[len(prefix):])Java ​
java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
static boolean verifySignature(
String payload, String secret, String eventType, String eventId, String timestamp, String signatureHeader)
throws Exception {
String prefix = "sha256=";
if (!signatureHeader.startsWith(prefix)) {
return false;
}
// String.length() counts UTF-16 code units, as required.
String message = payload.length() + ":" + payload + "|" + eventType + "|" + eventId + "|" + timestamp;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signatureHeader.substring(prefix.length()).getBytes(StandardCharsets.UTF_8));
}Check the timestamp ​
The signature proves the request was made by someone who knows your secret. To also stop an old request from being replayed, compare X-Timestamp with your clock and reject requests that are far off, for example more than five minutes. This tolerance is our recommendation, not something ChronosHub enforces. Because the timestamp is set again on every attempt, a retry is never "too old".
Respond ​
Return a 2xx status (200 or 202) as soon as you have verified and safely stored the event, and do the heavy work afterwards. The response body is ignored. ChronosHub waits up to 30 seconds for the response; a slower answer counts as a failure.
| Your response | Result |
|---|---|
2xx | Delivered. |
| Anything else, a timeout or a network error | Failed attempt, retried. |
Answer 2xx only for requests with a valid signature. Answer 401 for an invalid signature, so a misconfigured secret shows up as failed deliveries instead of silently lost events.
Retries and idempotency ​
A failed delivery is retried up to 5 times, and an event that still fails is marked as failed (see Outbound Events). A retry can also arrive after your endpoint processed the event but answered too late. So your endpoint must be idempotent: use X-Event-Id as the idempotency key. Store the ids you have processed and, when an id comes again, answer 2xx without processing it a second time.
Redirects ​
Respond directly at the URL you registered. Do not rely on redirects.
- Sandbox partners: ChronosHub follows only
307and308redirects, at most 5 hops, and only to HTTPS locations. A301,302or303response fails the delivery. The signature does not cover the URL, so the same headers and body are sent on every hop. - Regular partners: nothing has been changed for you. The HTTP client follows redirects the way it always has, with its default behaviour, so a
301,302or303can turn thePOSTinto aGETand the event is then not received. Register the final URL instead.
The sandbox-only restrictions on webhook hosts are described in Sandbox.
Testing your endpoint ​
In the sandbox you can trigger an outbound event to your own webhook with POST /sandbox/outbound-events, or with the trigger_outbound_event MCP tool, and then check the delivery status with GET /sandbox/outbound-events/{id}.