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.

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