/webhooks ยท HMAC-SHA256 signing · 16-attempt retry curve · 24-hour dual-active rotation · 30-day delivery log API reference →
Webhooks

Push delivery for hiring events.

Subscribe an HTTPS endpoint to one or more event types. We sign every delivery with HMAC-SHA256 over a timestamped envelope, retry on failure for three days, and rotate secrets with a 24-hour dual-active window so you never have to coordinate downtime. Five event types are live; new ones ship behind the same contract.

5 event types live 90-second p99 delivery SLA Webhook tier & up

Why webhooks #

Polling GET /v1/lateral-moves?since=... works for low-frequency dashboards. It is a poor fit for anything time-sensitive: a 5-minute poll interval averages 2.5 minutes of latency, a 1-minute interval burns most of your monthly call quota, and either way you lose ordering across pages on busy hours.

Webhooks invert the relationship. We tell you when something happens. End-to-end latency from index decision to POST on your endpoint is under 90 seconds at p99. Your monthly read quota is untouched; webhook deliveries are not metered.

Use the API to fetch on-demand and the webhook to be notified. Most production integrations do both.

Endpoint registration #

Register an HTTPS endpoint with POST /v1/webhooks. The response includes a one-time-visible signing secret. Save it on first read; we cannot show it to you again. To rotate, use POST /v1/webhooks/{id}/rotate-secret — the new secret is returned, and both old and new sign in parallel for 24 hours.

POST /v1/webhooks
curl -X POST https://api.placement.solutions/v1/webhooks \
  -H "Authorization: Bearer pk_live_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://example.com/hooks/placement",
    "events": ["lateral_move.detected", "partner_departure.detected"],
    "description": "Production CI feed"
  }'

The response shape:

201 Created
{
  "id": "we_01J9X5AC2P3K8Q4N7M6Y3R2T1V",
  "url": "https://example.com/hooks/placement",
  "events": ["lateral_move.detected", "partner_departure.detected"],
  "description": "Production CI feed",
  "secret": "whsec_live_8a3f1b9c0e7d4256af912e3c6d7f8b1a4c5e9d0f",
  "status": "active",
  "created_at": "2026-05-06T14:22:31Z"
}

Event types #

Five types are live. Each carries a fully-formed object that mirrors what the corresponding GET endpoint returns.

EventWhen it firesPayload object
job.created A new job posting is indexed and passes deduplication. Fires once per posting. Job
job.updated An indexed posting is edited (title, location, salary band, or description body changes). Fires once per change. Job
firm.headcount_changed Any firm in a dossier crosses a 1% threshold in total headcount or any practice band. Fires at most once per firm per day. FirmHeadcountDelta
lateral_move.detected A lateral hire reaches confidence ≥ 0.7 in the index. Re-fires once if confidence is later upgraded above 0.85. LateralMove
partner_departure.detected A partner exit reaches signal_count ≥ 3 and confidence ≥ 0.7. Includes the signal stack on the payload. PartnerDeparture
One subscription, many events. A single endpoint can subscribe to any subset of the event types. We deliver one HTTP POST per event; we do not batch. If you want batching, ack the delivery quickly and aggregate on your side.

Payload structure #

Every payload uses the same envelope:

application/json
{
  "id": "evt_01J9X6BD3R5L9Q5P8N7Z4S3U2W",
  "type": "lateral_move.detected",
  "created": "2026-05-06T14:22:35Z",
  "api_version": "v1",
  "data": {
    "object": { ... }
  }
}

The fields:

  • id — unique per event. Use this for deduplication on your side.
  • type — one of the five event names above.
  • created — RFC 3339 timestamp.
  • api_version — the major version that emitted the event. Currently v1.
  • data.object — the resource itself. Identical shape to the corresponding GET response.

Example: lateral_move.detected

