Skip to content

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:

HeaderDescription
Content-Typeapplication/json
User-AgentChronosHub-Integration-Framework/1.0
X-Hub-Signature-256sha256= followed by the lowercase hex HMAC-SHA256 signature, see Verify the signature.
X-Event-TypeThe event type, for example manuscript.uploaded.
X-Event-IdThe event's unique id (a UUID). The same event keeps the same id on every delivery attempt.
X-TimestampUnix 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}
  • payload is 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_length is the length of that string in UTF-16 code units, which is what String.Length gives in C# and Java and string.length gives 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's len() counts code points instead, so the Python example below converts.
  • event_type, event_id and timestamp are the values of the X-Event-Type, X-Event-Id and X-Timestamp headers, 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=d7f344a01a7930d39fa511f3805711b4fb096785ef38bfd6742e357130a519d1

With 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 responseResult
2xxDelivered.
Anything else, a timeout or a network errorFailed 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 307 and 308 redirects, at most 5 hops, and only to HTTPS locations. A 301, 302 or 303 response 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, 302 or 303 can turn the POST into a GET and 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}.