> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.zip.tax/v-6-0/guides/webhooks/verify/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. > Use your signing secret to confirm requests come from Ziptax