> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures in Python and Go

> Self-contained webhook signature verifiers for Python and Go backends: header trio handling with rotation support, raw-body caveats, timing-safe comparison, and a reject-path test.

# Verify webhook signatures in Python and Go

The [Webhook security](/webhooks/security) page covers the full signature protocol — header formats, the signed string, and timing-safe comparison. But its polished examples are mostly Node.js. If your backend is Python or Go with no Node in the request path, you need a canonical verifier you can paste directly into your codebase and run with just the standard library.

This guide gives you exactly that: one complete, copy-pasteable HMAC verifier per language. Each handles the header trio (`X-Orbit-Signature` first, `X-Orbit-Signature-Next` during a rotation, `X-Devotel-Signature` as the legacy fallback), guards the replay window, and compares with a timing-safe function. Each also ships with a negative test that proves the verifier refuses a wrong secret.

For the protocol details — what each header carries, the `<t>.<raw_body>` signed string, rotation grace windows — see [Webhook security](/webhooks/security). For a full receiver walkthrough with a queue and retry logic, see [Build a durable webhook consumer](/guides/webhook-consumer).

## What you're verifying

Every delivery carries the signature in up to three headers. Your verifier should read them in this order and accept if **any** matches:

1. **`X-Orbit-Signature`** — canonical, signed with your current secret. Check this first.
2. **`X-Orbit-Signature-Next`** — only present during a key-rotation grace window, signed with your previous secret. If you hold both secrets during a rotation, try this after the first fails.
3. **`X-Devotel-Signature`** — legacy back-compat header that carries one or two `v1=` candidates in a single header (new then previous). Old deliveries queued before the canonical headers existed carry only this one.

Each header uses the same encoding: `t=<unix_seconds>,v1=<hex>` with an optional second `v1=` in the legacy header. The signed string is `<t>.<raw_body>` — the literal timestamp from the header, a dot, then the exact bytes of the request body.

## Prerequisites

* Your webhook signing secret (`whsec_...`), captured at endpoint creation via `POST /api/v1/webhooks` or after a rotation via `POST /api/v1/webhooks/{id}/rotate-secret`. If you only have a masked preview (`whsec_********...<last4>`), rotate to recover a usable secret — the preview never validates.
* A webhook endpoint that receives the raw request body before any JSON parser or middleware touches it (see Step 1 below).

## Step 1 — Preserve the raw body

HMAC is computed over the exact bytes Orbit POSTed. If your framework deserializes the body first (`request.get_json()`, `json.NewDecoder(r.Body)` on the same body stream, an auto-parsing middleware), whatever you re-serialize will differ from what was signed. Preserve the raw bytes and pass them to the verifier.

**Python (Flask):** use `request.data` (bytes) — do not call `request.get_json()` before verification.

**Go (`net/http`):** read `r.Body` into a `[]byte` once, then use that buffer for both signature verification and JSON decoding.

```python theme={null}
# Flask — read raw body once
@app.route("/webhooks/orbit", methods=["POST"])
def handle():
    raw = request.data  # bytes; untouched by any parser
    ...
```

```go theme={null}
// net/http — read body once, reuse for verify and JSON
rawBody, err := io.ReadAll(r.Body)
if err != nil {
    http.Error(w, "read error", http.StatusBadRequest)
    return
}
```

## Step 2 — Python verifier (stdlib `hmac` + `hashlib`)

This is the complete verifier. Drop it into any Python codebase — no external dependencies.

```python theme={null}
import hashlib
import hmac
import time
from typing import Optional


def parse_signature_header(header: str):
    """Split a header like 't=123,v1=abc,v1=def' into (t, [v1s])."""
    t: Optional[str] = None
    v1s: list[str] = []
    for part in header.split(","):
        part = part.strip()
        if "=" not in part:
            continue
        key, _, value = part.partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            v1s.append(value)
    return t, v1s


def verify_webhook_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool:
    """Return True if any v1 candidate matches the HMAC-SHA256 of '<t>.<raw_body>'."""
    if not header:
        return False
    t, v1s = parse_signature_header(header)
    if not t or not v1s:
        return False

    # Replay guard: reject timestamps older than 5 minutes.
    try:
        age = int(time.time()) - int(t)
    except ValueError:
        return False
    if age < 0 or age > 5 * 60:
        return False

    signed = f"{t}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    # Timing-safe against every v1 candidate (covers rotation grace).
    return any(hmac.compare_digest(candidate, expected) for candidate in v1s)
```

Flask handler:

```python theme={null}
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/webhooks/orbit", methods=["POST"])
def handle_webhook():
    # Read headers in canonical → next → legacy order.
    header = (
        request.headers.get("X-Orbit-Signature")
        or request.headers.get("X-Orbit-Signature-Next")
        or request.headers.get("X-Devotel-Signature")
    )
    if not verify_webhook_signature(request.data, header, "whsec_your_secret"):
        return jsonify({"error": "Invalid signature"}), 401

    event = request.get_json()  # safe to parse after verification
    # Process the event...
    return jsonify({"received": True}), 200
```

## Step 3 — Go verifier (`crypto/hmac`)

Standard library only. Works with `net/http`, Gin, Echo, or any framework.

