Webhook Security

Every webhook delivery from Scope includes a cryptographic signature so you can verify it originated from us and hasn't been tampered with.

📥 Looking for inbound ATS webhooks?

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.

Headers

Each webhook POST request includes two security headers:

HeaderDescription
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.

Endpoint Requirements

Verification Algorithm

  1. Read x-scope-timestamp and x-scope-signature from the request headers.
  2. Construct the signed content: {timestamp}.{raw_body}
  3. Compute an HMAC-SHA256 using your webhook signing secret (whsec_...) and the signed content.
  4. Compare the computed hex digest to the x-scope-signature header using a timing-safe comparison.
  5. Optionally reject requests older than 5 minutes to prevent replay attacks.

Code Examples

Node.js
Python
Go
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)
}

Webhook Payload Format

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",
    ...
  }
}

Available Events

EventTrigger
candidate.createdA new candidate is added to any project.
candidate.scoredAI analysis completes for a candidate.
candidate.updatedCandidate data changes (status, enrichment, etc.).
feedback.createdA 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.

Retry Policy

Testing Locally

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

API Reference · Developer Settings · Support