Skip to main content

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:

FieldDescription
tUnix timestamp (seconds) when the delivery was signed.
v1Hex-encoded HMAC-SHA256 signature of the signed payload (scheme version 1).

How the signature is computed

  1. Take the timestamp t from the header.

  2. Take the raw request body exactly as received (the raw bytes — do not re-serialize the parsed JSON).

  3. Build the signed payload by concatenating them with a literal period:

    signed_payload = "{t}" + "." + raw_body
  4. Compute HMAC-SHA256(signing_secret, signed_payload) and hex-encode it.

  5. That value must equal the v1 field.

Verify against the RAW body

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:

  1. Extract t and v1 from the X-TakeTheme-Signature header.
  2. Recompute the HMAC over "{t}.{raw_body}" with your signing secret.
  3. Compare your value with v1 using a constant-time comparison to avoid timing attacks.
  4. (Recommended) Reject the request if t is more than ~5 minutes from the current time to prevent replay of captured deliveries.

Examples

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);
}

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.

Secrets are shown once

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

SymptomLikely cause
Signature never matchesYou're verifying against parsed-then-reserialized JSON. Use the raw body.
Works locally, fails in productionA proxy or middleware is rewriting the body. Verify before any body parsing.
Intermittent failures after a secret changeOld 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.