---
title: 'Block forged webhooks: verify Sautikit signatures in Go, Node, Python, PHP'
description: >-
  How to verify Sautikit HMAC-SHA256 webhook signatures in Go, Node, Python, and
  PHP, with timing-safe comparison and replay attack prevention.
summary: >-
  Every Sautikit webhook is signed with HMAC-SHA256. This post explains the
  signature scheme, why timing-safe comparison matters, and gives
  production-ready verification code in four languages.
date: 2026-06-30T00:00:00.000Z
type: blog
---


## Summary

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.

## Why Webhook Authentication Matters (and Why Basic Auth Is Not Enough)

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.

## How Sautikit Signs Requests: the `t=<ts>,v1=<hex>` Header Format

The `X-Sautikit-Signature` header looks like this:

```
X-Sautikit-Signature: t=1719744000,v1=3a7b9c2d4e1f...
```

- `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.

## The Signature Algorithm Step by Step

The signing surface is constructed as:

```
signed_payload = body + "." + t
```

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.

The HMAC is then:

```
signature = HMAC-SHA256(key=webhook_secret, message=signed_payload)
```

The `webhook_secret` is the per-endpoint secret shown in the Sautikit dashboard under [Webhooks](/developers/concepts/webhooks).

## Verification in Go

Go's `crypto/hmac` package provides `hmac.Equal` for constant-time comparison, which is what you should always use.

```go
package webhook

