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

# Verifying signatures

> How to verify BlooBank webhook deliveries — the HMAC-SHA256 scheme, header format, and code in six languages.

<Note>
  **Proposed scheme.** The signature format below follows industry conventions (Stripe, GitHub). Concrete header names and the HMAC secret rotation flow will be confirmed with the BlooBank platform team before this scheme exits "Proposed" status.
</Note>

Every webhook delivery is signed. **Verify the signature before processing the payload** — an unverified webhook is an attack surface.

## The scheme

HMAC-SHA256 over a deterministic signed payload, transmitted in the `X-Bloobank-Signature` header.

### The signed payload

```
{timestamp}.{rawBody}
```

| Field         | Source                                                                       |
| ------------- | ---------------------------------------------------------------------------- |
| `{timestamp}` | The value of the `X-Bloobank-Timestamp` header (Unix epoch in milliseconds). |
| `{rawBody}`   | The **exact raw bytes** of the request body.                                 |

The `.` is the literal ASCII period separator. The whole string is UTF-8 encoded.

### The signature header

`X-Bloobank-Signature` carries one or more signatures, comma-separated, each prefixed with a version label:

```
X-Bloobank-Signature: t=1736553600123,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

| Element  | Meaning                                                      |
| -------- | ------------------------------------------------------------ |
| `t=...`  | Unix epoch in milliseconds — mirrors `X-Bloobank-Timestamp`. |
| `v1=...` | HMAC-SHA256 hex digest, signed with the **webhook secret**.  |

Future scheme versions may add `v2=...` for migration; your verifier should accept any version it understands and reject the delivery if none match.

## The webhook secret

The webhook signing secret is generated by BlooBank when you configure an endpoint. It is a high-entropy random string (≥ 32 bytes, base64-encoded) shown **once** in the Dashboard. Copy it into your secret manager and never expose it.

Each endpoint configuration has its own secret. Rotating the secret produces a new value while the old one is briefly accepted in parallel — see [Rotation](#rotation) below.

## Verification — the algorithm

<Steps>
  <Step title="Read the headers and raw body">
    Capture `X-Bloobank-Timestamp`, `X-Bloobank-Signature`, and the **raw bytes** of the request body — do not re-parse and re-serialize.
  </Step>

  <Step title="Parse the signature header">
    Split by `,`, then by `=` for each part. Extract the `t=` timestamp and every `vN=` signature.
  </Step>

  <Step title="Verify the timestamp">
    The header timestamp must be within **±5 minutes** of your server's current time. Reject older deliveries — they are stale (probably replays).
  </Step>

  <Step title="Compute the expected HMAC">
    `expected = HMAC_SHA256_hex(secret, "{timestamp}.{rawBody}")`
  </Step>

  <Step title="Constant-time compare">
    Compare each `vN` from the header to your `expected` using a constant-time comparison function (`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python, etc.). If **any** matches, the signature is valid.
  </Step>

  <Step title="Reject otherwise">
    Return `401 Unauthorized` (without a body). Do not log the body, do not process the event.
  </Step>
</Steps>

## Code

