DatahyenaDatahyena
Webhooks

Security & delivery

Verify webhook signatures and understand retries and idempotency.

Every delivery is signed and carries headers you can use to verify and deduplicate it.

Headers

HeaderDescription
X-Datahyena-EventThe event type.
X-Datahyena-Event-IdStable id for this event/endpoint pair. Use it to deduplicate.
X-Datahyena-DeliveryUnique id for this delivery attempt.
X-Datahyena-SignatureHMAC signature, format t=<unix>,v1=<hex>.

Verifying the signature

Split the X-Datahyena-Signature header into its t (timestamp) and v1 (hex digest) parts, recompute an HMAC-SHA256 over <t>.<rawBody> with your endpoint's signing secret, and compare it to v1 in constant time. Because the timestamp is part of the signed string, you can also reject deliveries that are too old to narrow the replay window.

import crypto from 'node:crypto';

// header: "t=<unix>,v1=<hex>"
export function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // stale

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time

# header: "t=<unix>,v1=<hex>"  ·  raw_body is the exact request bytes
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts.get("t", 0))
    if not ts or abs(time.time() - ts) > tolerance:
        return False  # stale

    expected = hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"math"
	"strconv"
	"strings"
	"time"
)

// header: "t=<unix>,v1=<hex>"
func Verify(rawBody []byte, header, secret string, toleranceSec float64) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		if kv := strings.SplitN(p, "=", 2); len(kv) == 2 {
			parts[kv[0]] = kv[1]
		}
	}

	ts, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > toleranceSec {
		return false // stale
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(fmt.Sprintf("%d.%s", ts, rawBody)))
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
<?php
// header: "t=<unix>,v1=<hex>"
function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    $ts = (int)($parts['t'] ?? 0);
    if (!$ts || abs(time() - $ts) > $tolerance) {
        return false; // stale
    }

    $expected = hash_hmac('sha256', "$ts.$rawBody", $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
require "openssl"

# header: "t=<unix>,v1=<hex>"
def verify(raw_body, header, secret, tolerance = 300)
  parts = header.split(",").map { |p| p.split("=", 2) }.to_h
  ts = parts["t"].to_i
  return false if ts.zero? || (Time.now.to_i - ts).abs > tolerance # stale

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
  given = parts["v1"].to_s
  expected.bytesize == given.bytesize &&
    OpenSSL.fixed_length_secure_compare(expected, given)
end

Always verify against the raw request body before parsing JSON — re-serializing changes the bytes and breaks the signature. Reject deliveries whose timestamp is more than a few minutes old.

Retries and idempotency

A delivery is considered successful on any 2xx response within 10 seconds. Non-2xx, timeouts, and network errors are retried up to 6 times with exponential backoff. Because retries and replays reuse the same X-Datahyena-Event-Id, dedupe on that header and respond 2xx quickly.

On this page