RevKeen Docs
Webhooks

Verify signatures

HMAC signature verification, timestamp tolerance, and replay-attack prevention

Every webhook request from RevKeen carries an X-RevKeen-Signature header. Reject any request that fails signature verification — no exceptions.

The header

X-RevKeen-Signature: t=1705689600,v1=5d2c67a1b4e8f0d3c6a2b9e0f1d8c7b4a9e3f2c5d8b1a4e7f0c3b6a9d2e5f8c1
ComponentMeaning
t=<unix>Unix timestamp (seconds) when RevKeen signed the request.
v1=<hex>HMAC-SHA256 of "{t}.{payload}" using the endpoint's signing secret.

Verification procedure

  1. Split the header on , and parse the t= and v1= components.
  2. Compute the expected signature: HMAC_SHA256(secret, f"{t}.{raw_body}") — hex-encoded.
  3. Use a constant-time compare to match the expected and received signatures.
  4. Reject if the timestamp is more than 5 minutes old — prevents replay attacks.
  5. Only then parse the body and act on the event.

SDK helpers

Every official SDK ships a webhooks.verify() helper that does all of the above in one call. Prefer it over writing your own.

# Extract components from X-RevKeen-Signature header
TIMESTAMP=$(echo "$SIGHDR" | grep -oE 't=[0-9]+' | cut -d= -f2)
SIGNATURE=$(echo "$SIGHDR" | grep -oE 'v1=[a-f0-9]+' | cut -d= -f2)

# Compute expected
EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

# Constant-time compare (bash has no native one — use a proper lib in prod)
[ "$EXPECTED" = "$SIGNATURE" ] || exit 1
import { RevKeen } from "@revkeen/sdk";

const client = new RevKeen({ apiKey: process.env.REVKEEN_API_KEY! });

// In your webhook handler (Next.js App Router, Hono, Express, etc.)
const rawBody = await request.text(); // Must be the raw string, not JSON.parse'd
const signature = request.headers.get("x-revkeen-signature");
const secret = process.env.REVKEEN_WEBHOOK_SECRET!;

try {
  const event = client.webhooks.verify(rawBody, signature!, secret);
  // event.type, event.data.object are now trusted
} catch (err) {
  return new Response("invalid signature", { status: 400 });
}
import (
  "io"
  "net/http"
  revkeen "github.com/RevKeen/sdk-go"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    sig := r.Header.Get("X-RevKeen-Signature")

    event, err := revkeen.Webhooks.Verify(body, sig, os.Getenv("REVKEEN_WEBHOOK_SECRET"))
    if err != nil {
        http.Error(w, "invalid signature", http.StatusBadRequest)
        return
    }

    // event.Type, event.Data.Object are now trusted
}
use RevKeen\Webhooks\WebhookVerifier;

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_REVKEEN_SIGNATURE'] ?? '';
$secret = getenv('REVKEEN_WEBHOOK_SECRET');

try {
    $event = WebhookVerifier::verify($rawBody, $signature, $secret);
    // $event->type, $event->data->object are now trusted
} catch (\RevKeen\Exception\SignatureException $e) {
    http_response_code(400);
    exit('invalid signature');
}

Replay protection

The timestamp t in the signature header is part of the signed payload. Reject deliveries where now - t > 300s (5 minutes). An attacker who captures a valid request cannot replay it after the window expires.

Rotating the signing secret

  • Create a new endpoint with the dashboard or API — each endpoint has its own secret.
  • Ship the new endpoint's secret to your consumer.
  • Delete the old endpoint.
  • Alternatively, rotate the existing endpoint's secret — the response returns a new secret; update your consumer before the old secret is invalidated.

Never commit signing secrets to source control. Load them from your secrets manager (Vercel env, Infisical, AWS Secrets Manager, etc.).

Common failure modes

SymptomCause
Signature always failsReading the request body as JSON before computing HMAC mutates whitespace. Always hash the raw body bytes.
Signature fails intermittentlyClock skew between RevKeen and your server. Check NTP.
Signature fails in production onlyReverse proxy or WAF is re-encoding the body. Pass raw bytes through.
"signature too old"Retry delay pushed the request past the 5-minute window. Retries carry a fresh timestamp and signature; only delivered-now requests should verify.

See also

On this page