Every webhook delivery from Scope includes a cryptographic signature so you can verify it originated from us and hasn't been tampered with.
This page covers outbound webhooks — events Scope sends to your endpoints (e.g., when a candidate is scored or pushed to ATS).
For inbound two-way ATS sync (Scope receiving stage updates from Greenhouse, Lever, or Ashby), configure the integration in your hiring-org dashboard via Settings → Integrations → Enable two-way sync. Scope generates a unique HMAC secret per integration and provides setup instructions for each ATS. See the ATS Integrations section in the Agent Skills Hub for the full architecture.
Each webhook POST request includes two security headers:
| Header | Description |
|---|---|
x-scope-signature |
HMAC-SHA256 hex digest of timestamp.body using your webhook secret. |
x-scope-timestamp |
Unix timestamp (seconds) when the payload was signed. |
https://.x-scope-timestamp and x-scope-signature from the request headers.{timestamp}.{raw_body}whsec_...) and the signed content.x-scope-signature header using a timing-safe comparison.const crypto = require('crypto');
function verifyWebhook(req, secret) {
const signature = req.headers['x-scope-signature'];
const timestamp = req.headers['x-scope-timestamp'];
if (!signature || !timestamp) return false;
// Reject requests older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (Math.abs(age) > 300) return false;
const signedContent = `${timestamp}.${req.rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express example
app.post('/webhooks/scope', express.raw({ type: 'application/json' }), (req, res) => {
req.rawBody = req.body.toString();
if (!verifyWebhook(req, process.env.SCOPE_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.rawBody);
console.log('Verified event:', event.event, event.data);
res.status(200).send('ok');
});
import hmac, hashlib, time, json
def verify_webhook(headers: dict, body: bytes, secret: str) -> bool:
signature = headers.get('x-scope-signature', '')
timestamp = headers.get('x-scope-timestamp', '')
if not signature or not timestamp:
return False
# Reject requests older than 5 minutes
age = abs(int(time.time()) - int(timestamp))
if age > 300:
return False
signed_content = f"{timestamp}.{body.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
signed_content.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhooks/scope', methods=['POST'])
def handle_webhook():
if not verify_webhook(request.headers, request.data, SCOPE_WEBHOOK_SECRET):
abort(401)
event = json.loads(request.data)
print(f"Verified event: {event['event']}", event['data'])
return 'ok', 200
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"time"
)
func verifyWebhook(r *http.Request, secret string) ([]byte, bool) {
sig := r.Header.Get("X-Scope-Signature")
ts := r.Header.Get("X-Scope-Timestamp")
if sig == "" || ts == "" {
return nil, false
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-tsInt)) > 300 {
return nil, false
}
body, _ := io.ReadAll(r.Body)
signedContent := fmt.Sprintf("%s.%s", ts, string(body))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedContent))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
return nil, false
}
return body, true
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, ok := verifyWebhook(r, "whsec_your_secret_here")
if !ok {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
fmt.Printf("Verified: %s\n", string(body))
w.WriteHeader(http.StatusOK)
}
All webhook payloads follow this envelope structure (timestamp is a Unix seconds string):
{
"event": "candidate.scored",
"timestamp": "1739323200",
"organization_id": "org_uuid",
"data": {
"candidate_id": "cand_uuid",
"project_id": "proj_uuid",
"candidate_score": 8.2,
"recommendation": "Interested",
...
}
}
| Event | Trigger |
|---|---|
candidate.created | A new candidate is added to any project. |
candidate.scored | AI analysis completes for a candidate. |
candidate.updated | Candidate data changes (status, enrichment, etc.). |
feedback.created | A team member gives feedback on a candidate. |
Important: Always verify signatures before processing webhook payloads. Never trust the payload contents without verification. Store your webhook secret securely - treat it like a password.
2xx response within 10 seconds.Use a tunneling service to test webhooks against localhost:
# Using ngrok
ngrok http 3000
# Then register the ngrok URL in Scope Developer Settings:
# https://abc123.ngrok.io/webhooks/scope