There’s a really big button in your SETTINGS page marked “TEST WEBHOOK.” You don’t need to read this page. Just add your URL and click that a few times.
How Webhooks Work
When a candidate finishes their assessment, we POST a JSON payload to your webhook URL. This is basic HTTP stuff that’s been working since 1999.
Example HTTP Request
Here’s exactly what we send to your endpoint:
POST /webhooks/errorgolf HTTP/1.1
Host: your-api.com
Content-Type: application/json
X-ErrorGolf-Signature: sha256=a8b1c9d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
User-Agent: ErrorGolf-Webhook-WTFMasheen/0.01
Content-Length: 1247
{
"name": "Sarah Chen",
"email": "sarah.chen@example.com",
"message": "Hope this doesn't break anything!",
"holes": [1, 42, 78],
"shots": {
"1": {
"code": "Balding + divorce = Corvette. Hair plugs + therapy = survival.",
"language": "human"
},
"42": {
"code": "P(tenure) = merit^2 / (politics * committee_size)",
"language": "math"
},
"78": {
"code": "const crisis = desperation => desperation > 0.8 ? 'intervention' : 'concern';",
"language": "javascript"
}
},
"scorecard": {
"holes": {
"1": {
"first": {
"personality": "jaded_dev",
"quality": {"score": 85, "commentary": "Brutally efficient"},
"creativity": {"score": 90, "commentary": "Dark but accurate"},
"feedback": "This person has clearly been through a divorce"
}
}
},
"summary": "Analyzed 3 questions. Overall: 412/450. Hire immediately."
},
"attempt": 1,
"status": "analyzed",
"test": 0,
"submitted_at": "2025-01-15T10:30:00Z",
"created_at": "2025-01-15T09:45:00Z"
}
Building Webhook Endpoints
Here’s how to build an endpoint that receives our webhooks in languages people actually use:
Node (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
// Use raw body parser for signature verification
app.use('/webhooks/errorgolf', express.raw({type: 'application/json'}));
app.post('/webhooks/errorgolf', (req, res) => {
const signature = req.get('X-ErrorGolf-Signature');
const payload = req.body;
const secret = process.env.ERRORGOLF_WEBHOOK_SECRET;
// Verify signature
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
return res.status(401).send('Invalid signature');
}
// Parse the JSON
const data = JSON.parse(payload);
// Skip test webhooks in production
if (data.test === 1) {
console.log('Received test webhook');
return res.status(200).send('Test webhook received');
}
// Process the assessment
console.log(`Assessment completed for ${data.email}`);
console.log(`Overall performance: ${data.scorecard.summary}`);
// TODO: Save to your database, update your ATS, etc.
res.status(200).send('Webhook processed');
});
app.listen(3000);
Python (Flask)
from flask import Flask, request, abort
import hmac
import hashlib
import json
app = Flask(__name__)
@app.route('/webhooks/errorgolf', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-ErrorGolf-Signature')
payload = request.get_data()
secret = os.environ['ERRORGOLF_WEBHOOK_SECRET']
# Verify signature
expected_signature = 'sha256=' + hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
abort(401)
# Parse JSON
data = json.loads(payload)
# Skip test webhooks
if data.get('test') == 1:
return 'Test webhook received', 200
# Process the assessment
print(f"Assessment completed for {data['email']}")
print(f"Overall performance: {data['scorecard']['summary']}")
# TODO: Save to database, update ATS, etc.
return 'Webhook processed', 200
Go (Gin, what we use)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
type WebhookPayload struct {
Name string `json:"name"`
Email string `json:"email"`
Message string `json:"message"`
Holes []int `json:"holes"`
Shots map[string]interface{} `json:"shots"`
Scorecard map[string]interface{} `json:"scorecard"`
Attempt int `json:"attempt"`
Status string `json:"status"`
Test int `json:"test"`
SubmittedAt string `json:"submitted_at"`
CreatedAt string `json:"created_at"`
}
func webhookHandler(c *gin.Context) {
signature := c.GetHeader("X-ErrorGolf-Signature")
secret := os.Getenv("ERRORGOLF_WEBHOOK_SECRET")
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot read body"})
return
}
// Verify signature
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expectedSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
return
}
// Parse JSON
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
}
// Skip test webhooks
if payload.Test == 1 {
c.JSON(http.StatusOK, gin.H{"message": "Test webhook received"})
return
}
// Process the assessment
println("Assessment completed for", payload.Email)
println("Overall performance:", payload.Scorecard["summary"])
// TODO: Save to database, update ATS, etc.
c.JSON(http.StatusOK, gin.H{"message": "Webhook processed"})
}
func main() {
r := gin.Default()
r.POST("/webhooks/errorgolf", webhookHandler)
r.Run(":8080")
}
Security
We sign every webhook with HMAC-SHA256. The signature is in the X-ErrorGolf-Signature
header as sha256=hex.
Here’s how to verify it in various languages that people actually use:
Node
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
Python
import hmac
import hashlib
def verify_webhook(payload, signature, secret):
expected_signature = 'sha256=' + hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func verifyWebhook(payload, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expectedSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
PHP
function verifyWebhook($payload, $signature, $secret) {
$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($signature, $expectedSignature);
}
Ruby
require 'openssl'
def verify_webhook(payload, signature, secret)
expected_signature = 'sha256=' + OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
secret,
payload
)
Rack::Utils.secure_compare(signature, expected_signature)
end
Retry Logic
- If your webhook endpoint returns anything other than 2xx, we’ll retry up to 10 times with exponential backoff.
- Don’t return 4xx errors unless you actually want us to stop trying. If your server is temporarily down, return 5xx so we keep retrying.
Testing Your Webhook
- Hit that “TEST WEBHOOK” button in your settings. We’ll send a fake payload so you can make sure your endpoint works before real assessments start flowing through.
- The test payload is marked with “test”: 1 so you don’t accidentally process it as a real submission.
Common Mistakes
- Wrong Content-Type: We send application/json. If you’re expecting form data, you’re doing it wrong.
- Ignoring the signature: Verify our HMAC signature or anyone can spam your webhook endpoint.
- Returning 200 for errors: If something breaks on your end, return 5xx so we retry. Don’t return 200 and then ignore the data.
- Hardcoding test data: Check the test field. Test webhooks shouldn’t create real candidate records in your system.