Now in open beta: AI interviews in English and Arabic. Join the beta

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

EventWhen
application.createdA candidate applied or was added to a role
application.movedAn application moved to another stage
application.rejectedAn application was rejected, with the reason category
application.hiredAn application was marked hired
interview.invitedA candidate was invited to an AI interview
interview.startedA candidate started an AI interview
interview.completedA candidate finished an AI interview; grading is under way
interview.gradedAn AI interview report is ready
interview.scheduledA human interview was scheduled or rescheduled
scorecard.submittedA reviewer submitted a scorecard
review.requestedA candidate asked for a human review of an AI decision
offer.sentAn offer was sent to a candidate
offer.acceptedA candidate accepted an offer
offer.declinedA candidate declined an offer
requisition.approvedA requisition was approved
role.openedA role was opened for applications
role.closedA role was closed
candidate.repliedA candidate replied by WhatsApp, email or SMS
candidate.erasedA 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 →

See Octrahire with your own roles

A 30-minute walkthrough with your jobs or your programmes.