Signature Verification
All webhooks from Audian are cryptographically signed to ensure their authenticity and integrity. You should always verify the signature before processing the webhook payload.
Overview​
When we send a webhook to your endpoint, we include three headers:
- X-Audian-Signature: HMAC-SHA256 signature of the payload
- X-Audian-Timestamp: Unix timestamp of when the webhook was sent
- X-Audian-Delivery-ID: Unique identifier for this delivery attempt
You verify the signature by computing your own HMAC-SHA256 hash using your webhook signing secret and comparing it to the provided signature.
Getting Your Signing Secret​
When you create a webhook, Audian returns a signing secret:
curl -X POST https://api.audian.com:8443/v2/webhooks \
-H "X-Auth-Token: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-domain.com/webhooks",
"events": ["*"]
}'
Response:
{
"id": "wh_1234567890abcdef",
"url": "https://your-domain.com/webhooks",
"signing_secret": "whsec_abcdef1234567890xyz"
}
Store this secret securely in an environment variable:
export AUDIAN_WEBHOOK_SECRET="whsec_abcdef1234567890xyz"
Signature Algorithm​
The signature is computed as follows:
timestamp = X-Audian-Timestamp header value
body = raw JSON request body (not parsed)
secret = your webhook signing secret
signature = HMAC-SHA256(secret, timestamp + "." + body)
Implementation Examples​
Node.js / JavaScript​
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use(express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.AUDIAN_WEBHOOK_SECRET;
function verifySignature(signature, timestamp, body, secret) {
// Compute expected signature
const message = `${timestamp}.${body}`;
const expected = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
// Use constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhooks', (req, res) => {
const signature = req.headers['x-audian-signature'];
const timestamp = req.headers['x-audian-timestamp'];
const body = req.body.toString('utf-8');
try {
if (!verifySignature(signature, timestamp, body, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature verified, safe to process
const event = JSON.parse(body);
handleEvent(event);
res.json({ received: true });
} catch (error) {
console.error('Webhook verification failed:', error);
res.status(400).json({ error: 'Invalid request' });
}
});
function handleEvent(event) {
console.log(`Processing event: ${event.event}`);
// Your event handling logic here
}
app.listen(3000);
Python / Flask​
import hmac
import hashlib
import json
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.getenv('AUDIAN_WEBHOOK_SECRET').encode()
def verify_signature(signature, timestamp, body, secret):
"""Verify the webhook signature using HMAC-SHA256."""
message = f'{timestamp}.{body}'.encode()
expected = hmac.new(
secret,
message,
hashlib.sha256
).hexdigest()
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(signature, expected)
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
signature = request.headers.get('x-audian-signature')
timestamp = request.headers.get('x-audian-timestamp')
body = request.get_data(as_text=True)
try:
if not verify_signature(signature, timestamp, body, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
# Signature verified, safe to process
event = json.loads(body)
handle_event(event)
return jsonify({'received': True})
except Exception as e:
print(f'Webhook verification failed: {e}')
return jsonify({'error': 'Invalid request'}), 400
def handle_event(event):
print(f"Processing event: {event['event']}")
# Your event handling logic here
if __name__ == '__main__':
app.run(debug=False)
Python / Django​
import hmac
import hashlib
import json
import os
from django.views.decorators.http import require_http_methods
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
WEBHOOK_SECRET = os.getenv('AUDIAN_WEBHOOK_SECRET').encode()
def verify_signature(signature, timestamp, body, secret):
"""Verify the webhook signature using HMAC-SHA256."""
message = f'{timestamp}.{body}'.encode()
expected = hmac.new(
secret,
message,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@csrf_exempt
@require_http_methods(['POST'])
def webhook_handler(request):
signature = request.META.get('HTTP_X_AUDIAN_SIGNATURE')
timestamp = request.META.get('HTTP_X_AUDIAN_TIMESTAMP')
body = request.body.decode('utf-8')
try:
if not verify_signature(signature, timestamp, body, WEBHOOK_SECRET):
return JsonResponse({'error': 'Invalid signature'}, status=401)
event = json.loads(body)
handle_event(event)
return JsonResponse({'received': True})
except Exception as e:
return JsonResponse({'error': 'Invalid request'}, status=400)
def handle_event(event):
# Your event handling logic here
pass
PHP​
<?php
$webhook_secret = getenv('AUDIAN_WEBHOOK_SECRET');
function verify_signature($signature, $timestamp, $body, $secret) {
$message = $timestamp . '.' . $body;
$expected = hash_hmac('sha256', $message, $secret);
// Use constant-time comparison to prevent timing attacks
return hash_equals($expected, $signature);
}
// Get webhook data
$signature = $_SERVER['HTTP_X_AUDIAN_SIGNATURE'] ?? null;
$timestamp = $_SERVER['HTTP_X_AUDIAN_TIMESTAMP'] ?? null;
$body = file_get_contents('php://input');
if (!$signature || !$timestamp) {
http_response_code(400);
exit('Missing headers');
}
if (!verify_signature($signature, $timestamp, $body, $webhook_secret)) {
http_response_code(401);
exit('Invalid signature');
}
// Signature verified, safe to process
$event = json_decode($body, true);
handle_event($event);
http_response_code(200);
echo json_encode(['received' => true]);
function handle_event($event) {
// Your event handling logic here
}
?>
Java​
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WebhookVerifier {
private static final String ALGORITHM = "HmacSHA256";
public static boolean verifySignature(
String signature,
String timestamp,
String body,
String secret) throws Exception {
String message = timestamp + "." + body;
byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8);
byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
SecretKeySpec secretKeySpec = new SecretKeySpec(
secretBytes,
0,
secretBytes.length,
ALGORITHM
);
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(secretKeySpec);
byte[] digest = mac.doFinal(messageBytes);
String expected = bytesToHex(digest);
return MessageDigest.isEqual(
signature.getBytes(StandardCharsets.UTF_8),
expected.getBytes(StandardCharsets.UTF_8)
);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
}
Go​
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
)
func verifySignature(signature, timestamp, body, secret string) bool {
message := timestamp + "." + body
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
expected := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Audian-Signature")
timestamp := r.Header.Get("X-Audian-Timestamp")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
secret := os.Getenv("AUDIAN_WEBHOOK_SECRET")
if !verifySignature(signature, timestamp, string(body), secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Signature verified, safe to process
var event map[string]interface{}
json.Unmarshal(body, &event)
handleEvent(event)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"received":true}`)
}
Important Security Notes​
1. Use Raw Request Body​
You must use the raw request body, not the parsed JSON object. Different JSON serialization methods may produce different results, breaking signature verification.
Correct:
const body = req.body.toString('utf-8'); // raw bytes
Incorrect:
const body = JSON.stringify(req.body); // re-serialized, may differ
2. Use Constant-Time Comparison​
Always use constant-time comparison functions to prevent timing attacks:
- JavaScript:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - PHP:
hash_equals() - Java:
MessageDigest.isEqual() - Go:
hmac.Equal()
Incorrect:
if (signature === expected) { // Vulnerable to timing attacks!
3. Store Secrets Securely​
Never hardcode your webhook secret:
Correct:
const secret = process.env.AUDIAN_WEBHOOK_SECRET;
Incorrect:
const secret = "whsec_hardcoded_secret"; // Security risk!
Timestamp Verification (Optional)​
You can optionally verify that the webhook was delivered recently by checking the timestamp:
function verifyTimestamp(timestamp, maxAge = 300) {
const now = Math.floor(Date.now() / 1000);
const age = now - parseInt(timestamp);
return age < maxAge; // Default: 5 minutes
}
app.post('/webhooks', (req, res) => {
const timestamp = req.headers['x-audian-timestamp'];
if (!verifyTimestamp(timestamp)) {
return res.status(401).json({ error: 'Request too old' });
}
// Continue with signature verification...
});
Rotating Your Signing Secret​
You can rotate your webhook signing secret at any time:
curl -X POST https://api.audian.com:8443/v2/webhooks/wh_1234567890abcdef/rotate-secret \
-H "X-Auth-Token: YOUR_API_KEY"
Response:
{
"id": "wh_1234567890abcdef",
"signing_secret": "whsec_new_secret_value"
}
After rotation, use the new secret for all future webhook verification.
Testing Signature Verification​
You can test your signature verification implementation by:
- Sending a test webhook from the Dashboard
- Comparing the provided signature with your computed signature
- Verifying they match
Test your implementation with these values:
Secret: whsec_test_12345678
Timestamp: 1705315800
Body: {"test":true}
Expected Signature: 8c1234abcd567890ef...
Common Issues​
Signature Mismatch​
Problem: Computed signature doesn't match provided signature.
Solutions:
- Verify you're using the correct signing secret
- Ensure you're using the raw request body, not re-serialized JSON
- Check that timestamp and body are concatenated with a dot separator
- Confirm you're using SHA256, not another algorithm
Headers Not Received​
Problem: Headers are missing from the request.
Solutions:
- Some frameworks convert header names to lowercase
- Some frameworks prefix custom headers
- Check your framework's documentation
- Use case-insensitive header lookup when possible
Timeout Issues​
Problem: Signature verification is slow.
Solutions:
- Use faster libraries (native crypto modules)
- Pre-compute secrets if calling signature verification repeatedly
- Run verification asynchronously to avoid blocking