Webhook Signature Verification

Each webhook delivery is signed with HMAC-SHA256 so you can verify that the request came from Truss and has not been replayed or tampered with.

Obtaining Your Signing Secret

When webhook signing is enabled for your subscriber, Truss assigns a signing secret: a 64-character hexadecimal string (32 random bytes) unique to your webhook endpoint.

Contact Truss to retrieve your signing secret. There is no self-service endpoint for this today. If you need to rotate your secret, contact Truss and we will provision a new one.

Important: Treat your signing secret like a password. Store it in a secrets manager, never commit it to source control, and never log it.

The Signature Header

When a signing secret is configured, every webhook delivery includes an X-Webhook-Signature header:

X-Webhook-Signature: t=1700000000,v1=a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456
Component Description
t Unix timestamp (seconds) at the time Truss sent the webhook
v1 HMAC-SHA256 signature as a lowercase hex string (64 characters)

The v1 prefix is a version identifier. Future signature schemes may use additional version keys in the same header.

If no signing secret is configured for your subscriber, the X-Webhook-Signature header is omitted entirely.

What Is Signed

The signature is computed over the UTF-8 bytes of:

{timestamp}.{raw_request_body}

Where {timestamp} is the value of t from the header and {raw_request_body} is the exact JSON string sent in the HTTP request body — not a re-serialized version of a parsed object.

Important: You must verify the signature against the raw request body bytes as received over the wire. Parsing JSON and re-serializing it (even with the same data) can change key order or spacing and will cause verification to fail.

Verifying a Signature

  1. Read the raw request body before parsing JSON.
  2. Read the X-Webhook-Signature header. Reject the request if it is missing.
  3. Parse the header: split on ,, then split each part on the first = to extract t and v1.
  4. Reject the request if |now - t| exceeds your tolerance window (Truss recommends 300 seconds).
  5. Compute HMAC_SHA256(signing_secret, "{t}." + raw_body) and compare the resulting hex digest to v1 using a constant-time comparison.
  6. Only after verification succeeds, parse the JSON body and process the event.

Examples

Use this helper to verify the X-Webhook-Signature header:

const crypto = require('crypto');

const TOLERANCE_SECONDS = 300;

// Assumes rawBody is the exact, unparsed request body as received from Truss.
function verifyTrussSignature(rawBody, signatureHeader, signingSecret) {
  if (!signatureHeader) {
    return false;
  }

  const parts = Object.fromEntries(
    signatureHeader.split(',').map((part) => part.split('=', 2))
  );
  const timestamp = parseInt(parts.t, 10);
  const expectedSignature = parts.v1;

  if (!timestamp || !expectedSignature) {
    return false;
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
    return false;
  }

  const signedPayload = `${timestamp}.${rawBody}`;
  const computedSignature = crypto
    .createHmac('sha256', signingSecret)
    .update(signedPayload, 'utf-8')
    .digest('hex');

  const expectedBuffer = Buffer.from(expectedSignature, 'utf-8');
  const computedBuffer = Buffer.from(computedSignature, 'utf-8');

  return (
    expectedBuffer.length === computedBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, computedBuffer)
  );
}
import hmac
import hashlib
import time

TOLERANCE_SECONDS = 300


# Assumes raw_body is the exact, unparsed request body bytes as received from Truss.
def verify_truss_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
    if not signature_header:
        return False

    parts = dict(part.split("=", 1) for part in signature_header.split(","))
    timestamp = int(parts.get("t", 0))
    expected_signature = parts.get("v1")

    if not timestamp or not expected_signature:
        return False

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

    body_str = raw_body.decode("utf-8")
    signed_payload = f"{timestamp}.{body_str}".encode("utf-8")
    computed_signature = hmac.new(
        signing_secret.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(computed_signature, expected_signature)

Replay Protection

The timestamp is included in the signed payload, so a signature from an old delivery cannot be reused with a different body. Combined with the tolerance check (reject requests where t is more than 5 minutes from your server's clock), this protects against replay attacks.

Ensure your server's clock is reasonably accurate (NTP). If your tolerance is too large, replayed requests remain valid for longer.

Best Practices

  • Always read the raw body before parsing JSON.
  • Use a constant-time comparison (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python).
  • Reject any request that lacks X-Webhook-Signature once signing is enabled for your subscriber.
  • Store the signing secret in your secrets manager; never log it or commit it to source control.
  • Use event_id for idempotency — duplicate deliveries are possible (see the Webhook Setup Guide).

For delivery behavior, retries, and endpoint requirements, see the Webhook Setup Guide.