Webhooks
Halo posts to your address when something happens. Every event and its payload, how to check the signature, how retries work and how to drop repeats.
A webhook is a POST from Halo to an address of yours, sent when something happens in your organization. The event goes out only if the change it describes was saved, and nobody waits for it.
Webhooks come from Halo CRM today. Reports, extracts and deliveries don't send any yet.
Add an endpoint
Owners and Admins add one in Halo CRM, under Connections: a name, an address, and which events it wants. API keys can't manage endpoints.
The address must be public. Use
https://for anything that matters. Halo won't send to a private network, or to an address with a username or password in it.Events are exact names, or a prefix ending in
*, such ascrm.*orcrm.listing.*. Left empty, the endpoint gets every event.The secret starts with
whs_and shows once, when the endpoint is made. Keep it with your other secrets. To replace it, choose New secret: the old one stops at once, and the new one shows once. Until your system has it, it can't check what Halo sends.
Send a test posts a webhook.test event straight away. What was sent shows the last 50 deliveries with each answer, and Send again on those that failed.
What Halo sends
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: Halo-Webhooks/1
X-Halo-Event: crm.listing.published
X-Halo-WebhookDelivery: 0d8a1f52-6f6e-4a53-9f5e-2b1f4b6f7c11
X-Halo-Timestamp: 1793901600
X-Halo-Signature: sha256=5b1f…
{
"createdAt": "2026-11-05T18:00:00Z",
"data": { "id": "6f1c…", "name": "The Harbor Kitchen", "status": "published" },
"event": "crm.listing.published"
}| Header | Means |
|---|---|
X-Halo-Event | The event, the same as event in the body |
X-Halo-WebhookDelivery | This delivery's id. It stays the same on every retry, so it's what you dedupe on |
X-Halo-Timestamp | When this try was sent, in Unix seconds. Each try has its own |
X-Halo-Signature | sha256= and the hex HMAC-SHA256 of the timestamp, a dot and the body |
Every body has the same three fields: event, createdAt (when the change happened, in UTC) and data, which depends on the event. Halo sends it compact, on one line; it's spread out here to read.
Check the signature
Do this before you trust anything in the request. Compute the signature from the raw bytes of the body, before any JSON parser sees them. The order of the keys and the spacing are Halo's, and parsing and writing the body again gives a different string.
import { createHmac, timingSafeEqual } from "node:crypto";
const FIVE_MINUTES = 5 * 60;
export function isFromHalo(headers, rawBody, secret) {
const timestamp = headers["x-halo-timestamp"];
const sent = headers["x-halo-signature"] ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= FIVE_MINUTES)) return false;
const expected =
"sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(sent);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib, hmac, time
def is_from_halo(headers, raw_body: bytes, secret: str) -> bool:
timestamp = headers.get("x-halo-timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(headers.get("x-halo-signature", ""), expected)The whole secret is the key, whs_ included. The timestamp is signed too, so a request someone recorded can't be sent to you again later: refuse old ones. Header names are case-insensitive, and some servers show X-Halo-WebhookDelivery as X-Halo-Webhookdelivery.
Answer quickly
Answer with any 2xx, within 10 seconds. Anything else counts as a failure, and that includes a redirect that doesn't reach a 2xx in three hops. Halo reads the first 64 KB of your answer and does nothing with it.
If your work takes longer, put the event on your own queue and answer first.
Retries
A failed delivery is tried up to six times, over about 31 hours:
| Try | Waits after the one before |
|---|---|
| 1 | Sent straight away |
| 2 | 1 minute |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
| 6 | 24 hours |
After the sixth, the delivery is marked failed and Halo stops trying. After a long outage, choose Send again on a failed delivery under What was sent: it goes out with the same X-Halo-WebhookDelivery, so your dedupe still holds. Only the last 50 are listed, so to catch up on more, read what you missed through the Developer API.
Nothing goes out for an endpoint someone switched off, or an organization that's suspended or deleted, however long ago the delivery was queued. It's marked failed with the reason, and isn't sent later. Removing an endpoint removes its record too. Otherwise the record of deliveries is kept for 30 days.
Drop repeats, and don't count on order
Delivery is at least once. If Halo doesn't hear your answer in time, it tries again, even if you did the work. Keep each X-Halo-WebhookDelivery you've finished for at least two days, and answer 200 without doing anything when one comes back.
Order isn't guaranteed either. A retry can arrive after a newer event about the same listing. Each listing and business carries updatedAt, so keep the newest, or read the current record when the order matters.
Events
| Event | Sent when | data |
|---|---|---|
crm.listing.created | A listing is added, by a person, an import or a trusted source | A listing |
crm.listing.updated | A listing changes, or comes back from the Trash | A listing. Back from the Trash, id and restored: true alone |
crm.listing.published | A listing goes to published, one at a time or several at once, or is added as published, as well as its created | A listing |
crm.listing.archived | A listing is archived, one at a time or several at once, or is added as archived, as well as its created | A listing |
crm.listing.deleted | A listing goes to the Trash | id, name, slug |
crm.business.created | A business is added | A business |
crm.business.updated | A business changes | A business |
crm.business.deleted | A business goes to the Trash | id, name |
crm.proposal.opened | Something new waits in the Inbox | id, origin, title, targetType, targetId |
crm.proposal.applied | Someone applies what was in the Inbox, or a trusted source's change applies itself | id, targetType, targetId, title |
crm.signup.received | A business signs up on the extranet | proposalId, business |
crm.subscription.changed | A business's plan changes or ends | businessId, status, and plan unless it ended |
webhook.test | You press Send a test | message |
Listing events
{
"id": "6f1c2a90-…",
"kind": "restaurant",
"name": "The Harbor Kitchen",
"slug": "the-harbor-kitchen",
"status": "published",
"summary": "Oysters by the water.",
"website": "https://example.com",
"phone": "+1 541 555 0100",
"address": "1 Pier St",
"city": "Newport",
"postalCode": "97365",
"region": "central-coast",
"lat": 44.63,
"lng": -124.05,
"startsAt": null,
"endsAt": null,
"tags": ["seafood"],
"data": { "hours": "Daily, 11 to 9" },
"businessId": "0b7e…",
"updatedAt": "2026-11-05T18:00:00Z"
}data holds the fields the listing's kind adds, which your organization defines. startsAt and endsAt are set for happenings such as events, and lat, lng and businessId can be null.
Business events
{
"id": "0b7e4c11-…",
"name": "Harbor Hospitality",
"status": "active",
"website": "https://example.com",
"city": "Newport",
"postalCode": "97365",
"region": "central-coast",
"tags": ["member"],
"updatedAt": "2026-11-05T18:00:00Z"
}Webhooks never carry the people at a business: no contact's name, email or phone. A business's own email, phone and street address stay out too, as they do from the Developer API, since they're often how you reach a person. A listing's phone and address are there, because its page gives them to visitors.
New fields can appear in data at any time, and a new event never reaches an endpoint with an exact list until you add it. So ignore what you don't recognize.