Skip to main content
Every webhook Verisoul delivers includes an x-signature HTTP header. You must verify this signature before processing the payload to confirm it was sent by Verisoul and has not been tampered with.

Signature Header Format

The x-signature header contains three comma-separated components:
x-signature: t=1773933769,h=content-type x-event-id x-event-type,v1=388bce5537bf39d624211b1f5c1d19e0003b8c4b0513e38ac6aae381df7f5f71
ComponentDescription
tUnix timestamp (seconds) when the webhook was signed
hHeader names included in the signature: content-type x-event-id x-event-type
v1Hex-encoded HMAC-SHA256 signature

How the Signature is Computed

The signature is an HMAC-SHA256 hash over a signed data string constructed by joining these values with . (dots):
signed_data = "{t}.{h}.{content-type}.{x-event-id}.{x-event-type}.{raw_body}"
For example, given:
  • t=1773933769
  • h=content-type x-event-id x-event-type
  • content-type: application/json
  • x-event-id: 5ded1748-8c2f-4ef4-8276-32af793f62b0
  • x-event-type: email.intelligence.completed
  • Body: {"request_id":"ffd7df51-...","status":"error",...}
The signed data string is:
1773933769.content-type x-event-id x-event-type.application/json.5ded1748-8c2f-4ef4-8276-32af793f62b0.email.intelligence.completed.{"request_id":"ffd7df51-...","status":"error",...}
The signature is then:
v1 = hex( HMAC-SHA256( secret, signed_data ) )

Verification Steps

1

Parse the signature header

Split the x-signature header value on , and extract the t, h, and v1 components. Reject the request if any component is missing.
2

Check the timestamp

Parse t as a Unix timestamp and compare it to your server’s current time. Reject the request if the difference exceeds your tolerance (recommended: 5 minutes). This prevents replay attacks.
3

Build the signed data string

Look up the value of each header named in h (content-type, x-event-id, x-event-type), then concatenate with . separators:{t}.{h}.{content-type value}.{x-event-id value}.{x-event-type value}.{raw_body}
4

Compute the expected signature

Compute HMAC-SHA256 over the signed data string using your webhook subscription’s shared secret as the key. Hex-encode the result (lowercase).
5

Compare signatures

Compare your computed signature with the v1 value using a constant-time comparison function to prevent timing attacks. Return 401 Unauthorized if they do not match.
Always verify against the raw request body exactly as received — not a parsed and re-serialized version. JSON key ordering and whitespace matter for signature verification.

Code Examples

const crypto = require('crypto');
const express = require('express');
const app = express();

app.use('/webhook', express.json({
  verify: (req, res, buf) => { req.rawBody = buf; }
}));

function verifySignature(rawBody, signature, headers, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    signature.split(',').map(p => {
      const i = p.indexOf('=');
      return [p.slice(0, i), p.slice(i + 1)];
    })
  );

  if (!parts.t || !parts.h || !parts.v1) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(parts.t)) > toleranceSec) return false;

  const headerNames = parts.h.split(' ');
  const headerValues = headerNames.map(h => headers[h] || '').join('.');
  const signedData = `${parts.t}.${parts.h}.${headerValues}.${rawBody}`;

  const expected = crypto.createHmac('sha256', secret).update(signedData).digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

app.post('/webhook', (req, res) => {
  const signature = req.headers['x-signature'];
  const rawBody = req.rawBody.toString('utf8');

  if (!signature || !verifySignature(rawBody, signature, req.headers, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  console.log('Verified webhook:', req.body.event_type);
  res.json({ status: 'verified' });
});

app.listen(3000);

Test Your Implementation

You can generate a valid signature locally and send a test webhook with curl:
SECRET="your-webhook-secret"
BODY='{"event_type":"email.intelligence.completed","status":"success"}'
TIMESTAMP=$(date +%s)
EVENT_ID="test-event-123"
EVENT_TYPE="email.intelligence.completed"

SIGNED_DATA="${TIMESTAMP}.content-type x-event-id x-event-type.application/json.${EVENT_ID}.${EVENT_TYPE}.${BODY}"
V1=$(echo -n "$SIGNED_DATA" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
SIGNATURE="t=${TIMESTAMP},h=content-type x-event-id x-event-type,v1=${V1}"

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "x-event-id: ${EVENT_ID}" \
  -H "x-event-type: ${EVENT_TYPE}" \
  -H "x-signature: ${SIGNATURE}" \
  -d "${BODY}"

Troubleshooting

The most common cause is using a parsed and re-serialized request body instead of the raw bytes. JSON serializers may reorder keys, change whitespace, or alter number formatting. Always verify against the raw body exactly as received.
Ensure your server’s clock is synchronized via NTP. The default tolerance is 5 minutes (300 seconds). If your server clock drifts beyond this, verification will fail even with a valid HMAC. You can increase the tolerance, but keep it as short as practical.
Each webhook subscription has its own secret. You can find it in the Verisoul dashboard under your webhook subscription settings. Make sure you are using the secret that corresponds to the subscription receiving the webhook.
The header names listed in the h component (content-type, x-event-id, x-event-type) are lowercase. When looking up header values, make sure your framework’s header access is case-insensitive or that you normalize to lowercase.
Verisoul uses HMAC-SHA256, not HMAC-SHA1. Double-check the hash algorithm in your implementation.