# Webhooks

A HeadHoncho webhook calls your address with each job or lead change you ask for, signed and retried.

Page: https://headhoncho.app/docs/api/webhooks | Part of the HeadHoncho API docs: https://headhoncho.app/docs/api | Machine-readable: https://headhoncho.app/docs/api/openapi.json | Everything in one file: https://headhoncho.app/docs/api/llms-full.txt

## 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](https://headhoncho.app/docs/api/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:

```json
{
  "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](https://headhoncho.app/docs/api/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](https://headhoncho.app/docs/api/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](https://headhoncho.app/docs/api/change_webhook) with `rotate_secret` to get a new one.

[Send webhook test](https://headhoncho.app/docs/api/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:

```bash
# 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:

```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:

```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](https://headhoncho.app/docs/api/list_webhook_deliveries), or on the Webhooks tab.
- [Redeliver webhook](https://headhoncho.app/docs/api/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](https://headhoncho.app/docs/api/change_webhook) and `paused` set 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 by `at`.

## 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](https://headhoncho.app/docs/api/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](https://headhoncho.app/docs/api/list_event_types) lists, and the same ones [List events](https://headhoncho.app/docs/api/list_events) returns.

| Kind | When it is sent | What `data` holds |
| --- | --- | --- |
| [`job.created`](https://headhoncho.app/docs/api/list_event_types) | A job or a lead was opened. | `status`, `job_type`, `quoted`, `scheduled_start`, `scheduled_end`, `source` |
| [`job.details_changed`](https://headhoncho.app/docs/api/list_event_types) | 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`](https://headhoncho.app/docs/api/list_event_types) | A job moved to another status. | `status`, `status_slug`, `previous_status` |
| [`job.assigned`](https://headhoncho.app/docs/api/list_event_types) | A technician or subcontractor was put on a job. | `technician_id`, `technician` |
| [`job.unassigned`](https://headhoncho.app/docs/api/list_event_types) | A technician or subcontractor was taken off a job. | `technician_id`, `technician` |
| [`job.archived`](https://headhoncho.app/docs/api/list_event_types) | A job was archived. | Nothing more |
| [`job.unarchived`](https://headhoncho.app/docs/api/list_event_types) | A job was taken out of the archive. | Nothing more |
| [`job.restored`](https://headhoncho.app/docs/api/list_event_types) | A job came back from Trash. | Nothing more |
| [`job_note.added`](https://headhoncho.app/docs/api/list_event_types) | A note was added to a job. Read the job's history for the words. | Nothing more |
| [`job_payment.recorded`](https://headhoncho.app/docs/api/list_event_types) | A payment was recorded on a job. Read the job's money for the amount. | Nothing more |

## The calls

- [Add webhook](https://headhoncho.app/docs/api/add_webhook), [Change webhook](https://headhoncho.app/docs/api/change_webhook) and [Remove webhook](https://headhoncho.app/docs/api/remove_webhook) manage an address.
- [List webhooks](https://headhoncho.app/docs/api/list_webhooks) and [Get webhook](https://headhoncho.app/docs/api/get_webhook) read them. A key sees the webhooks it made.
- [List webhook deliveries](https://headhoncho.app/docs/api/list_webhook_deliveries), [Redeliver webhook](https://headhoncho.app/docs/api/redeliver_webhook) and [Send webhook test](https://headhoncho.app/docs/api/send_webhook_test) show what was sent and send again.

Next: [errors](https://headhoncho.app/docs/api/errors) for what a refusal says, and [recipes](https://headhoncho.app/docs/api/recipes#zapier) for a Zapier trigger that uses a webhook.
