This guide explains how to develop a new webhook provider (verifier and/or validator) or improve an existing one by capturing real webhooks from production systems.
The workflow is:
- Deploy gatekeeperd to a public server to receive real webhooks
- Configure the provider to send webhooks to your gatekeeperd
- Capture the raw requests (headers + body)
- Copy recordings to your dev environment
- Use recordings to develop and test verifiers/validators
- Iterate until tests pass with real payloads
- Go 1.21+ installed
- Clone of the gatekeeper repository
- Text editor or IDE
Option A: Bare Metal / VPS
- A server with a public IP (DigitalOcean, Linode, AWS EC2, etc.)
- A domain name pointing to that IP
- Ports 80 and 443 open for ACME certificate provisioning
Option B: Kubernetes
- A cluster with an ingress controller
- A domain name pointing to your ingress
- TLS termination (via ingress or cert-manager)
- Access to the webhook provider's dashboard (Slack, GitHub, Shopify, etc.)
- Ability to configure webhook URLs and obtain signing secrets
Create a minimal config that accepts webhooks and logs them. Use the noop verifier initially to accept all requests:
# config-recording.yaml
global:
acme_email: "you@example.com"
acme_cache_dir: "/var/cache/gatekeeper/certs"
metrics_port: 9090
log_level: debug # Important: debug level logs request details
verifiers:
# Use noop initially to accept all requests while recording
accept-all:
type: noop
routes:
- hostname: webhooks.yourdomain.com
path: /slack
verifier: accept-all
destination: http://localhost:8888 # Dummy destination (see below)Gatekeeperd forwards requests to a destination. During recording, you have options:
Option 1: Drop on the floor (simplest)
Run a simple server that accepts everything:
# Using netcat (responds with 200 OK to everything)
while true; do echo -e "HTTP/1.1 200 OK\r\n\r\n" | nc -l 8888; done
# Or using Python
python3 -c "
from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
self.send_response(200)
self.end_headers()
HTTPServer(('', 8888), Handler).serve_forever()
"Option 2: Echo server (for debugging)
# Using Python to log what's received
python3 -c "
from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
print('Headers:', dict(self.headers))
print('Body:', body.decode())
self.send_response(200)
self.end_headers()
HTTPServer(('', 8888), Handler).serve_forever()
"Some providers require specific responses:
| Provider | Requirement |
|---|---|
| Slack | Must respond to url_verification challenge with the challenge value |
| GitHub | Any 2xx response |
| Shopify | Any 2xx response |
| Stripe | Any 2xx response (retries on failure) |
For Slack, your echo server needs to handle the challenge:
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
class SlackHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
data = json.loads(body)
# Log everything
print('Headers:', dict(self.headers))
print('Body:', body.decode())
# Handle Slack URL verification
if data.get('type') == 'url_verification':
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(data['challenge'].encode())
else:
self.send_response(200)
self.end_headers()
HTTPServer(('', 8888), SlackHandler).serve_forever()# Build
go build -o gatekeeperd ./cmd/gatekeeperd
# Copy to server
scp gatekeeperd config-recording.yaml user@server:/opt/gatekeeper/
# On the server, run gatekeeperd
/opt/gatekeeper/gatekeeperd -config /opt/gatekeeper/config-recording.yamlUse the Helm chart with recording configuration:
helm install gatekeeper-recorder ./deploy/helm/gatekeeper \
--set config="$(cat config-recording.yaml)"- Go to your provider's webhook configuration page
- Note the signing secret/key (you'll need this for verification)
- Configure the webhook URL to point to your gatekeeperd
Example for Slack:
- Go to api.slack.com > Your App > Event Subscriptions
- Set Request URL to
https://webhooks.yourdomain.com/slack - Copy the "Signing Secret" from Basic Information
Most providers have a way to send test events:
- Slack: Use the app in a test workspace, send messages
- GitHub: Go to webhook settings > "Redeliver" on past events, or push to a repo
- Shopify: Create test orders in a development store
With log_level: debug, gatekeeperd logs request details:
# On bare metal
journalctl -u gatekeeperd -f
# On Kubernetes
kubectl logs -f deployment/gatekeeper-recorderFrom the logs, extract the headers and body. Create a recording file:
{
"metadata": {
"provider": "slack",
"event_type": "event_callback",
"description": "Message posted in channel"
},
"request": {
"method": "POST",
"path": "/slack",
"headers": {
"Content-Type": "application/json",
"X-Slack-Request-Timestamp": "1234567890",
"X-Slack-Signature": "v0=abc123..."
},
"body": {
"type": "event_callback",
"event": {
"type": "message",
"text": "Hello world"
}
}
},
"signing_secret": "your-actual-signing-secret"
}Important: Include the real signing secret so the recording can be used to verify the signature algorithm works.
For bulk capture, modify the echo server to save recordings:
import json
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
class RecordingHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
# Save recording
recording = {
"metadata": {
"provider": "unknown",
"event_type": "unknown",
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"description": "Auto-captured webhook"
},
"request": {
"method": "POST",
"path": self.path,
"headers": dict(self.headers),
"body": json.loads(body) if body else {}
},
"signing_secret": "REPLACE_WITH_REAL_SECRET"
}
filename = f"recording_{int(time.time())}.json"
with open(filename, 'w') as f:
json.dump(recording, f, indent=2)
print(f"Saved: {filename}")
# Respond
self.send_response(200)
self.end_headers()
HTTPServer(('', 8888), RecordingHandler).serve_forever()scp user@server:/path/to/recording_*.json ./testdata/recordings/slack/event_callback/# Find the pod
kubectl get pods -l app=gatekeeper-recorder
# Copy files
kubectl cp gatekeeper-recorder-xxx:/path/to/recordings ./testdata/recordings/Place recordings in the standard directory structure:
testdata/recordings/
{provider}/
{event_type}/
{descriptive_name}.json
Example:
testdata/recordings/
slack/
event_callback/
message.json
reaction_added.json
url_verification/
challenge.json
func TestSlackVerifier_RealPayload(t *testing.T) {
rec, err := testutil.LoadRecording("slack", "event_callback", "message")
if err != nil {
t.Fatal(err)
}
verifier := NewSlackVerifier(rec.SigningSecret, 5*time.Minute)
req, body := rec.ToHTTPRequest()
err = verifier.Verify(req, body)
if err != nil {
t.Errorf("verification failed: %v", err)
}
}If verification fails, add debug logging to understand the signature computation:
// In your verifier, temporarily add:
fmt.Printf("Timestamp: %s\n", timestamp)
fmt.Printf("Body: %s\n", string(body))
fmt.Printf("Signature base: %s\n", signatureBase)
fmt.Printf("Expected: %s\n", expectedSignature)
fmt.Printf("Received: %s\n", receivedSignature)Common issues:
- Timestamp format differences
- Body encoding (raw vs parsed JSON)
- Signature encoding (hex vs base64)
- Prefix format (e.g., Slack uses
v0=)
Use the recording mutation helpers:
func TestSlackVerifier_Failures(t *testing.T) {
rec, _ := testutil.LoadRecording("slack", "event_callback", "message")
verifier := NewSlackVerifier(rec.SigningSecret, 5*time.Minute)
tests := []struct {
name string
rec *testutil.Recording
}{
{"invalid signature", rec.InvalidateSignature()},
{"expired timestamp", rec.ExpireTimestamp(10 * time.Minute)},
{"wrong secret", rec.WithSigningSecret("wrong-secret")},
{"missing signature", rec.WithoutHeader("X-Slack-Signature")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, body := tt.rec.ToHTTPRequest()
err := verifier.Verify(req, body)
if err == nil {
t.Error("expected verification to fail")
}
})
}
}Examine real payloads to understand the structure:
cat testdata/recordings/slack/event_callback/message.json | jq '.request.body'Create a JSON Schema that matches:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "slack/event_callback",
"title": "Slack Event Callback",
"type": "object",
"required": ["type", "event"],
"properties": {
"type": {
"type": "string",
"const": "event_callback"
},
"event": {
"type": "object",
"required": ["type"],
"properties": {
"type": {"type": "string"},
"text": {"type": "string"}
}
}
}
}func TestSlackSchema_RealPayload(t *testing.T) {
rec, _ := testutil.LoadRecording("slack", "event_callback", "message")
v, err := validator.NewJSONSchemaValidator(validator.JSONSchemaConfig{
SchemaFile: "../../schemas/slack/event_callback.json",
})
if err != nil {
t.Fatal(err)
}
err = v.Validate(rec.Request.Body)
if err != nil {
t.Errorf("validation failed: %v", err)
}
}If validation fails, the error message tells you what's wrong:
validation failed: missing properties: 'team_id'
Adjust your schema or make fields optional as needed.
Once verifier and validator work with real payloads:
- Add verifier type to
internal/config/config.go - Wire verifier in
internal/proxy/handler.go - Add schema to
schemas/{provider}/ - Update documentation
See CODING_STANDARDS.md for the full checklist.
Deploy gatekeeperd with your new verifier (not noop):
verifiers:
my-slack:
type: slack
signing_secret: "${SLACK_SIGNING_SECRET}"
validators:
slack-event:
type: json_schema
schema_file: "schemas/slack/event_callback.json"
routes:
- hostname: webhooks.yourdomain.com
path: /slack
verifier: my-slack
validator: slack-event
destination: http://your-actual-backend:8080Send real webhooks and verify they're accepted/rejected correctly.
- Check DNS resolution:
dig webhooks.yourdomain.com - Check TLS certificate:
curl -v https://webhooks.yourdomain.com/health - Check firewall rules (ports 80, 443)
- Check provider's webhook delivery logs
- Ensure signing secret matches exactly (no trailing whitespace)
- Check timestamp is within acceptable range
- Verify body is raw bytes, not re-serialized JSON
- Compare your signature computation with provider's documentation
- Use
jqto examine real payload structure - Check for optional fields that might be missing
- Verify field types (string vs number vs null)
- Never commit real signing secrets to the repository
- Use placeholder secrets in test recordings, or ensure recordings use test/development credentials
- Rotate secrets after development if they were exposed
- Keep production secrets in environment variables or secret management systems