Home
Advocacy Webhooks

Webhook Event Signatures

Verify that webhook requests come from TrueLoyal and were not tampered with.

To protect your application against forged, tampered and replayed requests, we strongly recommend verifying webhook event signatures. Verification ensures that the webhook payloads were actually sent by TrueLoyal and that the contents of the payloads have not been changed in transit.

If your webhook has a signing secret, we will include an X-TINT-Signature header formatted as timestamp,signature, where timestamp is the Unix time (in seconds) at which the request was signed.

To verify the signature:

  1. Split the header value on the first comma to get the timestamp and the signature.
  2. Create a new string by joining the timestamp, a period (.) and the raw request body, exactly as received.
  3. Compute the HMAC-SHA256 of that string using your webhook signing secret as the key and Base64-encode the binary digest.
  4. Compare the result with the signature using a constant-time comparison.
  5. To protect against replay attacks, reject requests whose timestamp is too far from the current time (for example, more than 5 minutes). The timestamp is part of the signed string, so it cannot be changed without invalidating the signature.

The following examples show how to verify a signature in Ruby, Python, PHP and Java.

require "base64"
require "openssl"

TOLERANCE = 300 # 5 minutes

# payload is the raw request body, e.g. request.raw_post in Rails; header is the X-TINT-Signature header.
def valid_signature?(payload, header, secret)
  timestamp, signature = header.to_s.split(",", 2)
  return false if signature.nil?

  # Reject missing, malformed or stale timestamps (replay protection).
  return false unless timestamp.match?(/\A\d+\z/) && (Time.now.to_i - timestamp.to_i).abs <= TOLERANCE

  expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", secret, "#{timestamp}.#{payload}"))
  OpenSSL.secure_compare(expected, signature)
end
import base64
import hashlib
import hmac
import time

TOLERANCE = 300  # 5 minutes


# payload is the raw request body as bytes (e.g. request.get_data() in Flask, request.body in Django);
# header is the X-TINT-Signature header.
def is_valid_signature(payload: bytes, header: str, secret: str) -> bool:
    timestamp, _, signature = (header or "").partition(",")
    if not signature:
        return False

    # Reject missing, malformed or stale timestamps (replay protection).
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE:
        return False

    signed = timestamp.encode() + b"." + payload
    expected = base64.b64encode(hmac.new(secret.encode(), signed, hashlib.sha256).digest()).decode()
    return hmac.compare_digest(expected, signature)
<?php

$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_TINT_SIGNATURE'] ?? '';
$secret = '<SIGNING_SECRET>';
$tolerance = 300; // 5 minutes

$parts = explode(',', $header, 2);
if (count($parts) !== 2) {
    http_response_code(400);
    exit;
}

[$timestamp, $signature] = $parts;

// Reject missing, malformed or stale timestamps (replay protection).
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
    http_response_code(400);
    exit;
}

$expected = base64_encode(hash_hmac('sha256', $timestamp . '.' . $payload, $secret, true));

if (!hash_equals($expected, $signature)) {
    http_response_code(400);
    exit;
}

// The signature is valid: process the event.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class WebhookSignature {
    private static final long TOLERANCE = 300; // 5 minutes

    // payload is the raw request body; header is the X-TINT-Signature header.
    public static boolean isValid(byte[] payload, String header, String secret) throws Exception {
        String[] parts = header == null ? new String[0] : header.split(",", 2);
        if (parts.length != 2 || !parts[0].matches("\\d{1,18}")) {
            return false;
        }

        // Reject stale timestamps (replay protection).
        long timestamp = Long.parseLong(parts[0]);
        if (Math.abs(System.currentTimeMillis() / 1000 - timestamp) > TOLERANCE) {
            return false;
        }

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((parts[0] + ".").getBytes(StandardCharsets.UTF_8));
        String expected = Base64.getEncoder().encodeToString(mac.doFinal(payload));

        return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                parts[1].getBytes(StandardCharsets.UTF_8));
    }
}