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
| Header | Description |
|---|---|
X-Datahyena-Event | The event type. |
X-Datahyena-Event-Id | Stable id for this event/endpoint pair. Use it to deduplicate. |
X-Datahyena-Delivery | Unique id for this delivery attempt. |
X-Datahyena-Signature | HMAC 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)
endAlways 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.