> For the complete documentation index, see [llms.txt](https://docs.ospree.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ospree.io/developers/webhooks/webhook-security.md).

# Webhook Security

To ensure that your webhook implementation is secure, you can verify that the webhook notifications come from Ospree by computing the `hmac-sha256` digest using your unique webhook secret. Each webhook URL has its own secret.

### Finding your webhook secret

To get your webhook secret, first create a new webhook with[Configure Webhook](/developers/webhooks/api-endpoints/configure-webhook.md) or [List Webhooks](/developers/webhooks/api-endpoints/list-webhooks.md) for existing webhooks. Then copy the `webhook_secret` field from the API response.&#x20;

### Signature Validation

Ospree webhook notifications contain `x-ospree-signature` and `x-ospree-timestamp` headers for signature validation. Your application should:

1. Read the **raw request body** and the **two headers** before modifying the payload.
2. Ensure `x-ospree-timestamp` is present and within **300 seconds (5 minutes)** of the current UTC time to avoid replay attacks.
3. Parse the JSON payload and confirm it includes a `request_id`.
4. Build the signing string as `<timestamp>.<request_id>.<raw_body>`.
5. Compute an `HMAC-SHA256` digest of the signing string using your `WEBHOOK_SECRET`.
6. Compare the computed digest with the hex value in `x-ospree-signature` (strip the `hmac-sha256=` prefix) using a constant-time comparison.

#### Example Request Headers

```
  'user-agent': 'Ospree-Webhook/2.0',
  'x-ospree-signature': 'hmac-sha256=4b562a267391cffb6f5847dd5978cca8e305fb21bf1d99dafeb565a05cae6b05',
  'x-ospree-timestamp': '1759839979'
```

#### Example Implementation

{% tabs %}
{% tab title="JavaScript" %}

```javascript
// server.js
const express = require('express');
const crypto = require('crypto');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 8010;
const MAX_AGE_SECONDS = 300;

function verifyWebhook(req, res, next) {
  const secret = process.env.WEBHOOK_SECRET;
  if (!secret) return next(); // skip during local testing without a secret

  const signatureHeader = req.get('X-Ospree-Signature');
  const timestampHeader = req.get('X-Ospree-Timestamp');
  const rawBody = req.body; // Buffer from express.raw

  if (!signatureHeader || !timestampHeader || !Buffer.isBuffer(rawBody)) {
    return res.status(400).send('Missing signature data');
  }

  const [algo, receivedSig] = signatureHeader.split('=', 2);
  if (algo !== 'hmac-sha256') {
    return res.status(400).send('Unsupported signature algorithm');
  }

  const timestamp = Number(timestampHeader);
  if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE_SECONDS) {
    return res.status(400).send('Signature timestamp expired');
  }

  const bodyString = rawBody.toString('utf8');
  const { request_id: requestId } = JSON.parse(bodyString);
  if (!requestId) {
    return res.status(400).send('Missing request_id');
  }

  const signingString = `${timestamp}.${requestId}.${bodyString}`;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signingString, 'utf8')
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(receivedSig, 'hex'), Buffer.from(expectedSig, 'hex'))) {
    return res.status(400).send('Invalid webhook signature');
  }

  req.body = JSON.parse(bodyString); // parsed body is safe to use now
  next();
}

app.post(
  '/api/webhook',
  express.raw({ type: 'application/json' }),
  verifyWebhook,
  (req, res) => {
    console.log('Verified webhook payload:', req.body);
    res.status(200).json({ ok: true });
  }
);

app.listen(PORT, () => {
  console.log(`Listening on http://localhost:${PORT}`);
});
```

{% endtab %}

{% tab title="Python" %}

```python
# server.py
import hmac
import json
import os
import hashlib
from datetime import datetime, timezone

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import uvicorn

app = FastAPI()
MAX_AGE_SECONDS = 300  # 5 minutes


