Docs / Verifying webhooks

Verifying webhook signatures

Every delivery is signed. Verify before you trust — it takes four lines.

What we send

POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Signature: 92d33ce5997859ec537a804240c682c1b87c92254fc7325e99a27d6cd841dd7f
X-Event-Id: cmshauqa300emlh0yp9cygl9q

{"api_version":"2026-08","event_id":"cmshauqa300emlh0yp9cygl9q", ...}

X-Signature = hex( HMAC-SHA256( secret, rawBody ) ), where secret is the whsec_… value returned once when the watch was created.

The three rules

  1. Verify the RAW body. Compute the HMAC over the exact bytes received — before any JSON parsing. Parsing and re-serializing changes key order and whitespace and will break the signature. In Express use express.raw(); in Next.js disable the body parser for this route; in Fastify use the raw-body plugin.
  2. Compare in constant time. Use timingSafeEqual / hmac.compare_digest, never === on hex strings.
  3. Dedupe on event_id. Deliveries are at-least-once (retries can double-deliver after a timeout you actually served). Track processed event_ids and skip repeats.

Node (SDK)

import { constructEvent } from '@tronhooks/sdk'; // verifies + parses, throws on mismatch

app.post('/tron-hook', express.raw({ type: 'application/json' }), (req, res) => {
  const event = constructEvent(req.body, req.header('x-signature'), process.env.WHSEC);
  if (alreadyProcessed(event.event_id)) return res.sendStatus(200); // idempotency
  // ... credit the deposit ...
  res.sendStatus(200);
});

Node (no SDK)

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, signatureHex, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest();
  const given = Buffer.from(signatureHex ?? '', 'hex');
  return expected.length === given.length && timingSafeEqual(expected, given);
}

Python

import hmac, hashlib

def verify(raw_body: bytes, signature_hex: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_hex or "")

Go

package hooks

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

// Verify recomputes HMAC-SHA256 over the RAW body and compares in constant time.
func Verify(rawBody []byte, signatureHex, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	got, err := hex.DecodeString(signatureHex)
	if err != nil {
		return false
	}
	return hmac.Equal(expected, got)
}

func Handler(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body) // read BEFORE any JSON decoding
	if !Verify(body, r.Header.Get("X-Signature"), watchSecret) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// json.Unmarshal(body, &event) … dedupe on event.EventID, then:
	w.WriteHeader(http.StatusOK)
}

Responding

Secret hygiene: the whsec_ secret is shown exactly once and stored encrypted on our side. If you lose it, delete the watch and create a new one. Treat it like a password.

Missed deliveries while your endpoint was down? GET /v1/events has the full history for reconciliation.

API reference →

Ready? Free tier, 30-second signup, no credit card.

Create your account →