Skip to content

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...

API Implementation (Sender) ​

go
// 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:

go
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 ​

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 ​

javascript
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 ​

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', 200

Ruby ​

ruby
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
end

Important 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:

go
body, _ := io.ReadAll(r.Body)  // Raw bytes
VerifySignature(body, signature, secret)

Wrong:

go
var event map[string]any
json.Unmarshal(body, &event)  // Parsed
json.Marshal(event)            // Re-serialized (may differ!)
VerifySignature(reserializedBody, signature, secret)  // WILL FAIL

JSON 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 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:

go
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:

go
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:

go
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 API has a 10-second timeout).

Troubleshooting ​

Signature Verification Fails ​

Check:

  1. Are you using the raw request body? (Not re-serialized JSON)
  2. Is the secret correct? (Copy-paste from subscription creation response)
  3. Is the header present? (X-Webhook-Signature)
  4. Is the header format correct? (sha256=...)

Debug:

go
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:

bash
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:

  1. Your endpoint receives the request
  2. Signature verification passes
  3. You return 200 OK

If the test succeeds, your implementation is correct.