<CodeGroup>
  ```javascript Node.js theme={"dark"}
  import crypto from 'node:crypto';

  const FIVE_MINUTES_MS = 5 * 60 * 1000;

  function verifyWebhook(rawBody, headers, secret) {
    const sigHeader = headers['x-bloobank-signature'];
    if (!sigHeader) throw new Error('Missing signature header');

    // Parse "t=...,v1=...,v1=..."
    const parts = Object.fromEntries(
      sigHeader.split(',').map(p => {
        const [k, v] = p.split('=', 2);
        return [k.trim(), v.trim()];
      })
    );
    // Multiple v1 signatures are possible (during rotation); collect all
    const v1Sigs = sigHeader
      .split(',')
      .filter(p => p.trim().startsWith('v1='))
      .map(p => p.split('=', 2)[1].trim());

    const ts = Number(parts.t);
    if (!Number.isFinite(ts)) throw new Error('Invalid timestamp');
    if (Math.abs(Date.now() - ts) > FIVE_MINUTES_MS) {
      throw new Error('Timestamp outside ±5 minutes — likely replay');
    }

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

    const expectedBuf = Buffer.from(expected, 'hex');
    const ok = v1Sigs.some(sig => {
      const sigBuf = Buffer.from(sig, 'hex');
      return sigBuf.length === expectedBuf.length
          && crypto.timingSafeEqual(sigBuf, expectedBuf);
    });
    if (!ok) throw new Error('Signature mismatch');

    return JSON.parse(rawBody);
  }

  // Express example
  app.post('/webhooks/bloobank', express.raw({ type: 'application/json' }), (req, res) => {
    try {
      const event = verifyWebhook(req.body.toString('utf8'), req.headers, process.env.BLOOBANK_WEBHOOK_SECRET);
      enqueueAsync(event);            // do real work elsewhere
      res.status(200).end();
    } catch (err) {
      console.error('Webhook verification failed:', err);
      res.status(401).end();
    }
  });
  ```

  ```python Python theme={"dark"}
  import hmac, hashlib, os, time
  from flask import Flask, request, abort

  app = Flask(__name__)
  FIVE_MINUTES = 5 * 60

  def verify_webhook(raw_body: bytes, headers, secret: str) -> dict:
      sig_header = headers.get('X-Bloobank-Signature')
      if not sig_header:
          raise ValueError('Missing signature header')

      # Parse "t=...,v1=...,v1=..."
      parts = {}
      v1_sigs = []
      for chunk in sig_header.split(','):
          k, _, v = chunk.strip().partition('=')
          if k == 'v1':
              v1_sigs.append(v)
          else:
              parts[k] = v

      ts = int(parts.get('t', '0'))
      if abs(int(time.time() * 1000) - ts) > FIVE_MINUTES * 1000:
          raise ValueError('Timestamp outside ±5 minutes — likely replay')

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

      if not any(hmac.compare_digest(expected, sig) for sig in v1_sigs):
          raise ValueError('Signature mismatch')

      import json
      return json.loads(raw_body)

  @app.post('/webhooks/bloobank')
  def receive():
      try:
          event = verify_webhook(request.get_data(), request.headers, os.environ['BLOOBANK_WEBHOOK_SECRET'])
          enqueue_async(event)
          return '', 200
      except Exception as e:
          app.logger.error(f'Webhook verification failed: {e}')
          abort(401)
  ```

  ```go Go theme={"dark"}
  package main

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

  const fiveMinutes = 5 * time.Minute

  func verifyWebhook(rawBody []byte, headers http.Header, secret string) error {
      sigHeader := headers.Get("X-Bloobank-Signature")
      if sigHeader == "" {
          return errors.New("missing signature header")
      }

      var tsStr string
      var v1Sigs []string
      for _, p := range strings.Split(sigHeader, ",") {
          kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
          if len(kv) != 2 { continue }
          switch kv[0] {
          case "t":  tsStr = kv[1]
          case "v1": v1Sigs = append(v1Sigs, kv[1])
          }
      }

      ts, err := strconv.ParseInt(tsStr, 10, 64)
      if err != nil { return errors.New("invalid timestamp") }
      if time.Since(time.UnixMilli(ts)).Abs() > fiveMinutes {
          return errors.New("timestamp outside ±5 minutes")
      }

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

      for _, sig := range v1Sigs {
          if hmac.Equal([]byte(sig), []byte(expected)) {
              return nil
          }
      }
      return errors.New("signature mismatch")
  }

  func handler(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      if err := verifyWebhook(body, r.Header, os.Getenv("BLOOBANK_WEBHOOK_SECRET")); err != nil {
          http.Error(w, "", http.StatusUnauthorized)
          return
      }
      // enqueue async work, then ack
      w.WriteHeader(http.StatusOK)
  }
  ```

  ```java Java theme={"dark"}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import jakarta.servlet.http.*;
  import java.io.IOException;
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;
  import java.time.Instant;
  import java.util.*;
  import java.util.stream.Collectors;

  public class WebhookVerifier {
      private static final long FIVE_MINUTES_MS = 5 * 60 * 1000;

      public static void verify(byte[] rawBody, Map<String, String> headers, String secret) throws Exception {
          String sigHeader = headers.get("X-Bloobank-Signature");
          if (sigHeader == null) throw new SecurityException("Missing signature");

          var parts = Arrays.stream(sigHeader.split(","))
              .map(p -> p.trim().split("=", 2))
              .filter(kv -> kv.length == 2)
              .collect(Collectors.groupingBy(kv -> kv[0],
                       Collectors.mapping(kv -> kv[1], Collectors.toList())));

          var ts = parts.getOrDefault("t", List.of()).stream().findFirst()
                        .orElseThrow(() -> new SecurityException("Missing timestamp"));
          var v1 = parts.getOrDefault("v1", List.of());

          if (Math.abs(Instant.now().toEpochMilli() - Long.parseLong(ts)) > FIVE_MINUTES_MS)
              throw new SecurityException("Timestamp outside ±5 minutes");

          var signed = (ts + "." + new String(rawBody, StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8);
          var mac = Mac.getInstance("HmacSHA256");
          mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
          var expected = bytesToHex(mac.doFinal(signed));

          for (var s : v1) {
              if (MessageDigest.isEqual(expected.getBytes(), s.getBytes())) return;
          }
          throw new SecurityException("Signature mismatch");
      }

      private static String bytesToHex(byte[] b) {
          var sb = new StringBuilder();
          for (byte x : b) sb.append(String.format("%02x", x));
          return sb.toString();
      }
  }
  ```

  ```php PHP theme={"dark"}
  <?php
  function verifyWebhook(string $rawBody, array $headers, string $secret): array {
      $sigHeader = $headers['X-Bloobank-Signature'] ?? '';
      if (!$sigHeader) throw new Exception('Missing signature');

      $ts = null;
      $v1Sigs = [];
      foreach (explode(',', $sigHeader) as $p) {
          [$k, $v] = array_pad(explode('=', trim($p), 2), 2, null);
          if ($k === 't')  $ts = $v;
          if ($k === 'v1') $v1Sigs[] = $v;
      }

      if ($ts === null) throw new Exception('Missing timestamp');
      if (abs(intval(microtime(true) * 1000) - intval($ts)) > 5 * 60 * 1000)
          throw new Exception('Timestamp outside ±5 minutes');

      $signed   = $ts . '.' . $rawBody;
      $expected = hash_hmac('sha256', $signed, $secret);

      foreach ($v1Sigs as $sig) {
          if (hash_equals($expected, $sig)) return json_decode($rawBody, true);
      }
      throw new Exception('Signature mismatch');
  }

  // Usage
  $body = file_get_contents('php://input');
  try {
      $event = verifyWebhook($body, getallheaders(), getenv('BLOOBANK_WEBHOOK_SECRET'));
      enqueue_async($event);
      http_response_code(200);
  } catch (Exception $e) {
      http_response_code(401);
  }
  ```

  ```csharp .NET theme={"dark"}
  using System.Security.Cryptography;
  using System.Text;
  using Microsoft.AspNetCore.Mvc;

  public class WebhookController : ControllerBase {
      [HttpPost("/webhooks/bloobank")]
      public async Task<IActionResult> Receive() {
          using var reader = new StreamReader(Request.Body, Encoding.UTF8);
          var rawBody = await reader.ReadToEndAsync();

          if (!TryVerify(rawBody, Request.Headers, Environment.GetEnvironmentVariable("BLOOBANK_WEBHOOK_SECRET")))
              return Unauthorized();

          // enqueue async, then ack
          return Ok();
      }

      private static bool TryVerify(string rawBody, IHeaderDictionary headers, string secret) {
          if (!headers.TryGetValue("X-Bloobank-Signature", out var sigHeader)) return false;

          var parts = sigHeader.ToString().Split(',', StringSplitOptions.TrimEntries);
          string? ts = null; var v1Sigs = new List<string>();
          foreach (var p in parts) {
              var kv = p.Split('=', 2);
              if (kv.Length != 2) continue;
              if (kv[0] == "t")  ts = kv[1];
              if (kv[0] == "v1") v1Sigs.Add(kv[1]);
          }
          if (ts is null || !long.TryParse(ts, out var tsMs)) return false;
          if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - tsMs) > 5 * 60 * 1000) return false;

          var signed = Encoding.UTF8.GetBytes($"{ts}.{rawBody}");
          using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
          var expected = Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant();

          return v1Sigs.Any(sig => CryptographicOperations.FixedTimeEquals(
              Encoding.ASCII.GetBytes(sig.ToLowerInvariant()),
              Encoding.ASCII.GetBytes(expected)));
      }
  }
  ```
