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

{% hint style="info" %}
This feature is currently in testing and will be available in production soon.
{% endhint %}

### Finding your webhook secret

To get your webhook secret, first create a new webhook with[Configure Webhook](/ospree-api/webhooks/api-endpoints/configure-webhook.md) or [List Webhooks](/ospree-api/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 %}


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.ospree.io/ospree-api/webhooks/webhook-security.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
