Webhooks are signed with your tenant’s whsec_ secret. Verifying the signature proves the request came from Geldstuck and wasn’t altered in transit.

Headers

Every delivery carries two signature headers - a modern one and a legacy fallback. Always verify the modern header; the legacy one exists only for older integrations.
HeaderFormatUse
Geldstuck-Signaturet=<unix>,v1=<hmac>Modern - verify this.
X-Geldstuck-SignatureRaw HMAC hexLegacy.
X-Geldstuck-EventEvent type, e.g. kyc.completedConvenience - never trust before verifying.
X-Geldstuck-Webhook-IdEvent ID, e.g. evt_01HX...Use for idempotency.

How we compute Geldstuck-Signature

signedPayload = timestamp + "." + rawRequestBody
signature     = HMAC_SHA256(webhookSecret, signedPayload)
header        = "t=" + timestamp + ",v1=" + hexEncode(signature)

How you verify it

1

Read the raw body

Do not parse JSON before verifying. The signature is over the exact bytes we sent - any whitespace normalization or key-order change will break it. Use your framework’s raw-body middleware.
2

Split the header

Extract t (timestamp) and v1 (signature) by splitting on commas and =.
3

Reject stale timestamps

If the timestamp is more than 5 minutes old, reject the request. This prevents replay attacks even if a signature leaks.
4

Compute the expected HMAC

Build signedPayload = timestamp + "." + body and HMAC it with your secret.
5

Compare with `timingSafeEqual`

Never use == or === - they leak timing information that lets attackers brute-force byte-by-byte.

Reference implementation

import crypto from "crypto";

export function verify(
  rawBody: Buffer,
  header: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  const timestamp = Number(parts.t);
  const received = parts.v1;
  if (!timestamp || !received) return false;

  const age = Math.floor(Date.now() / 1000) - timestamp;
  if (age > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody.toString()}`, "utf8")
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Rotating the webhook secret

1

Generate a new secret

From the dashboard (Developers → Webhooks → Rotate secret) or API.
2

Dual-verify for the grace period

We return both the old and new secrets for 24 hours. Your handler should accept either.
3

Finalize

After 24 hours the old secret stops working. Make sure your handler has the new secret in its secrets manager before the window closes.
Your verifier should accept a list of valid secrets and return success if any one matches. That way rotations are a one-line config change.