Verifying Signatures
Every webhook TakeTheme sends is signed with the endpoint's signing secret (whsec_…). Verifying the signature proves the request genuinely came from TakeTheme and that the body wasn't tampered with in transit. Always verify before acting on a payload — otherwise anyone who discovers your URL could forge events.
The signature header
Each delivery carries an X-TakeTheme-Signature header:
X-TakeTheme-Signature: t=1751371200,v1=5257a869e7b...
It has two comma-separated fields:
| Field | Description |
|---|---|
t | Unix timestamp (seconds) when the delivery was signed. |
v1 | Hex-encoded HMAC-SHA256 signature of the signed payload (scheme version 1). |
How the signature is computed
-
Take the timestamp
tfrom the header. -
Take the raw request body exactly as received (the raw bytes — do not re-serialize the parsed JSON).
-
Build the signed payload by concatenating them with a literal period:
signed_payload = "{t}" + "." + raw_body -
Compute
HMAC-SHA256(signing_secret, signed_payload)and hex-encode it. -
That value must equal the
v1field.
The signature covers the exact bytes TakeTheme sent. If your framework parses JSON and you re-serialize it, key ordering or whitespace may differ and verification will fail. Read the raw body before JSON parsing (e.g. express.raw, request.get_data(), file_get_contents('php://input')).
Verification steps
To securely verify a webhook:
- Extract
tandv1from theX-TakeTheme-Signatureheader. - Recompute the HMAC over
"{t}.{raw_body}"with your signing secret. - Compare your value with
v1using a constant-time comparison to avoid timing attacks. - (Recommended) Reject the request if
tis more than ~5 minutes from the current time to prevent replay of captured deliveries.
Examples
- Node.js
- Python
- PHP
- Ruby
- Go
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* @param {Buffer|string} rawBody Raw request body (unparsed).
* @param {string} header Value of the X-TakeTheme-Signature header.
* @param {string} secret The endpoint signing secret (whsec_...).
*/
export function verifySignature(rawBody, header, secret) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
// Optional but recommended: reject stale deliveries.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > TOLERANCE_SECONDS) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
const signedPayload = `${timestamp}.${body}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Constant-time comparison.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
parts = dict(
kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv
)
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
# Optional but recommended: reject stale deliveries.
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
<?php
function verify_signature(string $rawBody, ?string $header, string $secret): bool
{
if (!$header) {
return false;
}
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', trim($kv), 2), 2, null);
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? null;
$signature = $parts['v1'] ?? null;
if (!$timestamp || !$signature) {
return false;
}
// Optional but recommended: reject stale deliveries.
if (abs(time() - (int) $timestamp) > 5 * 60) {
return false;
}
$signedPayload = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($expected, $signature);
}
// Usage:
// $raw = file_get_contents('php://input');
// $header = $_SERVER['HTTP_X_TAKETHEME_SIGNATURE'] ?? null;
// if (!verify_signature($raw, $header, getenv('TAKETHEME_WEBHOOK_SECRET'))) { http_response_code(400); exit; }
require "openssl"
TOLERANCE_SECONDS = 5 * 60
def verify_signature(raw_body, header, secret)
return false unless header
parts = header.split(",").each_with_object({}) do |kv, h|
k, v = kv.strip.split("=", 2)
h[k] = v
end
timestamp = parts["t"]
signature = parts["v1"]
return false unless timestamp && signature
# Optional but recommended: reject stale deliveries.
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
signed_payload = "#{timestamp}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
# Constant-time comparison.
OpenSSL.secure_compare(expected, signature)
end
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 5 * 60
// VerifySignature checks an X-TakeTheme-Signature header against the raw body.
func VerifySignature(rawBody []byte, header, secret string) bool {
if header == "" {
return false
}
var timestamp, signature string
for _, kv := range strings.Split(header, ",") {
pair := strings.SplitN(strings.TrimSpace(kv), "=", 2)
if len(pair) != 2 {
continue
}
switch pair[0] {
case "t":
timestamp = pair[1]
case "v1":
signature = pair[1]
}
}
if timestamp == "" || signature == "" {
return false
}
// Optional but recommended: reject stale deliveries.
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-ts) > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}
Rotating the signing secret
If a signing secret is ever exposed, rotate it. Rotation issues a new secret and invalidates the old one immediately, so deploy the new secret to your endpoint promptly.
curl -X POST "https://api.taketheme.com/api/v1/store/webhooks/endpoints/{id}/rotate-secret" \
-H "tt-api-key: tt_xxx"
{ "secret": "whsec_9f2c...41ab" }
Like the create response, the new secret is returned only once — store it securely.
TakeTheme stores signing secrets encrypted and can never display them again after creation or rotation. If you lose a secret, rotate to generate a new one.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Signature never matches | You're verifying against parsed-then-reserialized JSON. Use the raw body. |
| Works locally, fails in production | A proxy or middleware is rewriting the body. Verify before any body parsing. |
| Intermittent failures after a secret change | Old secret still deployed somewhere. Finish rolling out the rotated secret. |
| All deliveries rejected as "stale" | Server clock drift. Sync with NTP, or widen your timestamp tolerance. |