← Back to Learn
Learn / Webhooks / Security

Webhook Signatures and Security

Learn how ACS signs every outbound webhook with HMAC-SHA256 and how to verify payloads to ensure requests are authentic.

Updated April 8, 2026


Every outbound webhook ACS sends includes an X-ACS-Signature header. This signature lets your receiving endpoint verify that the request genuinely came from ACS and that the payload has not been tampered with in transit.

How Signatures Work

ACS signs each webhook payload using HMAC-SHA256 with the unique secret generated when you created the webhook destination. The signature is computed over the raw request body bytes and delivered in the header:

X-ACS-Signature: sha256=<hex_digest>

To verify, your endpoint recomputes the HMAC using the same secret and compares it to the value in the header. A match confirms the request is authentic and unmodified.

Finding Your Webhook Secret

Your HMAC signing secret is displayed when you first create a webhook destination in Settings → Integrations → Outbound Webhooks. If you need to retrieve or rotate it later, find your destination in that list and click View Secret or Rotate Secret.

Verification Code Examples

Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, signature, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// Express route example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-acs-signature'];
  if (!verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const payload = JSON.parse(req.body);
  // process payload...
  res.status(200).send('OK');
});

Python (Flask)

import hmac, hashlib, os
from flask import request, abort

def verify_webhook(raw_body, signature, secret):
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.route('/webhook', methods=['POST'])
def webhook():
    sig = request.headers.get('X-ACS-Signature', '')
    if not verify_webhook(request.get_data(), sig, os.environ['WEBHOOK_SECRET']):
        abort(401)
    payload = request.get_json()
    # process payload...
    return 'OK', 200

PHP

function verifyWebhook(string $body, string $signature, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
    return hash_equals($expected, $signature);
}

$body = file_get_contents('php://input');
$sig  = $_SERVER['HTTP_X_ACS_SIGNATURE'] ?? '';

if (!verifyWebhook($body, $sig, getenv('WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('Invalid signature');
}
$payload = json_decode($body, true);
// process payload...

Critical: Use the Raw Request Body

Always verify against the raw, unprocessed request body — before any JSON parsing, decoding, or reformatting. Parsing and re-serializing JSON can alter whitespace or key order, causing the signature check to fail even on legitimate requests. In Express, use express.raw() middleware instead of express.json() for the webhook route.

Use Timing-Safe Comparison

Use a constant-time comparison function when comparing signature strings:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()

A simple === comparison is vulnerable to timing attacks that can allow an attacker to guess valid signatures.

Zapier and Make

If you are delivering webhooks to Zapier or Make, you generally do not need to implement signature verification — Zapier and Make use their own authentication at the trigger level. You can safely skip this step for those integrations.

Rotating Your Webhook Secret

If your secret is accidentally exposed:

  1. Go to Settings → Integrations → Outbound Webhooks
  2. Find the affected destination and click Rotate Secret
  3. Copy and store the new secret securely
  4. Update your receiving endpoint with the new value

ACS begins using the new secret immediately for all subsequent deliveries. In-flight deliveries using the old secret will fail verification on your endpoint — this is the expected and correct behavior.

← Back to Learn