> ## Documentation Index
> Fetch the complete documentation index at: https://docs.verisoul.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Signature Verification

> How to verify the HMAC-SHA256 signature on Verisoul webhook deliveries

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
```

| Component | Description                                                                    |
| --------- | ------------------------------------------------------------------------------ |
| `t`       | Unix timestamp (seconds) when the webhook was signed                           |
| `h`       | Header names included in the signature: `content-type x-event-id x-event-type` |
| `v1`      | Hex-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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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}`
  </Step>

  <Step title="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).
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Warning>
  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.
</Warning>

***

## Code Examples

<CodeGroup>
  ```javascript Node.js theme={null}
  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);
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time
  import os
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  def verify_signature(raw_body: bytes, signature: str, headers: dict, secret: str, tolerance_sec: int = 300) -> bool:
      parts = {}
      for segment in signature.split(','):
          idx = segment.index('=')
          parts[segment[:idx]] = segment[idx + 1:]

      if 't' not in parts or 'h' not in parts or 'v1' not in parts:
          return False

      now = int(time.time())
      if abs(now - int(parts['t'])) > tolerance_sec:
          return False

      header_names = parts['h'].split(' ')
      header_values = '.'.join(headers.get(h, '') for h in header_names)
      signed_data = f"{parts['t']}.{parts['h']}.{header_values}.{raw_body.decode()}"

      expected = hmac.new(secret.encode(), signed_data.encode(), hashlib.sha256).hexdigest()

      return hmac.compare_digest(expected, parts['v1'])

  @app.route('/webhook', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('x-signature')
      if not signature or not verify_signature(
          request.data, signature, request.headers, os.environ['WEBHOOK_SECRET']
      ):
          return jsonify({'error': 'Invalid signature'}), 401

      payload = request.get_json()
      print(f"Verified webhook: {payload['event_type']}")
      return jsonify({'status': 'verified'})

  if __name__ == '__main__':
      app.run(port=3000)
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"encoding/json"
  	"fmt"
  	"io"
  	"math"
  	"net/http"
  	"os"
  	"strconv"
  	"strings"
  	"time"
  )

  func verifySignature(body []byte, signature, secret string, headers http.Header, toleranceSec int64) bool {
  	parts := make(map[string]string)
  	for _, seg := range strings.Split(signature, ",") {
  		idx := strings.Index(seg, "=")
  		if idx < 0 {
  			continue
  		}
  		parts[seg[:idx]] = seg[idx+1:]
  	}

  	t, ok := parts["t"]
  	h, ok2 := parts["h"]
  	v1, ok3 := parts["v1"]
  	if !ok || !ok2 || !ok3 {
  		return false
  	}

  	ts, err := strconv.ParseInt(t, 10, 64)
  	if err != nil || int64(math.Abs(float64(time.Now().Unix()-ts))) > toleranceSec {
  		return false
  	}

  	names := strings.Split(h, " ")
  	vals := make([]string, len(names))
  	for i, name := range names {
  		vals[i] = headers.Get(name)
  	}
  	signedData := fmt.Sprintf("%s.%s.%s.%s", t, h, strings.Join(vals, "."), body)

  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(signedData))
  	expected := hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(expected), []byte(v1))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	body, _ := io.ReadAll(r.Body)
  	signature := r.Header.Get("x-signature")

  	if !verifySignature(body, signature, os.Getenv("WEBHOOK_SECRET"), r.Header, 300) {
  		http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
  		return
  	}

  	var payload map[string]interface{}
  	json.Unmarshal(body, &payload)
  	fmt.Printf("Verified webhook: %v\n", payload["event_type"])

  	w.Header().Set("Content-Type", "application/json")
  	json.NewEncoder(w).Encode(map[string]string{"status": "verified"})
  }

  func main() {
  	http.HandleFunc("/webhook", webhookHandler)
  	http.ListenAndServe(":3000", nil)
  }
  ```

  ```csharp C# theme={null}
  using System.Security.Cryptography;
  using System.Text;

  var builder = WebApplication.CreateBuilder(args);
  var app = builder.Build();
  var secret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET")!;

  app.MapPost("/webhook", async (HttpContext ctx) =>
  {
      ctx.Request.EnableBuffering();
      using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen: true);
      var rawBody = await reader.ReadToEndAsync();

      var signature = ctx.Request.Headers["x-signature"].FirstOrDefault();
      if (signature == null)
          return Results.Json(new { error = "Missing signature" }, statusCode: 401);

      // Parse t, h, v1 from the signature header
      string? t = null, h = null, v1 = null;
      foreach (var seg in signature.Split(','))
      {
          var eq = seg.IndexOf('=');
          if (eq < 0) continue;
          var key = seg[..eq];
          var val = seg[(eq + 1)..];
          switch (key) { case "t": t = val; break; case "h": h = val; break; case "v1": v1 = val; break; }
      }
      if (t == null || h == null || v1 == null)
          return Results.Json(new { error = "Invalid signature" }, statusCode: 401);

      // Check timestamp tolerance (5 minutes)
      if (!long.TryParse(t, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
          return Results.Json(new { error = "Timestamp expired" }, statusCode: 401);

      // Build signed data string
      var names = h.Split(' ');
      var headerVals = string.Join(".", names.Select(n => ctx.Request.Headers[n].ToString()));
      var signedData = $"{t}.{h}.{headerVals}.{rawBody}";

      // Compute and compare HMAC
      using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
      var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signedData))).ToLowerInvariant();
      if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1)))
          return Results.Json(new { error = "Signature mismatch" }, statusCode: 401);

      return Results.Json(new { status = "verified" });
  });

  app.Run();
  ```
</CodeGroup>

***

## Test Your Implementation

You can generate a valid signature locally and send a test webhook with `curl`:

```bash theme={null}
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

<AccordionGroup>
  <Accordion title="Signature always fails to verify">
    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.
  </Accordion>

  <Accordion title="Timestamp rejected but signature is correct">
    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.
  </Accordion>

  <Accordion title="Wrong secret">
    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.
  </Accordion>

  <Accordion title="Header name casing">
    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.
  </Accordion>

  <Accordion title="Using SHA1 instead of SHA256">
    Verisoul uses **HMAC-SHA256**, not HMAC-SHA1. Double-check the hash algorithm in your implementation.
  </Accordion>
</AccordionGroup>