POST /hooks/placement
{
  "id": "evt_01J9X6BD3R5L9Q5P8N7Z4S3U2W",
  "type": "lateral_move.detected",
  "created": "2026-05-06T14:22:35Z",
  "api_version": "v1",
  "data": {
    "object": {
      "id": "lm_01J9X5AC2P3K8Q4N7M6Y3R2T1V",
      "from_firm_id": "frm_kirkland",
      "to_firm_id": "frm_paul_weiss",
      "practice": "private-equity",
      "market": "new-york",
      "seniority": "partner",
      "confidence": 0.82,
      "detected_at": "2026-05-06T14:21:54Z",
      "signals": [
        { "type": "firm_announcement", "weight": 0.45 },
        { "type": "trade_press", "weight": 0.20 },
        { "type": "bar_admission_change", "weight": 0.17 }
      ]
    }
  }
}

Signature verification #

Every delivery includes a Placement-Signature header:

request header
Placement-Signature: t=1746542555,v1=8a3f1b9c0e7d4256af912e3c6d7f8b1a4c5e9d0f7e2a3b4c5d6e7f8a9b0c1d2e

The signature is computed as HMAC-SHA256 of the string {ts}.{raw_body}, hex-encoded, where ts is the Unix timestamp from the header and raw_body is the byte-exact request body. Verify the signature before parsing the JSON. A failing verification means the request did not come from us.

5-minute replay window. Reject any delivery where abs(now - t) > 300. We do not retry signed bodies older than 5 minutes; anything outside the window is either a clock-skew bug on your side or a replay attempt.

Python #

verify.py
import hmac, hashlib, time, os
from flask import Flask, request, abort

WEBHOOK_SECRET = os.environ["PLACEMENT_WEBHOOK_SECRET"]
TOLERANCE = 300  # 5 minutes

app = Flask(__name__)

def verify(raw_body: bytes, header: str, secret: str) -> None:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts["t"])
    sig = parts["v1"]
    if abs(time.time() - ts) > TOLERANCE:
        raise ValueError("replay")
    expected = hmac.new(
        secret.encode(),
        f"{ts}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise ValueError("bad signature")

@app.post("/hooks/placement")
def handle():
    try:
        verify(request.get_data(), request.headers["Placement-Signature"], WEBHOOK_SECRET)
    except Exception:
        abort(400)
    event = request.get_json()
    # enqueue event["id"]; respond fast
    return "", 200

Node #

verify.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const WEBHOOK_SECRET = process.env.PLACEMENT_WEBHOOK_SECRET;
const TOLERANCE = 300;
const app = express();

// IMPORTANT: capture the raw body BEFORE JSON parsing.
app.use("/hooks/placement", express.raw({ type: "application/json" }));

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const ts = Number(parts.t);
  const sig = parts.v1;
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE) throw new Error("replay");
  const expected = createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(`${ts}.`), rawBody]))
    .digest("hex");
  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    throw new Error("bad signature");
  }
}

app.post("/hooks/placement", (req, res) => {
  try { verify(req.body, req.headers["placement-signature"], WEBHOOK_SECRET); }
  catch { return res.status(400).end(); }
  const event = JSON.parse(req.body.toString("utf8"));
  // enqueue event.id; ack fast
  res.status(200).end();
});

Ruby #

verify.rb
require "sinatra"
require "openssl"

WEBHOOK_SECRET = ENV.fetch("PLACEMENT_WEBHOOK_SECRET")
TOLERANCE = 300

def verify!(raw_body, header, secret)
  parts = header.split(",").map { |p| p.split("=", 2) }.to_h
  ts  = parts["t"].to_i
  sig = parts["v1"]
  raise "replay" if (Time.now.to_i - ts).abs > TOLERANCE
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
  raise "bad signature" unless Rack::Utils.secure_compare(expected, sig)
end

post "/hooks/placement" do
  raw = request.body.read
  begin
    verify!(raw, request.env["HTTP_PLACEMENT_SIGNATURE"], WEBHOOK_SECRET)
  rescue
    halt 400
  end
  event = JSON.parse(raw)
  # enqueue event["id"]
  200
end

Retry curve #

If your endpoint does not return a 2xx within 10 seconds, we retry. Sixteen attempts over three days. Connection errors, timeouts, and any HTTP status outside 200–299 trigger a retry. After 16 attempts or 72 hours (whichever first), the delivery is marked terminally failed and is queryable via the deliveries log.

AttemptDelay from previousCumulative elapsed
10 (immediate)0s
25 minutes5m
330 minutes35m
42 hours2h 35m
55 hours7h 35m
610 hours17h 35m
7–1612 hours eachup to 72h

