Webhook Signature Verification
Every webhook delivery includes an X-Webhook-Signature header for authenticity verification. Receivers should verify this signature before processing the payload.
How Signatures Work
Signing algorithm: HMAC-SHA256 of the raw request body using the subscription's signing_secret.
X-Webhook-Signature: sha256=a1b2c3d4e5f6...Core API Implementation (Sender)
// internal/core/webhook/signer.go
func Sign(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}When delivering a webhook:
body, _ := json.Marshal(payload)
signature := Sign(body, subscription.SigningSecret)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Webhook-Signature", signature)
req.Header.Set("X-Webhook-Event", eventType)Receiver Implementation
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
func VerifySignature(body []byte, header, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header))
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
// Read the raw body (must be the original bytes for signature verification)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Get the signature header
signature := r.Header.Get("X-Webhook-Signature")
if signature == "" {
http.Error(w, "Missing signature", http.StatusUnauthorized)
return
}
// Verify the signature
secret := "whsec_a1b2c3d4e5f6g7h8..." // Your subscription's signing_secret
if !VerifySignature(body, signature, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Signature valid — process the webhook
// ...
w.WriteHeader(http.StatusOK)
}Node.js
const crypto = require('crypto');
function verifySignature(body, header, secret) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(body);
const expected = 'sha256=' + hmac.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(header)
);
}
app.post('/webhook', (req, res) => {
// Express with body-parser raw middleware
const signature = req.headers['x-webhook-signature'];
const secret = 'whsec_a1b2c3d4e5f6g7h8...'; // Your signing secret
if (!signature) {
return res.status(401).send('Missing signature');
}
// req.body must be the raw Buffer (not parsed JSON)
if (!verifySignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Signature valid — process the webhook
const event = JSON.parse(req.body);
// ...
res.status(200).send('OK');
});Python
import hmac
import hashlib
def verify_signature(body: bytes, header: str, secret: str) -> bool:
mac = hmac.new(secret.encode(), body, hashlib.sha256)
expected = 'sha256=' + mac.hexdigest()
return hmac.compare_digest(expected, header)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature')
secret = 'whsec_a1b2c3d4e5f6g7h8...' # Your signing secret
if not signature:
return 'Missing signature', 401
# request.data is the raw bytes (not request.json)
if not verify_signature(request.data, signature, secret):
return 'Invalid signature', 401
# Signature valid — process the webhook
event = request.json
# ...
return 'OK', 200Ruby
require 'openssl'
def verify_signature(body, header, secret)
mac = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
expected = "sha256=#{mac}"
Rack::Utils.secure_compare(expected, header)
end
post '/webhook' do
signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']
secret = 'whsec_a1b2c3d4e5f6g7h8...' # Your signing secret
halt 401, 'Missing signature' unless signature
# request.body.read gets the raw body
body = request.body.read
halt 401, 'Invalid signature' unless verify_signature(body, signature, secret)
# Signature valid — process the webhook
event = JSON.parse(body)
# ...
status 200
endImportant Implementation Notes
1. Use Raw Body for Verification
CRITICAL: You MUST verify the signature against the raw request body bytes, not the parsed JSON.
Correct:
body, _ := io.ReadAll(r.Body) // Raw bytes
VerifySignature(body, signature, secret)Wrong:
var event map[string]any
json.Unmarshal(body, &event) // Parsed
json.Marshal(event) // Re-serialized (may differ!)
VerifySignature(reserializedBody, signature, secret) // WILL FAILJSON re-serialization may produce different byte representations (whitespace, key order), breaking the signature.
2. Timing-Safe Comparison
Always use constant-time comparison to prevent timing attacks:
- Go:
hmac.Equal() - Node.js:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - Ruby:
Rack::Utils.secure_compare()
Don't use: expected == header (vulnerable to timing attacks)
3. Store Secret Securely
The signing_secret is returned ONLY when creating a subscription. Store it securely:
- Environment variables (recommended)
- Encrypted secrets manager (AWS Secrets Manager, Vault, etc.)
- Never commit to version control
- Never log or expose in error messages
4. Signature Header Format
The signature header is always:
X-Webhook-Signature: sha256=<hex_digest>- Prefix:
sha256= - Digest: Lowercase hex string
- Example:
sha256=a1b2c3d4e5f6789...
Security Best Practices
1. HTTPS Only
The Core API enforces HTTPS-only webhook URLs. Never use HTTP endpoints.
2. Signature Verification is Required
Always verify signatures. Without verification, anyone can send fake webhooks to your endpoint.
3. Validate Event Structure
After signature verification, validate the event payload:
var event struct {
ID string `json:"id"`
Event string `json:"event"`
Data any `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if event.ID == "" || event.Event == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}4. Implement Idempotency
Use the event id field to prevent duplicate processing:
if alreadyProcessed(event.ID) {
w.WriteHeader(http.StatusOK)
return
}
processEvent(event)
markProcessed(event.ID)5. Return 2xx Quickly
Return a 2xx response as soon as the signature is verified. Process the event asynchronously:
func handleWebhook(w http.ResponseWriter, r *http.Request) {
// Verify signature
if !VerifySignature(...) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Queue for async processing
queue.Enqueue(body)
// Return immediately
w.WriteHeader(http.StatusOK)
}This prevents timeouts (The Core API has a 10-second timeout).
Troubleshooting
Signature Verification Fails
Check:
- Are you using the raw request body? (Not re-serialized JSON)
- Is the secret correct? (Copy-paste from subscription creation response)
- Is the header present? (
X-Webhook-Signature) - Is the header format correct? (
sha256=...)
Debug:
fmt.Printf("Body: %s\n", string(body))
fmt.Printf("Header: %s\n", signature)
fmt.Printf("Secret: %s\n", secret)
fmt.Printf("Expected: %s\n", Sign(body, secret))Example Debug Output
Body: {"id":"evt_123","event":"appointment.created","data":{...}}
Header: sha256=a1b2c3d4e5f6789...
Secret: whsec_xyz123...
Expected: sha256=a1b2c3d4e5f6789...If Expected matches Header, verification should succeed.
Testing Signature Verification
Use the /test endpoint to send a test webhook:
curl -X POST https://api.example.com/v1/webhook-subscriptions/{uid}/test \
-H "Authorization: Bearer YOUR_TOKEN"This sends a real signed webhook to your endpoint. Verify:
- Your endpoint receives the request
- Signature verification passes
- You return 200 OK
If the test succeeds, your implementation is correct.