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

# Verifying signatures

> Confirm a webhook genuinely came from us, in four languages.

Every webhook is signed with HMAC-SHA256 using your webhook secret. Verify before you act on anything.

## The scheme

```
X-Webhook-Signature: t=1754750731,v1=5f2a8c...
```

The signed content is the timestamp and the raw body, joined by a dot:

```
signed_content = "{timestamp}.{raw_request_body}"
signature      = HMAC_SHA256(webhook_secret, signed_content)
```

<Warning>
  Sign the **raw body bytes**. If your framework parses JSON and you re-serialise it, key order and whitespace change and the signature will never match. Configure a raw-body parser for this route.
</Warning>

## Implementation

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "crypto";

  function verify(rawBody, header, secret) {
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=", 2))
    );
    const { t: timestamp, v1: signature } = parts;

    // Replay protection
    const age = Math.floor(Date.now() / 1000) - Number(timestamp);
    if (Number.isNaN(age) || Math.abs(age) > 300) return false;

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

    const a = Buffer.from(signature, "utf8");
    const b = Buffer.from(expected, "utf8");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  app.post("/webhooks/cryptocheckout",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const ok = verify(
        req.body.toString("utf8"),
        req.headers["x-webhook-signature"],
        process.env.CC_WEBHOOK_SECRET
      );
      if (!ok) return res.status(400).send("bad signature");
      res.sendStatus(200);
    });
  ```

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

  def verify(raw_body: str, header: str, secret: str) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      timestamp, signature = parts["t"], parts["v1"]

      if abs(int(time.time()) - int(timestamp)) > 300:
          return False

      expected = hmac.new(
          secret.encode(),
          f"{timestamp}.{raw_body}".encode(),
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(signature, expected)


  @app.post("/webhooks/cryptocheckout")
  def webhook():
      if not verify(
          request.get_data(as_text=True),
          request.headers["X-Webhook-Signature"],
          os.environ["CC_WEBHOOK_SECRET"],
      ):
          abort(400)
      return "", 200
  ```

  ```php PHP theme={null}
  <?php
  function cc_verify(string $rawBody, string $header, string $secret): bool {
      $parts = [];
      foreach (explode(',', $header) as $p) {
          [$k, $v] = explode('=', $p, 2);
          $parts[$k] = $v;
      }

      if (abs(time() - (int)$parts['t']) > 300) return false;

      $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
      return hash_equals($expected, $parts['v1']);
  }

  $raw = file_get_contents('php://input');
  if (!cc_verify($raw, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'], getenv('CC_WEBHOOK_SECRET'))) {
      http_response_code(400);
      exit('bad signature');
  }
  http_response_code(200);
  ```

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

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

  func verify(rawBody []byte, header, secret string) bool {
  	parts := map[string]string{}
  	for _, p := range strings.Split(header, ",") {
  		kv := strings.SplitN(p, "=", 2)
  		if len(kv) == 2 {
  			parts[kv[0]] = kv[1]
  		}
  	}

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

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

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

  func handler(w http.ResponseWriter, r *http.Request) {
  	raw, _ := io.ReadAll(r.Body)
  	if !verify(raw, r.Header.Get("X-Webhook-Signature"), os.Getenv("CC_WEBHOOK_SECRET")) {
  		http.Error(w, "bad signature", http.StatusBadRequest)
  		return
  	}
  	w.WriteHeader(http.StatusOK)
  }
  ```
</CodeGroup>

## Checklist

<Columns cols={2}>
  <Card title="Constant-time compare" icon="stopwatch">
    `timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, `hmac.Equal`. Never `==` on the signature.
  </Card>

  <Card title="Check the timestamp" icon="clock">
    Reject anything older than about five minutes, or a captured delivery can be replayed indefinitely.
  </Card>

  <Card title="Raw body only" icon="file-code">
    Configure your framework to hand you unparsed bytes on this route.
  </Card>

  <Card title="Secret in env, not source" icon="key">
    Rotate it in the dashboard if it's ever exposed.
  </Card>
</Columns>

<Warning>
  If your webhook secret is unset, the signature is delivered as the literal string `none`. Treat that as a failure and refuse the delivery — set a secret before going live.
</Warning>