```go theme={null}
package webhook

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

func parseSignatureHeader(header string) (string, []string) {
	var t string
	var v1s []string
	for _, part := range strings.Split(header, ",") {
		part = strings.TrimSpace(part)
		eq := strings.IndexByte(part, '=')
		if eq < 0 {
			continue
		}
		key, value := part[:eq], part[eq+1:]
		switch key {
		case "t":
			t = value
		case "v1":
			v1s = append(v1s, value)
		}
	}
	return t, v1s
}

// VerifySignature returns true if any v1 candidate matches the HMAC-SHA256
// of "<t>.<rawBody>". It rejects malformed or stale (>5 min) signatures.
func VerifySignature(rawBody []byte, header, secret string) bool {
	t, v1s := parseSignatureHeader(header)
	if t == "" || len(v1s) == 0 {
		return false
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return false
	}
	age := time.Now().Unix() - ts
	if age < 0 || age > 5*60 {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))

	for _, candidate := range v1s {
		if hmac.Equal([]byte(candidate), []byte(expected)) {
			return true
		}
	}
	return false
}
```

`net/http` handler:

```go theme={null}
package main

import (
	"encoding/json"
	"io"
	"net/http"
	"os"
)

func handler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "read error", http.StatusBadRequest)
		return
	}

	// Canonical → next → legacy.
	header := r.Header.Get("X-Orbit-Signature")
	if header == "" {
		header = r.Header.Get("X-Orbit-Signature-Next")
	}
	if header == "" {
		header = r.Header.Get("X-Devotel-Signature")
	}

	if !webhook.VerifySignature(rawBody, header, os.Getenv("ORBIT_WEBHOOK_SECRET")) {
		http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
		return
	}

	var event map[string]interface{}
	if err := json.Unmarshal(rawBody, &event); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	// Process the event...
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func main() {
	http.HandleFunc("/webhooks/orbit", handler)
	http.ListenAndServe(":8080", nil)
}
```

## Step 4 — Negative test: prove reject-on-wrong-secret

A verifier that always returns `true` (or never actually compares) is worse than no verifier. Test the negative path once in unit tests and once against a live delivery with the wrong secret.

### Python pytest

```python theme={null}
def test_rejects_wrong_secret():
    body = b'{"id":"evt_1","type":"message.delivered"}'
    t = str(int(time.time()))
    # Build a valid-looking header but signed with a different secret.
    signed = f"{t}.".encode() + body
    bad_sig = hmac.new(b"whsec_wrong", signed, hashlib.sha256).hexdigest()
    header = f"t={t},v1={bad_sig}"

    assert not verify_webhook_signature(body, header, "whsec_correct")


def test_rejects_missing_header():
    body = b'{"id":"evt_1"}'
    assert not verify_webhook_signature(body, None, "whsec_correct")
    assert not verify_webhook_signature(body, "", "whsec_correct")
```

### Go `testing`

```go theme={null}
func TestRejectsWrongSecret(t *testing.T) {
	body := []byte(`{"id":"evt_1","type":"message.delivered"}`)
	ts := strconv.FormatInt(time.Now().Unix(), 10)
	mac := hmac.New(sha256.New, []byte("whsec_wrong"))
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	header := "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))

	if VerifySignature(body, header, "whsec_correct") {
		t.Fatal("verifier accepted a signature built with the wrong secret")
	}
}

func TestRejectsMissingHeader(t *testing.T) {
	body := []byte(`{"id":"evt_1"}`)
	if VerifySignature(body, "", "whsec_correct") {
		t.Fatal("verifier accepted a missing header")
	}
}
```

Run these before deploying. If a "reject" test passes (the verifier returns true on garbage), you have a logic bug — fix it before shipping.

## Rotation: handling `X-Orbit-Signature-Next`

During a secret rotation grace window (7 days), every delivery carries **both** the canonical header (signed with the new secret) and `X-Orbit-Signature-Next` (signed with the previous secret). Your verifier should keep both secrets configured and try both headers:

* Try `X-Orbit-Signature` against the current secret.
* If that fails and `X-Orbit-Signature-Next` is present, try it against the previous secret.
* Accept if either matches.

The header-order code in the handlers above already falls back to `X-Orbit-Signature-Next`. To verify against both secrets, keep both in your config and try each:

```python theme={null}
def verify_with_rotation(raw_body: bytes, headers, current_secret: str, prev_secret: Optional[str]) -> bool:
    primary = headers.get("X-Orbit-Signature") or headers.get("X-Devotel-Signature", "")
    if primary and verify_webhook_signature(raw_body, primary, current_secret):
        return True
    next_header = headers.get("X-Orbit-Signature-Next")
    if prev_secret and next_header:
        return verify_webhook_signature(raw_body, next_header, prev_secret)
    return False
```

After the grace window ends, Orbit stops sending `X-Orbit-Signature-Next` and the previous secret retires. Remove it from your config.

## Troubleshooting

If a delivery you believe is valid fails:

1. **Raw body mutated** — a body parser ran before verification (see Step 1). See [Troubleshooting: signature failures](/webhooks/troubleshooting-signature-failures) section 1 and 5.
2. **Wrong secret** — a masked `whsec_********...<last4>` preview copied in, or a rotation that didn't reach your code. See [Webhook security: signing secret](/webhooks/security#signing-secret).
3. **Stale timestamp** — your server clock is skewed by more than 5 minutes. See [Troubleshooting: signature failures](/webhooks/troubleshooting-signature-failures) section 3.
4. **Missing header** — check the fallback order (canonical → next → legacy). Old queued deliveries may only have `X-Devotel-Signature`.

For the full failure catalog mapped to error codes, see [Troubleshooting: signature verification failures](/webhooks/troubleshooting-signature-failures).

## Continue

* [Webhook security](/webhooks/security) — full protocol spec and all header details
* [Build a durable webhook consumer](/guides/webhook-consumer) — queue, dedupe, ack-fast pattern
* [Troubleshooting: signature failures](/webhooks/troubleshooting-signature-failures) — complete failure catalog
* [Webhook events](/webhooks/events) — event catalog
