Verify webhook signatures
Cryptographically verify that webhooks are genuinely from Blooio
Overview
Blooio signs all outgoing webhooks with an HMAC-SHA256 signature, allowing you to verify that:
- The webhook was sent by Blooio (not a malicious third party)
- The payload hasn't been tampered with in transit
Signature format
Every webhook request includes an X-Blooio-Signature header:
X-Blooio-Signature: t=1735324800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Component | Description |
|---|---|
t | Unix timestamp (seconds) when the webhook was sent |
v1 | HMAC-SHA256 signature of {timestamp}.{raw_body} |
The v1 prefix allows for future algorithm versioning.
Getting your signing secret
When you create a webhook, the response includes a signing_secret:
{
"webhook_id": "wh_abc123",
"webhook_url": "https://example.com/webhook",
"webhook_type": "message",
"signing_secret": "whsec_a1b2c3d4e5f6789012345678901234567890abcdef"
}Save this secret immediately! It's only shown once at creation time. If you lose it, you'll need to rotate to get a new one.
Rotating your secret
If you need a new signing secret:
curl -X POST 'https://backend.blooio.com/v2/api/webhooks/{webhookId}/secret/rotate' \
-H 'Authorization: Bearer YOUR_API_KEY'The response includes the new secret (shown once):
{
"webhook_id": "wh_abc123",
"signing_secret": "whsec_newSecret1234567890abcdef1234567890abcdef",
"rotated_at": 1735324800000,
"rotation_count": 1
}Rotation is immediate. The old secret stops working instantly. Update your server before rotating in production.
Verifying signatures
const crypto = require('crypto');
function verifyBlooioSignature(rawBody, signatureHeader, secret) {
// Parse the signature header
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
const signature = parts.find(p => p.startsWith('v1=')).split('=')[1];
// Reject webhooks older than 5 minutes (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
throw new Error('Webhook timestamp too old');
}
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
// Express middleware
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-blooio-signature'];
if (!signature) {
return res.status(401).send('Missing signature');
}
try {
const isValid = verifyBlooioSignature(
req.body.toString(),
signature,
process.env.BLOOIO_WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
} catch (err) {
return res.status(401).send(err.message);
}
const event = JSON.parse(req.body);
// Process the webhook...
res.sendStatus(200);
});Use express.raw() to get the raw request body. Parsing as JSON first will change the body and break signature verification.
import hmac
import hashlib
import time
def verify_blooio_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
# Parse the signature header
parts = dict(p.split('=') for p in signature_header.split(','))
timestamp = parts.get('t')
signature = parts.get('v1')
if not timestamp or not signature:
raise ValueError('Invalid signature format')
# Reject webhooks older than 5 minutes
age = int(time.time()) - int(timestamp)
if age > 300:
raise ValueError('Webhook timestamp too old')
# Compute expected signature
signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(signature, expected)
# Flask example
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Blooio-Signature')
if not signature:
return 'Missing signature', 401
try:
is_valid = verify_blooio_signature(
request.get_data(),
signature,
os.environ['BLOOIO_WEBHOOK_SECRET']
)
if not is_valid:
return 'Invalid signature', 401
except ValueError as e:
return str(e), 401
event = request.get_json()
# Process the webhook...
return '', 200package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func verifyBlooioSignature(body []byte, signatureHeader, secret string) error {
// Parse signature header
parts := strings.Split(signatureHeader, ",")
var timestamp, signature string
for _, part := range parts {
if strings.HasPrefix(part, "t=") {
timestamp = strings.TrimPrefix(part, "t=")
}
if strings.HasPrefix(part, "v1=") {
signature = strings.TrimPrefix(part, "v1=")
}
}
if timestamp == "" || signature == "" {
return errors.New("invalid signature format")
}
// Check timestamp age
ts, _ := strconv.ParseInt(timestamp, 10, 64)
age := time.Now().Unix() - ts
if age > 300 {
return errors.New("webhook timestamp too old")
}
// Compute expected signature
signedPayload := timestamp + "." + string(body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expected := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(signature), []byte(expected)) {
return errors.New("signature mismatch")
}
return nil
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-Blooio-Signature")
err := verifyBlooioSignature(body, signature, os.Getenv("BLOOIO_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Process webhook...
w.WriteHeader(http.StatusOK)
}Troubleshooting
Common issues
| Problem | Solution |
|---|---|
Invalid signature | Make sure you're using the raw request body, not parsed JSON |
Webhook timestamp too old | Check your server's clock is synchronized (NTP) |
Missing signature header | The webhook may be from before signatures were enabled |
| Lost your secret | Use the rotate endpoint to get a new one |
Testing signatures
You can verify your implementation by checking the signature header value locally:
const crypto = require('crypto');
const secret = 'whsec_your_secret_here';
const timestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: 'message.sent', message_id: 'test' });
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
console.log(`t=${timestamp},v1=${signature}`);Best practices
- Store secrets securely - Use environment variables or a secrets manager
- Verify before processing - Always validate the signature before acting on webhook data
- Handle clock skew - The 5-minute tolerance accounts for minor clock differences
- Use constant-time comparison - Prevents timing attacks on signature verification
- Log verification failures - Monitor for potential attacks or misconfigurations