> This page is for version v5.0.
> For other versions, use one of these documentation indexes:
> - v6.0 (default): https://docs.zip.tax/v-6-0/llms.txt
> - v5.0: https://docs.zip.tax/v-5-0/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.zip.tax/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.zip.tax/_mcp/server.

# Verify & secure deliveries

Because your endpoint is a public URL, anyone who discovers it could send it
`POST` requests. Ziptax gives each account a **signing secret** so you can
verify that an incoming delivery is genuinely from Ziptax.

## Your signing secret

* Each account has a **single signing secret**, shared across **all** of that
  account's endpoints.
* It is prefixed with **`whsec_`**.
* From **Develop > Events**, you can **reveal** the current secret and
  **rotate** it. Rotating replaces the secret for the whole account, so update
  every endpoint that uses it at the same time.

> **Warning**
>
> Store your signing secret securely and treat it like a password. Keep it out of
> source control and never expose it in client-side code. If you believe it has
> leaked, rotate it from **Develop > Events**.

## How signing keys work

A signing key lets you prove that a request really came from the sender and was
not tampered with in transit. The pattern is the same across most webhook
providers:

### The sender signs each delivery

Before sending, the provider computes a hash-based message authentication code (HMAC) over the exact request body using the shared signing secret, then attaches the result as a signature header on the `POST`.

### Your endpoint recomputes the signature

On receipt, you compute an HMAC over the **raw** request body (the exact bytes you received, before any JSON parsing) using the same signing secret.

### You compare the two signatures

If your computed signature matches the one in the header, the request is authentic and unmodified. If it does not match, reject the request. Because you and the sender are the only parties who know the secret, no one else can forge a valid signature.

## Verifying a request

Ziptax sends the signature in the **`X-Signature`** header. It is a
hex-encoded **HMAC-SHA256** of the raw request body, keyed with your account's
signing secret. To verify a delivery, read the raw body, recompute the HMAC with
your secret, and compare it against `X-Signature` using a constant-time
comparison. Always compare with a timing-safe function so an attacker cannot
learn the correct signature byte by byte.

#### Node.js

```javascript
import express from "express";
import crypto from "crypto";

const SIGNING_SECRET = process.env.ZIPTAX_SIGNING_SECRET; // "whsec_..."

const app = express();

// Capture the RAW body. Verification must run on the exact bytes received,
// not on a re-serialized JSON object.
app.use(express.raw({ type: "application/json" }));

function isValidSignature(rawBody, signature) {
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(rawBody)
    .digest("hex");

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

app.post("/webhooks/ziptax", (req, res) => {
  const signature = req.get("X-Signature"); // Ziptax's signature header

  if (!isValidSignature(req.body, signature)) {
    return res.sendStatus(401);
  }

  const { event, data } = JSON.parse(req.body.toString());
  // ...handle the verified event...

  res.sendStatus(200);
});

app.listen(3000);
```

#### Python

```python
import hashlib
import hmac
import os

from flask import Flask, request

SIGNING_SECRET = os.environ["ZIPTAX_SIGNING_SECRET"]  # "whsec_..."

app = Flask(__name__)

def is_valid_signature(raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(
        SIGNING_SECRET.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    # Constant-time comparison avoids leaking the signature via timing.
    return hmac.compare_digest(expected, signature or "")

@app.post("/webhooks/ziptax")
def ziptax_webhook():
    signature = request.headers.get("X-Signature")  # Ziptax's signature header

    # request.get_data() returns the RAW body; verify before parsing JSON.
    if not is_valid_signature(request.get_data(), signature):
        return "", 401

    payload = request.get_json()
    # ...handle the verified event...

    return "", 200
```

#### Go

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
)

var signingSecret = []byte(os.Getenv("ZIPTAX_SIGNING_SECRET")) // "whsec_..."

func isValidSignature(rawBody []byte, signature string) bool {
	mac := hmac.New(sha256.New, signingSecret)
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))

	// Constant-time comparison avoids leaking the signature via timing.
	return hmac.Equal([]byte(expected), []byte(signature))
}

func main() {
	http.HandleFunc("/webhooks/ziptax", func(w http.ResponseWriter, r *http.Request) {
		signature := r.Header.Get("X-Signature") // Ziptax's signature header

		// Read the RAW body; verify before parsing JSON.
		rawBody, err := io.ReadAll(r.Body)
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		if !isValidSignature(rawBody, signature) {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		// ...parse rawBody and handle the verified event...

		w.WriteHeader(http.StatusOK)
	})

	http.ListenAndServe(":3000", nil)
}
```

> **Warning**
>
> Verify against the **raw request body**, not a re-serialized object. Frameworks
> that auto-parse JSON can reorder keys or change whitespace, which changes the
> HMAC and breaks verification. Capture the raw bytes first, verify, then parse.

## Next step

#### [Test & monitor](testing)

Send a test event and check delivery status.

#### [Best practices](best-practices)

Patterns for reliable, secure webhook handling.