import (
	"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:

```go
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)
}
```

## Verification in Node.js

```js
import crypto from "node:crypto";

const TOLERANCE_SEC = 300;

export function verifySautikitWebhook(rawBody, signatureHeader, secret) {
  // Parse the header.
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.trim().split("=", 2))
  );

  const ts = parseInt(parts.t, 10);
  const v1 = parts.v1;

  if (!ts || !v1) {
    throw new Error("Malformed X-Sautikit-Signature header");
  }

  // Replay-attack window check.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > TOLERANCE_SEC) {
    throw new Error("Timestamp outside tolerance window");
  }

  // Build signing surface: body + "." + timestamp
  const signingPayload = Buffer.concat([
    Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody),
    Buffer.from("." + String(ts)),
  ]);

  // Compute expected HMAC-SHA256.
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signingPayload)
    .digest("hex");

  // timingSafeEqual requires equal-length buffers.
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");

  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error("Signature mismatch");
  }
}
```

In an Express handler:

```js
import express from "express";
import { verifySautikitWebhook } from "./webhook.js";

const app = express();

// Use raw body parser so we get bytes before JSON parsing.
app.post(
  "/webhooks/sautikit",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      verifySautikitWebhook(
        req.body,
        req.headers["x-sautikit-signature"],
        process.env.SAUTIKIT_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(401).json({ error: err.message });
    }

    const event = JSON.parse(req.body.toString());
    console.log("Verified event:", event.event_type);
    res.sendStatus(200);
  }
);
```

## Verification in Python

```python
import hashlib
import hmac as hmac_lib
import time
from typing import Union


TOLERANCE_SEC = 300


def verify_sautikit_webhook(
    raw_body: Union[bytes, str],
    signature_header: str,
    secret: str,
) -> None:
    """
    Verifies a Sautikit webhook signature.
    Raises ValueError on failure.
    """
    if isinstance(raw_body, str):
        raw_body = raw_body.encode("utf-8")

    # Parse header.
    parts = dict(p.strip().split("=", 1) for p in signature_header.split(","))
    ts_str = parts.get("t")
    v1 = parts.get("v1")

    if not ts_str or not v1:
        raise ValueError("Malformed X-Sautikit-Signature header")

    ts = int(ts_str)

    # Replay-attack window check.
    now = int(time.time())
    if abs(now - ts) > TOLERANCE_SEC:
        raise ValueError(f"Timestamp {ts} is outside the ±{TOLERANCE_SEC}s window")

    # Signing surface: body + b"." + timestamp_bytes
    signed_payload = raw_body + b"." + ts_str.encode("ascii")

    # Compute expected HMAC-SHA256.
    expected = hmac_lib.new(
        secret.encode("utf-8"), signed_payload, hashlib.sha256
    ).hexdigest()

    # hmac.compare_digest is constant-time.
    if not hmac_lib.compare_digest(expected, v1):
        raise ValueError("Signature mismatch")
```

Flask handler:

```python
from flask import Flask, request, abort
import os
from webhook import verify_sautikit_webhook

app = Flask(__name__)

@app.post("/webhooks/sautikit")
def sautikit_webhook():
    try:
        verify_sautikit_webhook(
            request.get_data(),  # raw bytes, before JSON parsing
            request.headers.get("X-Sautikit-Signature", ""),
            os.environ["SAUTIKIT_WEBHOOK_SECRET"],
        )
    except ValueError as exc:
        abort(401, str(exc))

    event = request.get_json(force=True)
    print(f"Verified event: {event['event_type']}")
    return "", 200
```

## Verification in PHP

```php
<?php

const TOLERANCE_SEC = 300;

/**
 * Verifies a Sautikit webhook signature.
 *
 * @param string $rawBody     Raw HTTP request body.
 * @param string $sigHeader   Value of X-Sautikit-Signature header.
 * @param string $secret      Webhook secret from the dashboard.
 * @throws \RuntimeException  On failure.
 */
function verifySautikitWebhook(string $rawBody, string $sigHeader, string $secret): void
{
    // Parse header.
    $parts = [];
    foreach (explode(',', $sigHeader) as $part) {
        [$k, $v] = array_map('trim', explode('=', $part, 2));
        $parts[$k] = $v;
    }

    $tsStr = $parts['t'] ?? null;
    $v1    = $parts['v1'] ?? null;

    if ($tsStr === null || $v1 === null) {
        throw new \RuntimeException('Malformed X-Sautikit-Signature header');
    }

    $ts = (int) $tsStr;

    // Replay-attack window check.
    $now = time();
    if (abs($now - $ts) > TOLERANCE_SEC) {
        throw new \RuntimeException("Timestamp {$ts} is outside the ±" . TOLERANCE_SEC . "s window");
    }

    // Signing surface: body + "." + timestamp
    $signedPayload = $rawBody . '.' . $tsStr;

    // Compute expected HMAC-SHA256.
    $expected = hash_hmac('sha256', $signedPayload, $secret);

    // hash_equals is constant-time in PHP >= 5.6.
    if (!hash_equals($expected, $v1)) {
        throw new \RuntimeException('Signature mismatch');
    }
}

// Usage in a webhook handler (e.g., index.php):
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_X_SAUTIKIT_SIGNATURE'] ?? '';
$secret  = getenv('SAUTIKIT_WEBHOOK_SECRET');

try {
    verifySautikitWebhook($rawBody, $sigHeader, $secret);
} catch (\RuntimeException $e) {
    http_response_code(401);
    echo json_encode(['error' => $e->getMessage()]);
    exit;
}

$event = json_decode($rawBody, true);
error_log('Verified event: ' . $event['event_type']);
http_response_code(200);
```

## Replay Attack Prevention: the Timestamp Window Check

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:

```bash
# 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
```

## Testing Your Verification Locally with curl and a Known Secret

Use this script to generate a valid test request against your local endpoint:

```bash
SECRET="your-webhook-secret"
BODY='{"event_type":"call.completed","call_id":"9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0","duration_seconds":47}'
TS=$(date +%s)

# Signing surface: body + "." + timestamp
SIGNED="${BODY}.${TS}"

# Compute HMAC-SHA256
SIG=$(echo -n "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -v -X POST http://localhost:8080/webhooks/sautikit \
  -H "Content-Type: application/json" \
  -H "X-Sautikit-Signature: t=${TS},v1=${SIG}" \
  -d "$BODY"
```

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.

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Copy the per-endpoint webhook secret from the dashboard and drop one of the verification snippets above into your handler.

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See pricing →](/pricing)** &nbsp;·&nbsp; **[Need SMS, WhatsApp & an agent desk? Helloduty →](https://helloduty.com)**

## Related resources

- [Webhook concepts and event reference](/developers/concepts/webhooks)
- [Calls API reference](/developers/api#calls)
- [Quickstart: place your first call](/developers/guides/quickstart-place-a-call)
- [RFC 2104: HMAC definition](https://www.rfc-editor.org/rfc/rfc2104)
- [NIST on timing attacks](https://csrc.nist.gov/glossary/term/timing_attack)
- [Go `crypto/hmac` package docs](https://pkg.go.dev/crypto/hmac)
