Every HTTP request Sautikit sends to your webhook endpoint is signed with HMAC-SHA256. The signature arrives in the X-Sautikit-Signature header as t=<timestamp>,v1=<hex>. This post walks through the exact signing algorithm, explains why you must use timing-safe comparison instead of ==, and provides production-ready verification code in Go, Node.js, Python, and PHP.
An unauthenticated webhook endpoint is a public API that can accept arbitrary payloads from anyone. A bad actor who discovers your endpoint URL can POST a fabricated call.completed event claiming a call lasted 3 600 seconds, potentially triggering downstream business logic (a credit, a confirmation email, a ledger debit) based on data you never verified.
Basic authentication (username/password in the request headers) protects the endpoint from anonymous access, but it does not prove the payload came from Sautikit. Anyone with the credentials can still forge the event body. HMAC signing solves this: the signature is computed over the request body using a secret that only Sautikit and your server know, so a forged payload without the correct signature will fail verification.
There is a second attack to defend against: replay attacks. An attacker who intercepts a legitimate signed request can re-send it later and your verification will pass; the signature is still valid. The timestamp included in the header and the server-side timestamp window check are the defence.
t is a Unix timestamp (seconds since epoch) representing when Sautikit generated the signature.
v1 is the lowercase hex-encoded HMAC-SHA256 digest.
The v1= prefix is versioned so Sautikit can introduce a v2= scheme in the future without breaking existing integrations. Your verification code should parse only the v1= value from the header.
Where body is the raw HTTP request body (bytes, not parsed) and t is the timestamp string extracted from the header. The dot separator is intentional: it prevents length-extension ambiguity. Without the dot, a body that happens to end with a digit would produce a signing surface identical to a body that is one byte shorter followed by a different timestamp, creating a false-positive verification opportunity.
Here is a concrete failing example to illustrate why the dot matters. Suppose your secret is secret, the body is {"a":1}, and the timestamp is 1719744000. The signing surface is:
{"a":1}.1719744000
Without the dot it would be {"a":1}1719744000. An attacker could craft a body {"a":1}1 with timestamp 719744000 and produce the same bytes: a classic length-extension collision vector. The dot makes the body and timestamp boundaries unambiguous.
Go's crypto/hmac package provides hmac.Equal for constant-time comparison, which is what you should always use.
package webhookimport ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "strconv" "strings" "time")const ( // ±300 seconds matches the tolerance used by Safaricom's M-Pesa Daraja API. // East African server clocks drift under high NTP-pool load; 5 minutes is // the pragmatic window that stops replay attacks without rejecting legitimate // events from time-skewed delivery infrastructure. TimestampToleranceSec = 300)// ErrInvalidSignature is returned when the signature does not match.var ErrInvalidSignature = errors.New("webhook: invalid signature")// ErrTimestampExpired is returned when the timestamp is outside the tolerance window.var ErrTimestampExpired = errors.New("webhook: timestamp outside tolerance window")// Verify validates an incoming Sautikit webhook request.// secret is the per-endpoint webhook secret from the dashboard.// body is the raw request body bytes (read before parsing JSON).func Verify(r *http.Request, body []byte, secret string) error { header := r.Header.Get("X-Sautikit-Signature") if header == "" { return errors.New("webhook: missing X-Sautikit-Signature header") } ts, v1, err := parseSignatureHeader(header) if err != nil { return fmt.Errorf("webhook: malformed header: %w", err) } // Replay-attack check: reject requests outside the ±5-minute window. now := time.Now().Unix() if abs(now-ts) > TimestampToleranceSec { return ErrTimestampExpired } // Reconstruct the signing surface: body + "." + timestamp_string tsStr := strconv.FormatInt(ts, 10) signed := append(body, '.') signed = append(signed, []byte(tsStr)...) // Compute expected HMAC. mac := hmac.New(sha256.New, []byte(secret)) mac.Write(signed) expected := mac.Sum(nil) // Decode received signature. received, err := hex.DecodeString(v1) if err != nil { return fmt.Errorf("webhook: invalid hex in v1: %w", err) } // hmac.Equal is constant-time: it does not short-circuit on the first // mismatching byte. Using bytes.Equal instead would leak timing information: // a 256-iteration loop terminates at the first mismatch, and on a fast network // an attacker can distinguish "failed at byte 0" from "failed at byte 31" via // repeated probes, enabling a byte-by-byte brute-force of the expected HMAC. if !hmac.Equal(expected, received) { return ErrInvalidSignature } return nil}func parseSignatureHeader(header string) (ts int64, v1 string, err error) { parts := strings.Split(header, ",") for _, part := range parts { part = strings.TrimSpace(part) if strings.HasPrefix(part, "t=") { ts, err = strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64) if err != nil { return 0, "", fmt.Errorf("bad timestamp: %w", err) } } else if strings.HasPrefix(part, "v1=") { v1 = strings.TrimPrefix(part, "v1=") } } if ts == 0 || v1 == "" { return 0, "", errors.New("missing t or v1 field") } return ts, v1, nil}func abs(x int64) int64 { if x < 0 { return -x } return x}
A usage example inside an HTTP handler:
func handleCallEvent(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "cannot read body", http.StatusBadRequest) return } if err := webhook.Verify(r, body, os.Getenv("SAUTIKIT_WEBHOOK_SECRET")); err != nil { http.Error(w, "signature verification failed", http.StatusUnauthorized) return } // Safe to process the event now. var event map[string]any _ = json.Unmarshal(body, &event) w.WriteHeader(http.StatusOK)}
The timestamp window is set to ±300 seconds (5 minutes). This is a deliberate choice that mirrors the tolerance used by Safaricom's M-Pesa Daraja API. East African infrastructure, particularly server deployments on Kenyan cloud providers and data centres, frequently experiences NTP pool drift under high load. A tighter window (say, 60 seconds) would cause legitimate webhook deliveries to fail when your server's clock is behind. A wider window (say, 24 hours) would make replay attacks trivially easy.
Five minutes is the pragmatic middle ground. If your server's clock is more than 5 minutes off, fix the NTP configuration; that is a problem independent of webhook verification.
You can verify the window logic with a quick test:
# Craft a request with a timestamp that is 6 minutes old.# The verification should reject it even though the signature is valid.SECRET="your-webhook-secret"BODY='{"event_type":"call.completed","call_id":"abc"}'OLD_TS=$(( $(date +%s) - 360 ))SIGNED="${BODY}.${OLD_TS}"SIG=$(echo -n "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')curl -X POST http://localhost:8080/webhooks/sautikit \ -H "Content-Type: application/json" \ -H "X-Sautikit-Signature: t=${OLD_TS},v1=${SIG}" \ -d "$BODY"# Expected: 401 Timestamp outside tolerance window
If your verification returns 200, it is working. If it returns 401, check that you are reading the raw body bytes before JSON parsing; the most common mistake is passing a re-serialised JSON string (which may have different whitespace) to the HMAC instead of the original bytes.