> For the complete documentation index, see [llms.txt](https://docs.catfee.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.catfee.io/en/getting-started/webhook/signature.md).

# Webhook Signing and Verification

Verify CatFee Webhook authenticity and integrity with per-user HMAC-SHA256 signatures, including User Center settings, integration examples, replay protection, and idempotency.

When webhook signing is enabled, CatFee adds an HMAC-SHA256 signature to your HTTP/HTTPS callbacks. Your server verifies the signature with your secret to confirm that the sender holds the same secret and that the request body and event headers have not been modified.

**Each user has one secret and one switch**, shared across all of that user's HTTP/HTTPS callbacks. Signing is disabled by default. The secret is generated the first time you enable signing, retained when you disable it, and reused when you enable it again. Telegram and email notifications are unaffected.

{% hint style="info" %}
Signing does not change the notification JSON structure or compatibility behavior of fields such as `delegation_type`. The event protocol is still identified by `X-EVENT-VERSION: 20260930`; `v1` is a separate version for the signature format. See [Webhook](/en/getting-started/webhook.md) for event fields.
{% endhint %}

## Quick Start

1. Prepare your receiver using an example below: read the raw request body, verify the signature, and then parse JSON.
2. Enable webhook signing in the User Center, copy your signing secret (referred to below as `secret`), and store it in your receiver's environment variables or secret store.
3. Check your implementation with the fixed test vector below, then send a test notification to verify a real callback.
4. Add persistent idempotency and business processing. Return `HTTP 200` with the body `success` only after reliable acceptance.

Enabling signing takes effect immediately for subsequent sends, including retries already queued. Coordinate enabling signing, configuring the secret, and deploying verification code. During this transition, return a non-2xx status if verification fails so the platform can retry under its existing policy; do not temporarily accept unsigned requests or invalid signatures.

## Enable, View, and Disable Signing

Manage signing and view your secret in the **User Center**.

### Enable Signing and Get Your Secret

1. Sign in to the User Center and open the webhook signing settings.
2. Enable signing. On first activation, the system generates a signing secret for your user.
3. Copy the secret to your receiver's environment variables or secret store, for example `CATFEE_WEBHOOK_SECRET`.

Throughout this guide and the verification code, `secret` means the signing secret copied from the User Center: 32 random bytes encoded with standard Base64. It is different from the API Secret used for the energy purchase API. Do not put it in frontend code, logs, or public repositories.

### View Current Settings

View the current signing status and secret in the User Center's webhook signing settings. No secret exists before the first activation. After that, disabling signing retains the secret.

### Disable Signing

Turn off signing in the User Center. Subsequent HTTP/HTTPS callbacks will no longer include signatures. Enabling signing again reuses the same secret.

The switch applies to all HTTP/HTTPS callbacks for the current user and is checked at each actual send, **including test notifications and retries of existing notifications**. Update your receiver's acceptance policy when disabling signing.

## Headers and Signature Rules

When signing is enabled, two headers are added alongside the existing event headers:

| Header              | Description                                                                          |
| ------------------- | ------------------------------------------------------------------------------------ |
| `X-EVENT-ID`        | Event ID, used for signing and idempotency; unchanged across retries                 |
| `X-EVENT-TYPE`      | Event type, such as `EVENT_DELEGATION`                                               |
| `X-EVENT-VERSION`   | Business event protocol version, currently `20260930`                                |
| `X-EVENT-TIMESTAMP` | **New:** Unix timestamp in seconds for this send, as a decimal string                |
| `X-EVENT-SIGNATURE` | **New:** `v1=` followed by the 64-character lowercase hexadecimal HMAC-SHA256 result |

Header names are case-insensitive. **Do not modify the values used in signing.** No key ID is required, and the secret must not be included in the callback request.

### Calculation

1. Decode the `secret` copied from the User Center with **standard Base64** to obtain the 32-byte key. Do not use the Base64 string itself as the HMAC key.
2. Build the prefix in the following order, then append the raw request body bytes:

```
prefix = "v1\n"
       + X-EVENT-TIMESTAMP + "\n"
       + X-EVENT-ID + "\n"
       + X-EVENT-TYPE + "\n"
       + X-EVENT-VERSION + "\n"

signed_bytes = UTF-8(prefix) + raw_body_bytes
signature = "v1=" + lowercase_hex(HMAC-SHA256(secret_bytes, signed_bytes))
```

3. Compare your calculated signature with the received signature using a constant-time comparison. The examples use `timingSafeEqual`, `compare_digest`, and `MessageDigest.isEqual`, respectively.

The separator `\n` is one LF byte (`0x0a`), not a backslash followed by `n`, and not CRLF. Prefix fields contain non-empty printable ASCII values. The final prefix field is followed by one LF; **do not append a newline after the body**.

{% hint style="warning" %}
Verify the raw request body. Do not parse JSON and serialize it again before verification: changes to field order, whitespace, Unicode escaping, or newlines will invalidate the signature.

Use the **`X-EVENT-ID` header** in the signature. The JSON `event_id` may have a prefix such as `D-` or `B-` and must not replace the header value.
{% endhint %}

## Receiver Examples

Select your language, save the code as described below, and configure `CATFEE_WEBHOOK_SECRET`. The examples check signature format, enforce a 300-second time window, and compare signatures in constant time. **You must implement persistent idempotency and reliable business processing separately.** Code is split into two file tabs; use each code block’s copy button to save it.

{% tabs %}
{% tab title="Node.js / Express" %}
This example uses CommonJS. Save the two code tabs as `verify.js` and `receiver.js` in the same directory. Register the callback route before global `express.json()` middleware so it verifies the raw body before parsing JSON.

{% tabs %}
{% tab title="verify.js" %}
{% code lineNumbers="true" %}

```javascript
'use strict';
const crypto = require('node:crypto');
const fields = ['x-event-timestamp', 'x-event-id', 'x-event-type', 'x-event-version'];

// Supply this user's Base64 secret and a Buffer containing the raw body.
// The HTTP adapter must reject duplicate signing headers before collapsing them into an object.
function verify(secretBase64, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
  try {
    const normalized = new Map();
    for (const [name, value] of Object.entries(headers)) {
      const lower = name.toLowerCase();
      if (normalized.has(lower)) return false;
      normalized.set(lower, value);
    }
    const values = fields.map(name => normalized.get(name));
    if (values.some(value => typeof value !== 'string' || !/^[\x20-\x7e]+$/.test(value) || /[\r\n]/.test(value))) return false;
    const [timestamp] = values;
    if (!/^[0-9]{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return false;
    const signature = normalized.get('x-event-signature');
    if (typeof signature !== 'string' || signature.length !== 67 || !/^v1=[0-9a-f]{64}$/.test(signature)) return false;
    const encoded = secretBase64;
    if (typeof encoded !== 'string' || encoded.length !== 44 || !/^[A-Za-z0-9+/]{43}=$/.test(encoded)) return false;
    const secret = Buffer.from(encoded, 'base64');
    if (secret.length !== 32 || !Buffer.isBuffer(rawBody)) return false;
    const prefix = Buffer.from(['v1', ...values].join('\n') + '\n', 'utf8');
    const expected = crypto.createHmac('sha256', secret).update(prefix).update(rawBody).digest();
    return crypto.timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'));
  } catch {
    return false;
  }
}
module.exports = { verify };

if (require.main === module) {
  const assert = require('node:assert/strict');
  const fixture = require('./test-vector.json');
  const { headers, now } = fixture;
  const secret = fixture.secret;
  const body = Buffer.from(fixture.body, 'utf8');
  assert(verify(secret, headers, body, now));
  assert(!verify(secret, headers, Buffer.concat([body, Buffer.from('\n')]), now));
  assert(!verify(secret, headers, body, now + 301));
  assert(!verify(secret, headers, body, now - 301));
  for (const name of Object.keys(headers)) {
    assert(!verify(secret, { ...headers, [name]: headers[name] + '1' }, body, now), name);
  }
  assert(!verify(Buffer.alloc(32).toString('base64'), headers, body, now));
  console.log('Node.js: valid signature, tampering, wrong keys, expired/future timestamps verified');
}
```

{% endcode %}
{% endtab %}

{% tab title="receiver.js" %}
{% code lineNumbers="true" %}

```javascript
const express = require('express');
const { verify } = require('./verify');
const app = express();
const secret = process.env.CATFEE_WEBHOOK_SECRET;
if (!secret) throw new Error('Set CATFEE_WEBHOOK_SECRET');

const signedHeaders = [
  'x-event-timestamp', 'x-event-id', 'x-event-type',
  'x-event-version', 'x-event-signature',
];

// Register this route before app.use(express.json()).
app.post('/callback', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
  // Check rawHeaders for duplicates before the framework merges header values.
  const names = req.rawHeaders.filter((_, index) => index % 2 === 0)
    .map(name => name.toLowerCase());
  if (signedHeaders.some(name => names.filter(value => value === name).length !== 1)) {
    return res.status(400).send('invalid headers');
  }
  if (!verify(secret, req.headers, req.body)) {
    return res.status(401).send('invalid signature');
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.status(400).send('invalid json');
  }
  // TODO: Persistently deduplicate by user, X-EVENT-TYPE, and X-EVENT-ID, and reliably process event.
  // Return a non-2xx status on processing failure; return success only after reliable acceptance.
  return res.status(200).type('text/plain').send('success');
});

app.listen(8080);
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Python / FastAPI" %}
Save the two code tabs as `verify.py` and `receiver.py` in the same directory. Read the raw bytes with `await request.body()` and parse JSON only after verification succeeds. Do not serialize the result of `await request.json()` for verification.

{% tabs %}
{% tab title="verify.py" %}
{% code lineNumbers="true" %}

```python
"""Verify raw webhook bytes before JSON parsing; then deduplicate the authenticated event."""
import base64
import hashlib
import hmac
import re
import time

FIELDS = ("x-event-timestamp", "x-event-id", "x-event-type", "x-event-version")


def verify(secret_b64, headers, raw_body, now=None):
    """secret_b64 is this user's shared secret; headers must contain single values."""
    try:
        normalized = {}
        for name, value in headers.items():
            name = name.lower()
            if name in normalized:
                return False
            normalized[name] = value
        values = [normalized[name] for name in FIELDS]
        if any(not re.fullmatch(r"[\x20-\x7e]+", value) for value in values):
            return False
        timestamp = values[0]
        if not re.fullmatch(r"[0-9]{1,12}", timestamp):
            return False
        current_time = int(time.time()) if now is None else now
        if abs(current_time - int(timestamp)) > 300:
            return False
        signature = normalized["x-event-signature"]
        if not re.fullmatch(r"v1=[0-9a-f]{64}", signature):
            return False
        secret = base64.b64decode(secret_b64, validate=True)
        if len(secret) != 32:
            return False
        prefix = ("v1\n" + "\n".join(values) + "\n").encode("utf-8")
        expected = hmac.new(secret, prefix + raw_body, hashlib.sha256).digest()
        return hmac.compare_digest(expected, bytes.fromhex(signature[3:]))
    except (KeyError, ValueError, TypeError, AttributeError):
        return False


if __name__ == "__main__":
    import json
    from pathlib import Path
    fixture = json.loads(Path(__file__).with_name("test-vector.json").read_text())
    headers = fixture["headers"]
    secret = fixture["secret"]
    body = fixture["body"].encode("utf-8")
    now = fixture["now"]
    assert verify(secret, headers, body, now)
    assert not verify(secret, headers, body + b"\n", now)
    assert not verify(secret, headers, body, now + 301)
    assert not verify(secret, headers, body, now - 301)
    for name in headers:
        tampered = dict(headers)
        tampered[name] += "1"
        assert not verify(secret, tampered, body, now), name
    assert not verify(base64.b64encode(bytes(32)), headers, body, now)
    print("Python: valid signature, tampering, wrong keys, expired/future timestamps verified")
```

{% endcode %}
{% endtab %}

{% tab title="receiver.py" %}
{% code lineNumbers="true" %}

```python
import json
import os
from fastapi import FastAPI, Request, Response
from verify import verify

app = FastAPI()
secret = os.environ["CATFEE_WEBHOOK_SECRET"]
signed_headers = (
    "x-event-timestamp", "x-event-id", "x-event-type",
    "x-event-version", "x-event-signature",
)

@app.post("/callback")
async def callback(request: Request):
    if any(len(request.headers.getlist(name)) != 1 for name in signed_headers):
        return Response("invalid headers", status_code=400)
    raw_body = await request.body()
    if not verify(secret, dict(request.headers), raw_body):
        return Response("invalid signature", status_code=401)
    try:
        event = json.loads(raw_body)
    except (ValueError, UnicodeDecodeError):
        return Response("invalid json", status_code=400)
    # TODO: Persistently deduplicate by user, X-EVENT-TYPE, and X-EVENT-ID, and reliably process event.
    # Return a non-2xx status on processing failure; return success only after reliable acceptance.
    return Response("success", media_type="text/plain")
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Java / Spring Boot" %}
Save the two code tabs as `VerifyWebhook.java` and `CallbackController.java` in the same package, adding the corresponding `package` declarations. Java 17 or later is required. Use `byte[]` for `@RequestBody` to verify the raw bytes before parsing business objects.

{% tabs %}
{% tab title="VerifyWebhook.java" %}
{% code lineNumbers="true" %}

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;

/** Verify raw bytes before parsing JSON; deduplicate the authenticated event separately. */
public class VerifyWebhook {
    private static final String[] FIELDS = {
        "x-event-timestamp", "x-event-id", "x-event-type", "x-event-version"
    };

    public static boolean verify(String secretBase64, Map<String, String> headers, byte[] rawBody) {
        return verify(secretBase64, headers, rawBody, Instant.now().getEpochSecond());
    }

    // The HTTP adapter must reject duplicate signing headers before constructing the map.
    public static boolean verify(String secretBase64, Map<String, String> headers, byte[] rawBody, long now) {
        try {
            Map<String, String> normalized = new HashMap<>();
            for (var entry : headers.entrySet()) {
                if (normalized.putIfAbsent(entry.getKey().toLowerCase(Locale.ROOT), entry.getValue()) != null) return false;
            }
            StringBuilder prefix = new StringBuilder("v1\n");
            for (String field : FIELDS) {
                String value = normalized.get(field);
                if (value == null || !value.matches("[\\x20-\\x7e]+")) return false;
                prefix.append(value).append('\n');
            }
            String timestamp = normalized.get("x-event-timestamp");
            if (!timestamp.matches("[0-9]{1,12}")) return false;
            long sentAt = Long.parseLong(timestamp);
            if (sentAt < now - 300 || sentAt > now + 300) return false;
            String signature = normalized.get("x-event-signature");
            if (signature == null || !signature.matches("v1=[0-9a-f]{64}")) return false;
            String encodedSecret = secretBase64;
            if (encodedSecret == null) return false;
            byte[] secret = Base64.getDecoder().decode(encodedSecret);
            if (secret.length != 32) return false;
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            mac.update(prefix.toString().getBytes(StandardCharsets.UTF_8));
            return MessageDigest.isEqual(mac.doFinal(rawBody), HexFormat.of().parseHex(signature.substring(3)));
        } catch (Exception ignored) {
            return false;
        }
    }

    public static void main(String[] args) {
        String secretBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
        Map<String, String> headers = Map.of(
            "X-EVENT-TIMESTAMP", "1790726400", "X-EVENT-ID", "test-order",
            "X-EVENT-TYPE", "EVENT_DELEGATION", "X-EVENT-VERSION", "20260930",
            "X-EVENT-SIGNATURE", "v1=0405c17d7abae89dd9afdfa7638644cc4dc73a23ebc96dbcb1628a63f4cb3d49");
        byte[] body = "{\"event_id\":\"D-test-order\",\"data\":{\"business_type\":\"BUSINESS_TIME_BASED_RENTAL\",\"remark\":\"签名测试\"}}".getBytes(StandardCharsets.UTF_8);
        long now = 1790726400L;
        if (!verify(secretBase64, headers, body, now)) throw new AssertionError("Valid signature rejected");
        if (verify(secretBase64, headers, new byte[0], now)) throw new AssertionError("Modified body accepted");
        if (verify(secretBase64, headers, body, now + 301)) throw new AssertionError("Expired timestamp accepted");
        if (verify(secretBase64, headers, body, now - 301)) throw new AssertionError("Future timestamp accepted");
        for (String name : headers.keySet()) {
            Map<String, String> tampered = new HashMap<>(headers);
            tampered.put(name, headers.get(name) + "1");
            if (verify(secretBase64, tampered, body, now)) throw new AssertionError("Modified header accepted: " + name);
        }
        if (verify(Base64.getEncoder().encodeToString(new byte[32]), headers, body, now)) throw new AssertionError("Wrong key accepted");
        System.out.println("Java: valid signature, tampering, wrong keys, expired/future timestamps verified");
    }
}
```

{% endcode %}
{% endtab %}

{% tab title="CallbackController.java" %}
{% code lineNumbers="true" %}

```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Objects;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
public class CallbackController {
    private final ObjectMapper mapper;
    private final String secret = Objects.requireNonNull(
        System.getenv("CATFEE_WEBHOOK_SECRET"), "Set CATFEE_WEBHOOK_SECRET");

    public CallbackController(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @PostMapping(value = "/callback", consumes = MediaType.APPLICATION_JSON_VALUE,
                 produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity<String> callback(@RequestHeader HttpHeaders headers,
                                           @RequestBody byte[] rawBody) throws Exception {
        for (String name : List.of("X-EVENT-TIMESTAMP", "X-EVENT-ID", "X-EVENT-TYPE",
                                   "X-EVENT-VERSION", "X-EVENT-SIGNATURE")) {
            var values = headers.get(name);
            if (values == null || values.size() != 1) {
                return ResponseEntity.badRequest().body("invalid headers");
            }
        }
        if (!VerifyWebhook.verify(secret, headers.toSingleValueMap(), rawBody)) {
            return ResponseEntity.status(401).body("invalid signature");
        }
        var event = mapper.readTree(rawBody);
        // TODO: Persistently deduplicate by user, X-EVENT-TYPE, and X-EVENT-ID, and reliably process event.
        // Return a non-2xx status on processing failure; return success only after reliable acceptance.
        return ResponseEntity.ok("success");
    }
}
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}
{% endtabs %}

These controllers demonstrate signature verification. Implement business processing or reliable queuing at the `TODO` comments before deploying them to production. If one callback endpoint serves multiple CatFee users, select the user's secret using a configured route or other trusted context; do not trust a user identity claimed by an unverified body.

## Replay Protection, Idempotency, and Retries

A valid signature does not mean you have never received the notification before. Your receiver also needs:

* **A time window:** Accept requests whose timestamp differs from the current time by no more than 300 seconds, rejecting both stale requests and timestamps too far in the future. The examples implement this check. Keep your server clock synchronized.
* **Persistent idempotency:** Atomically deduplicate by user, `X-EVENT-TYPE`, and `X-EVENT-ID`. Mark an event complete only after the business transaction succeeds or the message is reliably queued. Allow retries after processing failures; an in-memory `Set` is insufficient for production.
* **Duplicate handling:** Return `HTTP 200` with `success` for events already processed successfully, without repeating their business effects.
* **Verification on every retry:** The platform generates a timestamp and signature for each send; the event ID remains unchanged. Use `X-EVENT-TIMESTAMP`, not the business event time in the body. Identical requests sent within the same second may have identical signatures.

Return a non-2xx status when verification fails. After verification and reliable acceptance, return `HTTP 200` with the body `success`. Signed callbacks do not automatically follow 3xx redirects, so configure the final receiver URL directly.

## Verify with a Fixed Test Vector

Save the following content as `test-vector.json` in the same directory as your verification source. These values are **for local testing only and must not be used for production callbacks**:

{% code title="test-vector.json" lineNumbers="true" %}

```json
{
  "secret": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
  "now": 1790726400,
  "headers": {
    "X-EVENT-TIMESTAMP": "1790726400",
    "X-EVENT-ID": "test-order",
    "X-EVENT-TYPE": "EVENT_DELEGATION",
    "X-EVENT-VERSION": "20260930",
    "X-EVENT-SIGNATURE": "v1=0405c17d7abae89dd9afdfa7638644cc4dc73a23ebc96dbcb1628a63f4cb3d49"
  },
  "body": "{\"event_id\":\"D-test-order\",\"data\":{\"business_type\":\"BUSINESS_TIME_BASED_RENTAL\",\"remark\":\"签名测试\"}}"
}
```

{% endcode %}

The raw body is the following single line of UTF-8 text, with no trailing newline. Keep the Chinese text unchanged: it exercises Unicode handling and is part of the signed bytes.

```json
{"event_id":"D-test-order","data":{"business_type":"BUSINESS_TIME_BASED_RENTAL","remark":"签名测试"}}
```

Run the self-test for your chosen language:

```bash
python3 verify.py
node verify.js
java VerifyWebhook.java
```

The self-tests fix the current time at `1790726400` and check the valid signature, body or header tampering, an incorrect secret, and the time window. Production receivers must use the actual current time, never the fixed test time.

After the self-test, send a test notification for a configured subscription from the User Center to verify a real callback. Test notifications use the same signing switch and secret. Balance, subscription, and delegation events are currently supported; test notifications are not added to the retry queue.

## Troubleshooting

| Problem                                                | What to check                                                                                                            |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| Verification always fails                              | Use the webhook `secret` and decode it with standard Base64 first; do not use the API Secret for the energy purchase API |
| Fields match but the signature differs                 | Check for JSON reserialization, extra whitespace, Unicode escaping, or a trailing newline; verify the raw bytes          |
| Event IDs do not match                                 | Sign with the `X-EVENT-ID` header, not the JSON `event_id`, which may include a `D-` or `B-` prefix                      |
| Timestamp validation fails                             | Use Unix **seconds**, not milliseconds or an ISO date; check your server clock and do not use the business event time    |
| Old events arrive after enabling signing               | The switch applies at send time, including pending retries; verify against this send's timestamp                         |
| Does disabling and enabling signing change the secret? | No. The same user secret is reused                                                                                       |
| Is HTTP supported?                                     | Yes, with the same signature rules as HTTPS; use HTTPS when transport encryption is required                             |
| Callbacks fail when the URL redirects                  | Signed callbacks do not follow redirects; configure the final receiver URL directly                                      |

Return to [Webhook](/en/getting-started/webhook.md) for event structures and business fields.
