> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hotfix.jobs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Product noun is jobs.
> MCP at https://rest.hotfix.jobs/mcp is the hiring contract for agents. Clients sign in with Hotfix; API keys are for HTTP only.
> Do not invent unpublished REST or hiring stats. Webhooks are set up in the app; there is no REST route or MCP tool to manage them.
> Do not mention Greenhouse, Ashby, Quick Apply, or claim.
> Creating a draft, offer, or booking link does not send email.

# Webhooks

> Get hiring events at your own URL as they happen. Each delivery is a signed JSON POST.

A webhook sends hiring events to a URL you own: a new application, a booked interview, an accepted offer. Use it to post to Slack, update a sheet, or start your own workflow. Nothing to poll.

## Add a webhook

Owners and admins manage webhooks in the app under **Settings** > **API** > **Webhooks**. Members can see them and their delivery log, but can't change them.

1. Click **New webhook**.
2. Enter an `https` URL.
3. Keep **All events**, or pick the events this URL should get.
4. Click **Add webhook** and copy the signing secret. It is shown once.

A company can have up to 10 webhooks. Each one has its own URL, its own events, and its own secret, so you can send offers to one system and everything to another.

Open a webhook to change its URL or events. That keeps the same secret. **Rotate secret** issues a new one and shows it once; deliveries sign with it right away. **Send test event** posts a signed `ping` to the URL and shows the response.

## Events

Pick **All events** to also get events added later.

| Event | Sent when |
| - | - |
| `application.confirmed` | A new application lands in the inbox (after the candidate confirms, if your apply form asks them to). |
| `interview.booked` | An interview is booked, by the candidate on their link or by your team. |
| `interview.rescheduled` | Your team moves an interview. |
| `interview.moved` | The candidate moves an interview on their link. |
| `interview.canceled` | An interview is canceled. |
| `feedback.submitted` | Someone on an interview adds their feedback (not when they edit it). |
| `feedback.completed` | Everyone on an interview has added their feedback. Sent once per interview. |
| `offer.created` | Your team creates an offer. |
| `offer.accepted` | The candidate accepts an offer. |
| `offer.declined` | The candidate declines an offer. |
| `job.listed` | A tech job goes onto Hotfix search, once Hotfix has read it. Not sent again after an edit, since a listed job stays on search. |
| `job.closed` | A job closes. |
| `ping` | You click **Send test event**. |

## The request

Every delivery is a `POST` with a JSON body:

```json theme={null}
{
  "event": "application.confirmed",
  "data": {
    "application_id": "7cbfa03e-70c6-4463-8e10-376e2221ec51",
    "job_id": "1f0c2a9e-3b6d-4c8a-9a51-6a2f0c9d1e77",
    "job_title": "iOS Engineer",
    "company_name": "Acme",
    "company_slug": "acme",
    "applicant_name": "Ada Lovelace",
    "applicant_email": "ada@example.com",
    "custom_fields": [
      {
        "id": "4b8e…",
        "key": "salary_expectation",
        "name": "Salary expectation",
        "type": "currency",
        "value": "140000",
        "display": "$140,000"
      }
    ]
  }
}
```

Events about an application carry the fields above, with `custom_fields` in the same shape as [applications](/applications). Some add a little more:

| Event | Also in `data` |
| - | - |
| `interview.booked` | `interview_id`, and `starts_at` when the candidate picked the time |
| `interview.rescheduled`, `interview.canceled` | `interview_id` |
| `interview.moved` | `interview_id`, `starts_at`, `previous_starts_at` |
| `feedback.submitted` | `interview_id`, `stage`, `submitted_by` (a user id). No verdict. |
| `feedback.completed` | `interview_id`, `stage`, and `feedback` (see below) |
| `offer.created` | `pay`, `start_date` |
| `offer.accepted` | `offer_id`, `accepted_as` (the name they typed) |
| `offer.declined` | `offer_id` |