</CodeGroup>

## Rotation

Webhook secrets should be rotated periodically. The flow:

<Steps>
  <Step title="Request rotation in the Dashboard">
    Generates a new secret. **Both** old and new secrets are valid for a configurable overlap window (default 24 hours).
  </Step>

  <Step title="During the overlap, BlooBank signs with both">
    Deliveries during the window carry two `v1=...` signatures — one signed with the old secret, one with the new. Your verifier accepts either.
  </Step>

  <Step title="Update your secret manager">
    Replace the old secret with the new one before the overlap window ends.
  </Step>

  <Step title="After the window expires, the old secret is invalidated">
    Only the new secret signs subsequent deliveries.
  </Step>
</Steps>

To support rotation client-side without downtime, your verifier should accept **multiple** secrets — try each, accept on first match. During steady-state operation, you hold one secret; during rotation, two.

## What can go wrong

| Failure               | Cause                                                     | Action                                                                                           |
| --------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Signature mismatch    | Body re-parsed and re-serialized between read and verify. | Pass the **raw bytes** to the verifier. Do not let middleware decode the JSON before you verify. |
| Timestamp drift       | Server clock more than ±5 minutes off.                    | Enable NTP.                                                                                      |
| Stale replay attack   | Attacker captures a delivery and resends hours later.     | The ±5-minute timestamp check rejects this.                                                      |
| Constant-time bypass  | Using `==` or `string.equals`.                            | Always use the platform's constant-time compare.                                                 |
| Secret leaked in logs | Including the secret in error messages.                   | Never log the secret; log only the result (ok/fail).                                             |

## Next

<CardGroup cols={3}>
  <Card title="Retry & delivery" icon="paper-plane" href="/get-started/webhooks/retry-and-delivery">
    The delivery contract.
  </Card>

  <Card title="Best practices" icon="check-double" href="/get-started/webhooks/best-practices">
    Idempotency, ordering, deduplication.
  </Card>

  <Card title="Payment events" icon="credit-card" href="/get-started/webhooks/payment-events">
    Event catalog.
  </Card>
</CardGroup>
