Skip to main content

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:

  1. X-Audian-Signature: HMAC-SHA256 signature of the payload
  2. X-Audian-Timestamp: Unix timestamp of when the webhook was sent
  3. 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:

  1. Sending a test webhook from the Dashboard
  2. Comparing the provided signature with your computed signature
  3. 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

Next Steps​