Signature Verification
Verify HelloJohn webhook signatures to ensure requests are authentic — HMAC-SHA256 verification in Node.js, Go, Python, Ruby, and PHP.
HelloJohn signs every webhook request with HMAC-SHA256 using your webhook secret. Always verify the signature before processing the event.
Skipping signature verification allows anyone on the internet to send fake events to your endpoint. Always verify.
How signatures work
HelloJohn adds two headers to every webhook request:
X-HelloJohn-Signature: v1=hmac_signature_here
X-HelloJohn-Timestamp: 1709900000The signature is computed over:
{timestamp}.{raw_request_body}Using HMAC-SHA256 with your webhook secret as the key.
Verification examples
import { createHmac, timingSafeEqual } from 'crypto'
function verifySignature(rawBody, signature, timestamp, secret) {
// Prevent replay attacks — reject events older than 5 minutes
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old')
}
const payload = `${timestamp}.${rawBody}`
const expected = createHmac('sha256', secret)
.update(payload)
.digest('hex')
const sig = signature.replace('v1=', '')
// Use timingSafeEqual to prevent timing attacks
const expectedBuf = Buffer.from(expected, 'hex')
const sigBuf = Buffer.from(sig, 'hex')
if (expectedBuf.length !== sigBuf.length) throw new Error('Invalid signature')
if (!timingSafeEqual(expectedBuf, sigBuf)) throw new Error('Invalid signature')
return JSON.parse(rawBody)
}
// Express usage
app.post('/webhooks/hellojohn',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = verifySignature(
req.body.toString(),
req.headers['x-hellojohn-signature'],
req.headers['x-hellojohn-timestamp'],
process.env.HELLOJOHN_WEBHOOK_SECRET
)
// Process event...
res.json({ received: true })
} catch {
res.status(400).json({ error: 'Invalid signature' })
}
}
)Or use the SDK helper:
import { verifyWebhookSignature } from '@hellojohn/js'
const event = verifyWebhookSignature(rawBody, headers, secret)
// Throws if invalid or expiredimport (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"time"
)
func verifySignature(body []byte, signature, timestamp, secret string) error {
// Check timestamp (prevent replay attacks)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp")
}
age := math.Abs(float64(time.Now().Unix() - ts))
if age > 300 {
return fmt.Errorf("webhook too old")
}
// Compute expected signature
payload := fmt.Sprintf("%s.%s", timestamp, body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
// Strip "v1=" prefix
sig := strings.TrimPrefix(signature, "v1=")
// Constant-time comparison
expectedBytes, _ := hex.DecodeString(expected)
sigBytes, _ := hex.DecodeString(sig)
if !hmac.Equal(expectedBytes, sigBytes) {
return fmt.Errorf("invalid signature")
}
return nil
}import hashlib
import hmac
import time
def verify_signature(body: bytes, signature: str, timestamp: str, secret: str) -> dict:
# Prevent replay attacks
now = int(time.time())
if abs(now - int(timestamp)) > 300:
raise ValueError("Webhook timestamp too old")
payload = f"{timestamp}.{body.decode()}"
expected = hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
sig = signature.replace("v1=", "")
# Constant-time comparison
if not hmac.compare_digest(expected, sig):
raise ValueError("Invalid signature")
import json
return json.loads(body)
# Flask usage
@app.route('/webhooks/hellojohn', methods=['POST'])
def webhook():
try:
event = verify_signature(
request.get_data(),
request.headers['X-HelloJohn-Signature'],
request.headers['X-HelloJohn-Timestamp'],
os.environ['HELLOJOHN_WEBHOOK_SECRET']
)
except ValueError as e:
return jsonify(error=str(e)), 400
# Process event...
return jsonify(received=True)require 'openssl'
require 'json'
def verify_signature(body, signature, timestamp, secret)
# Prevent replay attacks
age = (Time.now.to_i - timestamp.to_i).abs
raise 'Webhook too old' if age > 300
payload = "#{timestamp}.#{body}"
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
sig = signature.sub('v1=', '')
unless ActiveSupport::SecurityUtils.secure_compare(expected, sig)
raise 'Invalid signature'
end
JSON.parse(body)
endfunction verifySignature(string $body, string $signature, string $timestamp, string $secret): array {
// Prevent replay attacks
if (abs(time() - (int)$timestamp) > 300) {
throw new \Exception('Webhook timestamp too old');
}
$payload = $timestamp . '.' . $body;
$expected = hash_hmac('sha256', $payload, $secret);
$sig = str_replace('v1=', '', $signature);
if (!hash_equals($expected, $sig)) {
throw new \Exception('Invalid signature');
}
return json_decode($body, true);
}Replay attack prevention
The X-HelloJohn-Timestamp header contains the Unix timestamp when HelloJohn sent the request. Reject requests where the timestamp is more than 5 minutes in the past to prevent replayed requests.
Testing webhooks locally
Use ngrok or the HelloJohn CLI to forward webhooks to your local dev server:
# Using ngrok
ngrok http 3000
# Copy the https URL, register it as your webhook endpoint
# Using hjctl (forwards events to local port)
hjctl webhooks forward --port 3000From the admin panel, you can also send a test event to your registered endpoint:
POST /v2/admin/webhooks/{webhookId}/test
{ "event_type": "user.created" }