What we send
A webhook is an address you give us once. After that, each time a job or a lead changes in a way you asked for, we send one signed POST to it, so Zapier, n8n or your own code never has to keep asking. It is the same change that List events would show you, pushed instead of polled, with the same facts and the same rules about who may see what: a webhook only ever carries what the key that made it can read.
Each request has one change in a JSON body:
{
"id": "88402",
"event": "job.status_changed",
"at": "2026-10-26T14:02:11Z",
"by": "Reporting laptop, for Ariel T.",
"job": {
"id": "job_1138",
"number": 1138,
"company_id": "cmp_14f5"
},
"data": {
"status": "Dispatched",
"status_slug": "dispatched",
"previous_status": "Scheduled"
}
}
id is the change's id. event is its kind. job says which job, and data holds a few plain facts about the change. Money amounts, customer details and message text are never in it: read the job for those with Get job. Changes start from the moment you add the webhook; the past is not sent.
The headers
| Header | What it holds |
|---|---|
Content-Type | application/json |
User-Agent | HeadHoncho-Webhooks/1 |
X-HeadHoncho-Event | The kind of change, such as job.status_changed. |
X-HeadHoncho-Event-Id | The id of this change. The same change always has the same id, on every retry and every resend. Drop a repeat by it. |
X-HeadHoncho-Timestamp | Seconds since 1970 when this try was signed. |
X-HeadHoncho-Signature | sha256= and the hex HMAC-SHA256 of the timestamp, a dot, and the raw body, made with your signing secret. |
Add a webhook
In the app, open Automations, then API keys, then Webhooks, and choose Add webhook. Or call Add webhook with a key that has do on Company settings and read on Jobs. Give it an address, the kinds of change you want, and, if you like, the companies. The answer carries the signing secret, whsec_..., shown once. Lose it and use Change webhook with rotate_secret to get a new one.
Send webhook test sends a signed test event named webhook.test to one webhook and says what your address answered, so you can check your code before a real change arrives. A test is not retried and costs no delivery credit.
Check the signature
Anyone can post to a public address, so check that a call really came from HeadHoncho before you act on it. The signature is sha256= and the hex HMAC-SHA256 of the timestamp header, a dot, and the raw body, made with your signing secret. Use the body as it arrived: parsing it and printing it again changes the bytes and breaks the match. Refuse a timestamp more than five minutes old, and compare in constant time.
With cURL and openssl. This is a worked example; the digest is the real one for this secret, timestamp and body:
# The secret is the one shown when you added the webhook.
SECRET="whsec_example_secret"
TIMESTAMP="1798380131" # the X-HeadHoncho-Timestamp header
BODY='{"id":"88402","event":"job.status_changed","at":"2026-10-26T14:02:11Z","by":"Reporting laptop, for Ariel T.","job":{"id":"job_1138","number":1138,"company_id":"cmp_14f5"},"data":{"status":"Dispatched","status_slug":"dispatched","previous_status":"Scheduled"}}' # the raw body, exactly as received
printf '%s.%s' "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -hex \
| sed 's/^.* //'
# 6f37ea5413a1815b0d707ddbe5145aa449e5480e6189470232694e1c2d1b307f
# Put sha256= in front and compare it with the X-HeadHoncho-Signature header.
In Python:
import hashlib
import hmac
import time
def is_from_headhoncho(secret: str, headers, raw_body: bytes) -> bool:
"""`raw_body` is the request body exactly as received, before any JSON parsing."""
stamp = headers["X-HeadHoncho-Timestamp"]
if abs(time.time() - int(stamp)) > 300: # older than five minutes: refuse
return False
signed = stamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-HeadHoncho-Signature"])
In JavaScript:
import crypto from "node:crypto";
// `rawBody` is the request body exactly as received, a Buffer or a string,
// before any JSON parsing. In Express use express.raw({ type: "application/json" }).
export function isFromHeadHoncho(secret, headers, rawBody) {
const stamp = headers["x-headhoncho-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(stamp)) > 300) return false; // older than five minutes
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${stamp}.`)
.update(rawBody)
.digest("hex");
const given = Buffer.from(headers["x-headhoncho-signature"] || "");
const wanted = Buffer.from(expected);
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}
What to answer
Answer with any 2xx status within eight seconds, and do the work after answering. Anything else counts as a failure and is tried again: a 4xx or 5xx, no answer, a slow answer, an invalid certificate, and a redirect, which we do not follow.
Retries, pausing and resending
- A failed change is tried again after 30 seconds, then after a minute, then twice as long each time, up to four hours between tries, for 24 hours. After that it is marked failed and left alone.
- Every try is kept with its time, the answer code and how long it took. Read them with List webhook deliveries, or on the Webhooks tab.
- Redeliver webhook sends one delivery again within a minute.
- A webhook that has failed for two hours, with at least ten failed tries, is paused, and the person who made it is told in the app and on their phone. Fix the address, then resume it with Change webhook and
pausedset to false. What it missed is sent, in order. - Delivery is at least once. A change can arrive twice, and two changes can arrive out of order. Drop repeats by
X-HeadHoncho-Event-Id, and order byat.
Credits and plans
Webhooks come with every plan, Free included, like keys. Each change delivered costs one credit, however many tries it takes; a failed try costs nothing, and a change is never charged twice. The calls that manage webhooks cost a credit each, like any call. At zero credits we send nothing: the webhook is paused with the reason no_credits, the changes wait, and it carries on by itself when credits are back. The balance is in Get credits.
Which addresses we call
- Only
https, on port 443 or a port from 1024 up, with no user name or password in the address. - Only public addresses. We refuse a name that points at a private network, the machine itself, a link-local or cloud metadata address, or the 100.64.0.0/10 range. We check when you add the webhook and again before every send, after looking the name up, because a name can be re-pointed.
- We connect to the address we checked, verify the certificate against the name, follow no redirects, and read at most 16 KB of your answer.
Kinds of change
Choose from these when you add a webhook. They are the same kinds the List event types lists, and the same ones List events returns.
| Kind | When it is sent | What data holds |
|---|---|---|
| `job.created` | A job or a lead was opened. | status, job_type, quoted, scheduled_start, scheduled_end, source |
| `job.details_changed` | A job's facts changed: the customer, the address, the schedule, the price, the type or the notes. | job_type, quoted, scheduled_start, scheduled_end |
| `job.status_changed` | A job moved to another status. | status, status_slug, previous_status |
| `job.assigned` | A technician or subcontractor was put on a job. | technician_id, technician |
| `job.unassigned` | A technician or subcontractor was taken off a job. | technician_id, technician |
| `job.archived` | A job was archived. | Nothing more |
| `job.unarchived` | A job was taken out of the archive. | Nothing more |
| `job.restored` | A job came back from Trash. | Nothing more |
| `job_note.added` | A note was added to a job. Read the job's history for the words. | Nothing more |
| `job_payment.recorded` | A payment was recorded on a job. Read the job's money for the amount. | Nothing more |
The calls
- Add webhook, Change webhook and Remove webhook manage an address.
- List webhooks and Get webhook read them. A key sees the webhooks it made.
- List webhook deliveries, Redeliver webhook and Send webhook test show what was sent and send again.
Next: errors for what a refusal says, and recipes for a Zapier trigger that uses a webhook.