Webhooks
Get an HTTP call the moment something happens in your pipeline: a candidate applies, an interview is scored, an offer is accepted. How to add an endpoint, what Stori sends, and how to verify every request.
Overview
A webhook is a URL you own that Stori calls with a JSON body whenever a chosen event happens in your workspace. Use it to keep another system in step with Stori without polling: update a spreadsheet when a candidate moves stage, post to a channel when an interview is scored, or start your own onboarding when an offer is accepted.
- Endpoints are managed under Settings > Integrations > Webhooks. Admins only.
- Each endpoint has its own signing secret, shown once when you create it. Store it where your server can read it.
- You choose which events an endpoint receives. Nothing is sent for events you have not subscribed to.
Events
Every delivery carries the event name in the X-Stori-Event header and in the body. The catalogue:
Candidates
candidate.applied,candidate.stage_changed,candidate.profile_updatedcandidate.hired,candidate.rejected,candidate.unrejectedcandidate.merged,candidate.deleted,candidate.anonymized
Jobs and applications
job.created,job.updated,job.published,job.unpublished,job.closed,job.deletedapplication.created,application.updated,application.deleted
Interviews and offers
interview.scheduled,interview.rescheduled,interview.cancelled,interview.completed,interview.no_showoffer.created,offer.approved,offer.sent,offer.accepted,offer.declined,offer.rescinded
Only Stori can send these
interview.transcript_ready,interview.scored,interview.red_flags_detectedcandidate.traits_computed,candidate.fit_score_changed,candidate.benchmarked
Pipeline, search and compliance
pipeline.bulk_action_completed,search.saved_query_matchedaudit.event
What a delivery looks like
Stori sends an HTTP POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
X-Stori-Event |
The event name, for example candidate.stage_changed |
X-Stori-Delivery-Id |
Unique per delivery. The same id is reused on every retry of that delivery, so you can ignore duplicates |
X-Stori-Timestamp |
Unix seconds when the request was signed |
X-Stori-Signature |
t=<unix-seconds>,v1=<hex>, see below |
X-Stori-Action-Id |
Present when the event was caused by a connected AI assistant acting for a person |
User-Agent |
Stori-Webhooks/1.0 |
Respond with any 2xx status within a few seconds. Do your real work after replying, not before.
Retries
A delivery that does not get a 2xx is retried up to eight times over about three and a half days, with the wait growing between attempts: one minute, then five, thirty, two hours, six, twelve, and a day. After the last failure the delivery is marked failed and shows on the endpoint's page, where you can replay it by hand. Too many consecutive failures disable the endpoint so a dead URL does not keep a queue growing; fix the URL and switch it back on from the endpoint's page.
Verifying the signature
Verify every request before trusting it. The signature is an HMAC-SHA256 over the timestamp and the exact raw body, joined by a full stop, using your endpoint's secret:
v1 = HMAC-SHA256( secret, "<X-Stori-Timestamp>.<raw request body>" )
Compare it to the v1 value in X-Stori-Signature using a constant-time comparison, and reject requests whose timestamp is more than five minutes old so a captured request cannot be replayed. Always sign the raw bytes you received, not a re-serialised copy: any change in whitespace or key order produces a different hash.
Read the raw body before your framework parses it as JSON. Express, Next.js and Flask all parse by default, and a parsed-then-stringified body will not match.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyStoriWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const [tPart, v1Part] = (headers["x-stori-signature"] || "").split(",");
const timestamp = tPart?.slice(2);
const received = v1Part?.slice(3);
if (!timestamp || !received) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hmac, hashlib, time
def verify_stori_webhook(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
header = headers.get("X-Stori-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, received = parts.get("t"), parts.get("v1")
if not timestamp or not received:
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
Go
func verifyStoriWebhook(rawBody []byte, signatureHeader, secret string, tolerance time.Duration) bool {
var ts, received string
for _, part := range strings.Split(signatureHeader, ",") {
if strings.HasPrefix(part, "t=") {
ts = part[2:]
} else if strings.HasPrefix(part, "v1=") {
received = part[3:]
}
}
if ts == "" || received == "" {
return false
}
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(unix, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(received))
}
Rotate the secret from the endpoint's page if it is ever exposed. Deliveries already queued keep the secret they were signed with, so rotating does not break retries in flight.
Testing an endpoint
Use the Send test event button on the endpoint's page to receive a sample delivery with a real signature. The delivery log shows the status code your server returned, the response body, and how long it took, for every attempt.