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

# Signature verification

> Verify the X-Novatrade-Signature header before trusting webhook payloads.

Every Novatrade24 webhook request carries an HMAC-SHA256 signature in the
`X-Novatrade-Signature` header. **Always verify the signature** before
processing — never trust the payload otherwise.

## Signature format

```
X-Novatrade-Signature: t=1729684200,v1=7a9c5d4e3b2f1a8c9d6e5f4b3a2c1d8e7f6a5b4c3d2e1f8a9b7c6d5e4f3a2b1c
```

* `t` — Unix timestamp (seconds) of when the signature was generated.
* `v1` — HMAC-SHA256 hex digest of `<timestamp>.<raw_request_body>`, keyed by
  your endpoint's secret.

Future signature schemes will use higher `vN` prefixes. Accept any known
version; reject unknown.

## Verification algorithm

1. Parse the header into `t` and `v1` parts.
2. Build the signed string: `<t>.<raw_body>` (period-separated).
3. Compute `HMAC-SHA256(secret, signed_string)` and hex-encode.
4. **Constant-time compare** against the `v1` value.
5. Reject if the timestamp is more than **5 minutes** in the past (replay protection).

<Warning>
  Always use a **constant-time** comparison (`crypto.timingSafeEqual`,
  `hmac.compare_digest`, etc.). Regular `==` is vulnerable to timing attacks.
</Warning>

## Implementation examples

<CodeGroup>
  ```typescript TypeScript (Node.js) theme={null}
  import { createHmac, timingSafeEqual } from 'crypto';

  const MAX_AGE_SECONDS = 300; // 5 minutes

  export function verifyWebhookSignature(
    rawBody: string,
    signatureHeader: string,
    secret: string,
  ): boolean {
    const parts = Object.fromEntries(
      signatureHeader.split(',').map((p) => p.split('=', 2) as [string, string]),
    );
    const timestamp = parseInt(parts.t, 10);
    const provided = parts.v1;
    if (!timestamp || !provided) return false;

    const age = Math.floor(Date.now() / 1000) - timestamp;
    if (age < 0 || age > MAX_AGE_SECONDS) return false;

    const expected = createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(provided, 'hex');
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  MAX_AGE_SECONDS = 300

  def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
      parts = dict(p.split("=", 1) for p in signature_header.split(","))
      try:
          timestamp = int(parts["t"])
          provided = parts["v1"]
      except (KeyError, ValueError):
          return False

      age = int(time.time()) - timestamp
      if age < 0 or age > MAX_AGE_SECONDS:
          return False

      signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode()
      expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, provided)
  ```

  ```java Java theme={null}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.nio.charset.StandardCharsets;
  import java.time.Instant;
  import java.util.HexFormat;

  public class WebhookSignatureVerifier {
      private static final long MAX_AGE_SECONDS = 300;

      public static boolean verify(String rawBody, String signatureHeader, String secret)
              throws Exception {
          var parts = new java.util.HashMap<String, String>();
          for (var part : signatureHeader.split(",")) {
              var kv = part.split("=", 2);
              if (kv.length == 2) parts.put(kv[0], kv[1]);
          }

          var t = parts.get("t");
          var provided = parts.get("v1");
          if (t == null || provided == null) return false;

          long timestamp;
          try { timestamp = Long.parseLong(t); }
          catch (NumberFormatException e) { return false; }

          var age = Instant.now().getEpochSecond() - timestamp;
          if (age < 0 || age > MAX_AGE_SECONDS) return false;

          var signed = (timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8);
          var mac = Mac.getInstance("HmacSHA256");
          mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
          var expected = HexFormat.of().formatHex(mac.doFinal(signed));

          return java.security.MessageDigest.isEqual(
              expected.getBytes(StandardCharsets.UTF_8),
              provided.getBytes(StandardCharsets.UTF_8));
      }
  }
  ```

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

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "strconv"
      "strings"
      "time"
  )

  const maxAgeSeconds = 300

  func VerifySignature(rawBody, signatureHeader, secret string) bool {
      var t, v1 string
      for _, part := range strings.Split(signatureHeader, ",") {
          kv := strings.SplitN(part, "=", 2)
          if len(kv) != 2 {
              continue
          }
          switch kv[0] {
          case "t":
              t = kv[1]
          case "v1":
              v1 = kv[1]
          }
      }
      if t == "" || v1 == "" {
          return false
      }

      timestamp, err := strconv.ParseInt(t, 10, 64)
      if err != nil {
          return false
      }
      age := time.Now().Unix() - timestamp
      if age < 0 || age > maxAgeSeconds {
          return false
      }

      signed := t + "." + rawBody
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(signed))
      expected := hex.EncodeToString(mac.Sum(nil))

      return hmac.Equal([]byte(expected), []byte(v1))
  }
  ```
</CodeGroup>

## Common mistakes

<AccordionGroup>
  <Accordion title="Verifying against a parsed/pretty-printed body">
    The signature is computed over the **raw request bytes**. Any parser that
    normalizes whitespace or reorders keys breaks verification. Capture the
    raw body before JSON parsing.

    In Express, use `express.raw({ type: 'application/json' })` for the
    webhook route and parse JSON yourself after verification.

    In Spring Boot, bind the handler parameter as `byte[]` or `String`, not
    a typed DTO, and parse after verification succeeds.
  </Accordion>

  <Accordion title="Accepting events with expired timestamps">
    Enforce the 5-minute replay window. Without it, a leaked signed request
    can be replayed indefinitely. Reject requests with timestamps older than
    your window.
  </Accordion>

  <Accordion title="Using the wrong secret">
    Each registered endpoint has its own secret. If you have multiple webhook
    endpoints (e.g. per partner), route by `X-Novatrade-Event-Id` or
    `endpointId` (where available) and use the corresponding secret.
  </Accordion>

  <Accordion title="String equality instead of constant-time compare">
    `a === b` in JavaScript exits early on first mismatched character, leaking
    timing info. Use `crypto.timingSafeEqual`. Same in every language.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="Event catalog" icon="list" href="/webhooks/event-catalog">
    Payload schemas for every event type.
  </Card>

  <Card title="Replay and test" icon="repeat" href="/webhooks/replay-and-test">
    Inspect delivery history and manually replay events.
  </Card>
</CardGroup>
