Webhooks
Overview
Integrators can be notified of completed async and registered queries via webhooks, once the feature has been configured for their subscription. This removes the need to poll Halo Cloud to check for new results.
Webhooks are available for:
- Async queries
- Registered queries
For security reasons, the webhook payload is intentionally minimal and does not include the query result. To retrieve results, use the appropriate API endpoint with the queryId provided in the payload.
How It Works
When an async or registered query completes successfully, a webhook notification is sent to the integrator's configured URL:
- The query completion event is published to an internal message queue.
- The webhook processor picks up the event, constructs the payload, signs it with HMAC-SHA256, and sends it as an HTTPS POST to the integrator's URL.
- If delivery fails, the system retries automatically using exponential backoff.
Webhook Payload
All webhooks are sent as an HTTPS POST with a Content-Type: application/json header.
Payload Fields
| Field | Type | Description |
|---|---|---|
id |
string |
UUID — the unique ID of this webhook notification. |
occurredAt |
string |
ISO 8601 timestamp — when the query completed and the webhook was queued. |
siteId |
string |
UUID — the Halo site the query was sent to. |
queryId |
string |
UUID — the ID of the completed query. |
webhookSource |
string |
The source query type: "async" or "registered". |
Sample Payload
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"occurredAt": "2026-03-05T14:30:00.0000000+00:00",
"siteId": "12345678-abcd-ef01-2345-6789abcdef01",
"queryId": "abcdef01-2345-6789-abcd-ef0123456789",
"webhookSource": "async"
}
Note: The payload does not include the query result. To retrieve the result, use the appropriate API endpoint with the
queryId.
Webhook Headers
Each webhook request includes the following custom headers:
| Header | Description |
|---|---|
X-Halo-Id |
The webhook's unique ID. Same as the id field in the payload. |
X-Halo-Timestamp |
ISO 8601 timestamp of when the webhook was sent. Used for replay prevention and HMAC verification. |
X-Halo-Signature-256 |
HMAC-SHA256 signature for verifying payload integrity and authenticity. |
HMAC Signature Verification
Each webhook includes an HMAC-SHA256 signature in the X-Halo-Signature-256 header. This allows integrators to verify that the webhook originated from Halo and has not been tampered with.
Using the signature is optional, but it is included with every webhook. A unique secret is provisioned per integrator when webhooks are configured.
Signature Construction
The signature is computed as follows:
- Construct the signed message by concatenating the raw JSON request body, a literal
.(period), and theX-Halo-Timestampheader value:
For example, given the sample payload above and a timestamp of 2026-03-05T14:30:01.1234567+00:00:
{"id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","occurredAt":"2026-03-05T14:30:00+00:00","siteId":"12345678-abcd-ef01-2345-6789abcdef01","queryId":"abcdef01-2345-6789-abcd-ef0123456789","webhookSource":"async"}.2026-03-05T14:30:01.1234567+00:00
-
Compute the HMAC-SHA256 of the signed message using your integrator-specific secret (provided during webhook configuration) as the key. Both the message and key are UTF-8 encoded.
-
The resulting signature is a lowercase hexadecimal string (64 characters).
Verification Steps
To verify an incoming webhook:
- Extract the
X-Halo-Signature-256header value from the request. - Read the raw request body as a UTF-8 string (do not re-serialize or reformat it).
- Extract the
X-Halo-Timestampheader value. - Construct the signed message:
<raw_body>.<X-Halo-Timestamp>. - Compute HMAC-SHA256 using your secret and convert the result to a lowercase hex string.
- Compare your computed signature with the received
X-Halo-Signature-256value using a constant-time comparison to prevent timing attacks. - If the signatures do not match, reject the request with 401 Unauthorized.
Replay Prevention
The X-Halo-Timestamp header is included in the signed message, binding the signature to a specific point in time. To guard against replay attacks, you should:
- Parse the
X-Halo-Timestampvalue as a timezone-aware datetime (e.g.DateTimeOffsetin .NET). - Reject requests where the timestamp is more than 5 minutes old (or a tolerance appropriate for your use case).
Example: Verifying in C#
using System.Security.Cryptography;
using System.Text;
public static bool VerifyWebhookSignature(string requestBody, string timestamp,
string receivedSignature, string secret)
{
var signedMessage = $"{requestBody}.{timestamp}";
var messageBytes = Encoding.UTF8.GetBytes(signedMessage);
var secretBytes = Encoding.UTF8.GetBytes(secret);
using var hmac = new HMACSHA256(secretBytes);
var hashBytes = hmac.ComputeHash(messageBytes);
var expectedSignature = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
return CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expectedSignature),
Convert.FromHexString(receivedSignature));
}
Example: Verifying in Python
import hmac
import hashlib
def verify_webhook_signature(request_body: str, timestamp: str,
received_signature: str, secret: str) -> bool:
signed_message = f"{request_body}.{timestamp}"
expected_signature = hmac.new(
secret.encode("utf-8"),
signed_message.encode("utf-8"),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, received_signature)
Example: Verifying in Node.js
const crypto = require("crypto");
function verifyWebhookSignature(requestBody, timestamp, receivedSignature, secret) {
const signedMessage = `${requestBody}.${timestamp}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedMessage, "utf8")
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(receivedSignature, "hex")
);
}
URL Configuration
To configure webhook notifications for your subscription, contact Support. You will need to provide the HTTPS URL you want webhooks sent to.
Separate URLs can be configured for async and registered webhooks. If only one URL is provided, it will be used for both.
Your current webhook URL configuration can be viewed in the Halo Portal:
| Production | Stage |
|---|---|
| manage.haloconnect.io | manage.stage.haloconnect.io |
URL Template Variables
The following optional template variables can be used in your webhook URL:
| Variable | Replaced With |
|---|---|
{siteId} |
The site ID from the query (UUID). |
{queryId} |
The query ID from the query (UUID). |
{source} |
The source of the query: Registered or Async. |
These values are replaced when the webhook is sent. The same values are also included in the payload body.
Example: If your configured URL is:
For a query with site ID 12345678-abcd-ef01-2345-6789abcdef01 and source async, the webhook will be sent to:
Warning
URL template variable replacement is case-sensitive. The {source} variable is replaced with the PascalCase enum name (Registered or Async), not the lowercase value used in the JSON payload (registered or async).
Retry Behaviour
Webhooks automatically retry up to 10 times to ensure reliable delivery, using a decorrelated jitter exponential backoff strategy:
- First retry: approximately 5 seconds after the initial attempt.
- Maximum delay between retries: 90 minutes.
- Total retry window: all retries are spread across approximately 90 minutes from the initial attempt.
- Strategy: decorrelated jitter backoff, which adds randomisation to prevent thundering herd problems.
If all 10 retries are exhausted, the webhook is permanently failed and no further delivery attempts are made.
HTTP Timeout
Each webhook delivery attempt has a 5-second timeout. If the integrator's endpoint does not respond within 5 seconds, the attempt is treated as a transient failure and retried.
Integration Guide
Respond Immediately
- Return 200 OK within a few seconds. Our per-attempt timeout is 5 seconds.
- If we do not receive a 2xx response, we retry with exponential backoff over approximately 90 minutes (up to 10 retries).
- Avoid performing business logic, database writes, or external calls before responding.
Offload to a Durable Queue
- Push the raw payload onto a durable queue (for example Azure Service Bus, RabbitMQ, or Amazon SQS).
- Return 200 OK immediately after queuing.
- Process the event asynchronously with a background worker.
Implement Idempotency
- We may deliver the same event more than once due to timeouts or network issues.
- Each payload includes a unique
idfield. Store processed IDs and skip duplicates. - Your processing logic should be safe to execute multiple times for the same event.
Validate and Secure
- Verify the HMAC signature in the
X-Halo-Signature-256header (see HMAC Signature Verification). - Reject invalid signatures with 401 Unauthorized.
- Use HTTPS only. Webhook URLs must use the
https://scheme.
Return Appropriate Status Codes
| Status Code | Behaviour |
|---|---|
| 200–299 | Success — no retry. |
| 400 | Permanent failure — no retry. |
| 401 | Permanent failure — no retry. |
| 403 | Permanent failure — no retry. |
| 404 | Permanent failure — no retry. |
| 408 (Timeout) | Transient — we will retry. |
| 429 (Too Many Requests) | Transient — we will retry. |
| 5xx | Transient — we will retry. |
Tip
- Avoid returning 5xx, 408, or 429 unless you genuinely want us to retry delivery.
- Retries are intended for transient infrastructure issues (such as temporary unavailability), not business logic failures.
- Business logic issues should return 200 and be handled on your side.