Each attempt is logged with timestamp, response status, response-body prefix (first 4 KB), and latency. Logs are retained for 30 days and are queryable via GET /v1/webhooks/{id}/deliveries.

Auto-disable #

An endpoint that fails for three consecutive days (no 2xx in 72 hours of attempts across any event type) is automatically disabled. Disabled endpoints stop receiving deliveries until you re-enable them with PATCH /v1/webhooks/{id}.

The owner of the API key that created the endpoint is emailed at first auto-disable. The status field on the endpoint object flips from active to disabled; the rest of the configuration is preserved.

Why we disable instead of pausing. If your endpoint has been down for three days, almost always something has changed on your side that you do not want fixed silently. We require an explicit re-enable so you have a chance to backfill from the deliveries log on your terms.

Secret rotation #

To rotate, call POST /v1/webhooks/{id}/rotate-secret. The response returns the new secret once. For 24 hours after rotation, both the old and new secrets sign every delivery in parallel; your verifier only needs to accept either one. After 24 hours the old secret is retired and only the new one signs.

POST /v1/webhooks/{id}/rotate-secret
curl -X POST https://api.placement.solutions/v1/webhooks/we_01J9X5AC2P3K8Q4N7M6Y3R2T1V/rotate-secret \
  -H "Authorization: Bearer rk_live_REPLACE_ME"
200 OK
{
  "id": "we_01J9X5AC2P3K8Q4N7M6Y3R2T1V",
  "secret": "whsec_live_NEW_SECRET_VALUE_HERE",
  "previous_secret_expires_at": "2026-05-07T14:22:31Z"
}

Recommended verifier pattern: keep both secrets in your env and accept whichever validates. After 24 hours, drop the old one.

dual-active verifier (Python)
def verify_either(raw, header, current, previous=None):
    for secret in (current, previous):
        if not secret:
            continue
        try:
            verify(raw, header, secret)
            return  # success
        except ValueError:
            continue
    raise ValueError("signature did not match either secret")

Manual replay #

The deliveries log retains every attempted delivery for 30 days. Any attempt — success, failure, terminal failure — can be replayed with a fresh signature and timestamp.

POST /v1/webhooks/{id}/deliveries/{delivery_id}/replay
curl -X POST https://api.placement.solutions/v1/webhooks/we_01J.../deliveries/dl_01K.../replay \
  -H "Authorization: Bearer pk_live_REPLACE_ME"

Replays receive a fresh Placement-Signature header with a current timestamp; the body and event ID are unchanged. Idempotency on your side is up to you — the event id field will match the original delivery, so dedupe on it.

Best practices #

Verify before parsing

Run signature verification on the raw request body before any JSON parse. A failed verifier should return 400 and never touch your business logic. The Node example above uses express.raw() instead of express.json() for exactly this reason.

Ack fast, work async

Return 200 within 10 seconds. Move the actual work onto a queue. A handler that does database writes inline against a busy primary will start timing out under load and trigger spurious retries.

recommended pattern
def handle(req):
    verify(req.body, req.headers["Placement-Signature"], SECRET)
    event_id = json.loads(req.body)["id"]
    queue.enqueue("placement_event", event_id, req.body)
    return 200  # return immediately, work happens elsewhere

Idempotent handlers

Dedupe on event.id. We may legitimately deliver the same event twice (network race, your endpoint timing out after writing). The event id is unique and stable across retries.

Lock down the endpoint

  • HTTPS only. We refuse to register an HTTP endpoint at POST /v1/webhooks.
  • Restrict the path to webhook traffic. A leaked secret should not let an attacker write anywhere else.
  • If you have a WAF, allowlist our delivery IP range. The current range is published in your account dashboard and updated with 30 days of advance notice.

Handle replay errors

If you reject a delivery for being outside the 5-minute tolerance window, return 400 with body {"error":"replay"}. We retry the next attempt with a fresh timestamp anyway; this just keeps your logs clean.

Do not return 2xx on a failed verifier. A handler that ack’s a forged delivery as 200 will see attackers fan out fake events to your downstream queues. Always 400 on signature failure.