HelloJohn / docs
Webhooks

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

The 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 expired
import (
    "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)
end
function 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 3000

From the admin panel, you can also send a test event to your registered endpoint:

POST /v2/admin/webhooks/{webhookId}/test
{ "event_type": "user.created" }

On this page