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.
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" }'
import requests, uuid resp = requests.post( "https://api.placement.solutions/v1/webhooks", headers={ "Authorization": "Bearer pk_live_REPLACE_ME", "Idempotency-Key": str(uuid.uuid4()), }, json={ "url": "https://example.com/hooks/placement", "events": ["lateral_move.detected", "partner_departure.detected"], "description": "Production CI feed", }, timeout=10, ) endpoint = resp.json() # SAVE endpoint["secret"] now. It will not be shown again. secret = endpoint["secret"]
import { randomUUID } from "node:crypto"; const resp = await fetch("https://api.placement.solutions/v1/webhooks", { method: "POST", headers: { "Authorization": "Bearer pk_live_REPLACE_ME", "Content-Type": "application/json", "Idempotency-Key": randomUUID(), }, body: JSON.stringify({ url: "https://example.com/hooks/placement", events: ["lateral_move.detected", "partner_departure.detected"], description: "Production CI feed", }), }); const endpoint = await resp.json(); // Save endpoint.secret now; we cannot show it again.
The response shape:
{
"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.
| Event | When it fires | Payload 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 |
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:
{
"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. Currentlyv1.data.object— the resource itself. Identical shape to the correspondingGETresponse.
Example: lateral_move.detected
{
"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:
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.
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 #
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 #
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 #
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.
| Attempt | Delay from previous | Cumulative elapsed |
|---|---|---|
| 1 | 0 (immediate) | 0s |
| 2 | 5 minutes | 5m |
| 3 | 30 minutes | 35m |
| 4 | 2 hours | 2h 35m |
| 5 | 5 hours | 7h 35m |
| 6 | 10 hours | 17h 35m |
| 7–16 | 12 hours each | up 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.
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.
curl -X POST https://api.placement.solutions/v1/webhooks/we_01J9X5AC2P3K8Q4N7M6Y3R2T1V/rotate-secret \ -H "Authorization: Bearer rk_live_REPLACE_ME"
{
"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.
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.
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.
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.