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 withConfigure Webhook or List Webhooks for existing webhooks. Then copy the webhook_secret field from the API response.
Signature Validation
Ospree webhook notifications contain x-ospree-signature and x-ospree-timestamp headers for signature validation. Your application should:
Read the raw request body and the two headers before modifying the payload.
Ensure
x-ospree-timestampis present and within 300 seconds (5 minutes) of the current UTC time to avoid replay attacks.Parse the JSON payload and confirm it includes a
request_id.Build the signing string as
<timestamp>.<request_id>.<raw_body>.Compute an
HMAC-SHA256digest of the signing string using yourWEBHOOK_SECRET.Compare the computed digest with the hex value in
x-ospree-signature(strip thehmac-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
// 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}`);
});# 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)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
}
Last updated