def verify_signature(body_bytes: bytes, signature: str, timestamp: str) -> dict:
    secret = os.getenv("WEBHOOK_SECRET")
    if not secret:
        return json.loads(body_bytes.decode("utf-8"))  # skip verification if no secret

    if not signature or not timestamp:
        raise HTTPException(status_code=400, detail="Missing signature headers")

    algo, _, received_sig = signature.partition("=")
    if algo != "hmac-sha256":
        raise HTTPException(status_code=400, detail="Unsupported signature algorithm")

    try:
        timestamp_int = int(timestamp)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid timestamp")

    now = int(datetime.now(tz=timezone.utc).timestamp())
    if abs(now - timestamp_int) > MAX_AGE_SECONDS:
        raise HTTPException(status_code=400, detail="Signature timestamp expired")

    body_str = body_bytes.decode("utf-8")
    payload = json.loads(body_str)
    request_id = payload.get("request_id")
    if not request_id:
        raise HTTPException(status_code=400, detail="Missing request_id in payload")

    signing_string = f"{timestamp}.{request_id}.{body_str}"
    expected_sig = hmac.new(
        secret.encode("utf-8"),
        signing_string.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(received_sig, expected_sig):
        raise HTTPException(status_code=400, detail="Invalid webhook signature")

    return payload


@app.post("/api/webhook")
async def handle_webhook(request: Request):
    body = await request.body()
    payload = verify_signature(
        body,
        request.headers.get("X-Ospree-Signature"),
        request.headers.get("X-Ospree-Timestamp"),
    )
    # Process payload here
    print("Verified webhook payload:", payload)
    return JSONResponse({"ok": True})


if __name__ == "__main__":
    uvicorn.run("server:app", host="0.0.0.0", port=int(os.getenv("PORT", 8010)), reload=True)
```

{% endtab %}

{% tab title="Go" %}

```ruby
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const (
	defaultPort   = 8010
	maxAgeSeconds = 300
)

func main() {
	http.HandleFunc("/api/webhook", handleWebhook)

	addr := fmt.Sprintf(":%d", portFromEnv())
	log.Printf("Listening on http://localhost%s", addr)
	log.Fatal(http.ListenAndServe(addr, nil))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		respondError(w, http.StatusMethodNotAllowed, "Unsupported method")
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "Failed to read request body")
		return
	}
	defer r.Body.Close()

	payload := map[string]any{}
	if err := json.Unmarshal(body, &payload); err != nil {
		respondError(w, http.StatusBadRequest, "Invalid JSON payload")
		return
	}

	if err := verifySignature(r, body, payload); err != nil {
		respondError(w, err.status, err.message)
		return
	}

	log.Printf("Verified webhook payload: %+v", payload)
	respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}

type httpError struct {
	status  int
	message string
}

func verifySignature(r *http.Request, body []byte, payload map[string]any) *httpError {
	secret := os.Getenv("WEBHOOK_SECRET")
	if secret == "" {
		log.Println("WEBHOOK_SECRET not set; skipping verification")
		return nil
	}

	signatureHeader := r.Header.Get("X-Ospree-Signature")
	timestampHeader := r.Header.Get("X-Ospree-Timestamp")

	if signatureHeader == "" || timestampHeader == "" {
		return &httpError{http.StatusBadRequest, "Missing signature data"}
	}

	parts := strings.SplitN(signatureHeader, "=", 2)
	if len(parts) != 2 || !strings.EqualFold(parts[0], "hmac-sha256") {
		return &httpError{http.StatusBadRequest, "Unsupported signature algorithm"}
	}

	timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
	if err != nil {
		return &httpError{http.StatusBadRequest, "Invalid timestamp"}
	}

	if time.Now().Unix()-timestamp > maxAgeSeconds {
		return &httpError{http.StatusBadRequest, "Signature timestamp expired"}
	}

	requestID, _ := payload["request_id"].(string)
	if requestID == "" {
		return &httpError{http.StatusBadRequest, "Missing request_id"}
	}

	signingString := fmt.Sprintf("%d.%s.%s", timestamp, requestID, string(body))
	expected := computeHMAC(signingString, []byte(secret))

	received, err := hex.DecodeString(parts[1])
	if err != nil {
		return &httpError{http.StatusBadRequest, "Invalid signature format"}
	}

	if !hmac.Equal(received, expected) {
		return &httpError{http.StatusBadRequest, "Invalid webhook signature"}
	}

	return nil
}

func computeHMAC(data string, secret []byte) []byte {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(data))
	return mac.Sum(nil)
}

func respondError(w http.ResponseWriter, status int, message string) {
	log.Printf("Request failed: %s", message)
	respondJSON(w, status, map[string]any{"ok": false, "error": message})
}

func respondJSON(w http.ResponseWriter, status int, payload map[string]any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(payload); err != nil {
		log.Printf("failed to write response: %v", err)
	}
}

func portFromEnv() int {
	if value := os.Getenv("PORT"); value != "" {
		if port, err := strconv.Atoi(value); err == nil && port > 0 {
			return port
		}
		log.Printf("Invalid PORT value %q; using default %d", value, defaultPort)
	}
	return defaultPort
}

```

{% endtab %}
{% endtabs %}