`feedback.submitted` never carries a verdict, since someone else on the panel may not have added theirs yet. `feedback.completed` comes once nothing is hidden from anyone on the panel, so `feedback` lists each person's `user_id`, `name`, `verdict` (`strong_no`, `no`, `yes`, or `strong_yes`), and `ratings` on the job's interview criteria (`id`, `name`, `rating`, which is null for a skipped criterion). Notes are left out; read them with [list\_feedback](/reference/list-feedback).

Neither is sent while your company has [interview feedback turned off](/help/feedback#turn-feedback-off).

```json theme={null}
{
  "event": "feedback.completed",
  "data": {
    "interview_id": "b1c2d3e4-f5a6-7890-1234-56789abcdef0",
    "stage": "technical",
    "feedback": [
      {
        "user_id": "user_2abc",
        "name": "Alex Park",
        "verdict": "strong_yes",
        "ratings": [
          {
            "id": "c_1a2b3c4d",
            "name": "System design",
            "rating": "strong_yes"
          }
        ]
      },
      {
        "user_id": "user_9xyz",
        "name": "Priya Shah",
        "verdict": "no",
        "ratings": [
          { "id": "c_1a2b3c4d", "name": "System design", "rating": "yes" }
        ]
      }
    ],
    "application_id": "7cbfa03e-70c6-4463-8e10-376e2221ec51",
    "job_id": "1f0c2a9e-3b6d-4c8a-9a51-6a2f0c9d1e77",
    "job_title": "iOS Engineer",
    "company_name": "Acme",
    "company_slug": "acme",
    "applicant_name": "Ada Lovelace",
    "applicant_email": "ada@example.com",
    "custom_fields": []
  }
}
```

Job events send only `job_id` and `job_title`. A `ping` sends `webhook_id` and `company_id`.

Each request has these headers:

| Header | Value |
| - | - |
| `X-Hotfix-Event` | The event name. |
| `X-Hotfix-Delivery` | The delivery id. The same on every retry of that delivery. |
| `X-Hotfix-Timestamp` | Unix seconds when this attempt was signed. |
| `X-Hotfix-Signature` | `v1=` and a hex HMAC-SHA256. |

## Verify the signature

The signature is an HMAC-SHA256 of the timestamp, a period, and the raw request body, keyed with the webhook's secret. Compute it over the bytes you received, before parsing the JSON, and compare in constant time. Reject a timestamp more than five minutes from your clock, so an old request can't be replayed.

<CodeGroup>
  ```javascript Node theme={null}
  import crypto from "node:crypto";

  export function verify(rawBody, headers, secret) {
    const timestamp = headers["x-hotfix-timestamp"];
    const signature = headers["x-hotfix-signature"] ?? "";
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
    const expected =
      "v1=" +
      crypto
        .createHmac("sha256", secret)
        .update(`${timestamp}.`)
        .update(rawBody)
        .digest("hex");
    return (
      expected.length === signature.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
    );
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify(raw_body: bytes, headers, secret: str) -> bool:
      timestamp = headers.get("X-Hotfix-Timestamp", "")
      signature = headers.get("X-Hotfix-Signature", "")
      if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
          return False
      signed = timestamp.encode() + b"." + raw_body
      expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

## Respond and retries

Answer with any `2xx` within 10 seconds. Do slow work after you respond.

Anything else counts as a failure. Hotfix tries a delivery up to 5 times, waiting 1 minute, 5 minutes, 30 minutes, then 2 hours between tries. After the fifth failure the delivery shows as failed. A test event is not retried.

Because of retries, you can get the same delivery more than once. Use `X-Hotfix-Delivery` to drop repeats. Deliveries can also arrive out of order, so read `starts_at` and the current state instead of assuming the order you receive them in.

## Delivery log

Open a webhook to see its last deliveries: the event, whether it was delivered, the response code, how many tries it took, and the error when it failed.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.