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)

Go

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.