# Octrahire webhooks: events, signatures and verification

> Octrahire webhooks follow the Standard Webhooks spec: hiring events, payload format, HMAC-SHA256 signature headers, secret rotation and a verification example.

Source: https://octrahire.ai/developers/webhooks

Octrahire tells your systems when something happens in hiring, with signed messages that follow the Standard Webhooks specification. In beta.

## Status

- **Standard Webhooks delivery** (In beta): Signed with HMAC-SHA256, with secrets made by Octrahire and shown once.
- **Delivery log and replay** (In beta): See every delivery in settings and send one again.

Available: live in production today. In beta: deployed and usable today; it may still change. Coming soon: not usable yet.

## Add a webhook

With the webhooks:manage scope, or from your workspace settings:

Request:

```
curl -X POST https://api.octrahire.ai/api/v1/public/webhooks \
  -H "Authorization: Bearer ohk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hr.example.com/octrahire", "events": ["application.moved", "offer.accepted"]}'
```

Response (the secret is shown only once):

```
{"id": "whk_...", "url": "https://hr.example.com/octrahire",
 "events": ["application.moved", "offer.accepted"], "secret": "whsec_..."}
```

Use ["*"] for every event, and unsubscribe with DELETE /webhooks/{id}. The Zapier and Make apps, once published, will subscribe the same way.

## Events

| Event | When |
|---|---|
| `application.created` | A candidate applied or was added to a role |
| `application.moved` | An application moved to another stage |
| `application.rejected` | An application was rejected, with the reason category |
| `application.hired` | An application was marked hired |
| `interview.invited` | A candidate was invited to an AI interview |
| `interview.started` | A candidate started an AI interview |
| `interview.completed` | A candidate finished an AI interview; grading is under way |
| `interview.graded` | An AI interview report is ready |
| `interview.scheduled` | A human interview was scheduled or rescheduled |
| `scorecard.submitted` | A reviewer submitted a scorecard |
| `review.requested` | A candidate asked for a human review of an AI decision |
| `offer.sent` | An offer was sent to a candidate |
| `offer.accepted` | A candidate accepted an offer |
| `offer.declined` | A candidate declined an offer |
| `requisition.approved` | A requisition was approved |
| `role.opened` | A role was opened for applications |
| `role.closed` | A role was closed |
| `candidate.replied` | A candidate replied by WhatsApp, email or SMS |
| `candidate.erased` | A candidate's personal data was erased |

GET /events returns the current catalogue. application.stage is kept for receivers that already use it.

## What a delivery looks like

Headers:

```
webhook-id: msg_2Vb7...
webhook-timestamp: 1791885600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

Body:

```
{"type": "application.moved", "timestamp": "2026-10-13T10:00:00Z", "tenant": "acme",
 "data": {"application_id": "...", "role_id": "...", "from_stage": "in_review", "to_stage": "offer"}}
```

The signature is the base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{body}", keyed with the base64 part of your whsec_ secret. While a secret is being rotated, webhook-signature carries both signatures, separated by a space.

Reply with a 2xx status quickly, and do slow work after replying. Use webhook-id to ignore a message you have already handled.

## Verify the signature

Use the raw request body, exactly as received, before parsing it. With the Standard Webhooks library:

Python (pip install standardwebhooks):

```
from standardwebhooks.webhooks import Webhook

wh = Webhook("whsec_...")  # your endpoint's secret
payload = wh.verify(raw_body, request.headers)  # raises if the signature or timestamp is bad
```

Node.js, without a library:

```
import crypto from "node:crypto";

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigs = headers["webhook-signature"] || "";
  // Reject messages more than five minutes old or in the future.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
  return sigs.split(" ").some((s) => {
    const [version, sig] = s.split(",");
    const got = Buffer.from(sig || "", "base64");
    return version === "v1" && got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}
```

## Rotating a secret

POST /webhooks/{id}/rotate-secret returns a new secret. For a short while deliveries are signed with both the old and the new secret, so you can switch without missing a message.

Webhooks made before this format keep their old body and X-Octrahire-Signature header too, so existing receivers keep working.

[The public API](https://octrahire.ai/developers/api)
