Skip to content

Webhook Integration & Verification Guide

Build robust, real-time event-driven integrations that react to FOTOhub events without polling.

All webhook deliveries include an X-FotoHub-Signature header computed as an HMAC-SHA256 hex digest of the raw request payload.


Security Architecture

mermaid
flowchart TD
    A["FotoHub Engine Event"] --> B["Sign Payload (HMAC-SHA256(raw_bytes, secret))"]
    B --> C["POST to Partner Endpoint (Header: X-FotoHub-Signature)"]
    C --> D["Partner Gateway"]
    D --> E{"1. Check Signature (Constant-Time Compare)"}
    E -->|"Invalid"| F["Reject: 401 Unauthorized"]
    E -->|"Valid"| G{"2. Replay Defense (abs(now - timestamp) < 300s)"}
    G -->|"Stale / Future"| H["Reject: 400 Bad Request"]
    G -->|"Fresh"| I{"3. Idempotency Check (Redis SETNX / SQL PK)"}
    I -->|"Duplicate"| J["Acknowledge: 200 OK (Skip Duplicate)"]
    I -->|"New Event"| K["Acknowledge: 200 OK & Dispatch Async Worker"]

Production Verification Code Samples

python
import hmac
import hashlib
from datetime import datetime, timezone
from fastapi import FastAPI, Request, HTTPException, status
from pydantic import BaseModel

app = FastAPI()
WEBHOOK_SECRET = "whsec_your_secret_from_console"
MAX_SKEW_SECONDS = 300  # 5-minute replay defense window

class WebhookPayload(BaseModel):
    event: str
    timestamp: str
    attempt: int
    data: dict

def verify_signature(raw_body: bytes, signature_header: str) -> bool:
    if not signature_header:
        return False
    expected = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature_header, expected)

@app.post("/webhooks/fotohub")
async def handle_webhook(request: Request):
    # Step 1: Read raw body bytes BEFORE JSON parsing
    raw_body = await request.body()
    signature = request.headers.get("X-FotoHub-Signature", "")

    # Step 2: Constant-time signature verification
    if not verify_signature(raw_body, signature):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid signature"
        )

    # Step 3: Parse JSON payload
    payload = await request.json()
    event_timestamp_str = payload.get("timestamp")
    
    # Step 4: Replay attack defense
    try:
        event_time = datetime.fromisoformat(event_timestamp_str.replace("Z", "+00:00"))
        now = datetime.now(timezone.utc)
        if abs((now - event_time).total_seconds()) > MAX_SKEW_SECONDS:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Request timestamp outside 300s tolerance window"
            )
    except (ValueError, TypeError):
        raise HTTPException(status_code=400, detail="Invalid timestamp format")

    # Step 5: Process event asynchronously (or push to queue)
    event_type = payload.get("event")
    data = payload.get("data")
    print(f"Verified event {event_type} (attempt {payload.get('attempt')})")

    return {"received": True}
typescript
import express, { Request, Response } from "express";
import crypto from "crypto";

const app = express();
const WEBHOOK_SECRET = "whsec_your_secret_from_console";
const MAX_SKEW_SECONDS = 300;

// IMPORTANT: capture raw body Buffer for HMAC verification
app.use("/webhooks/fotohub", express.raw({ type: "application/json" }));

app.post("/webhooks/fotohub", (req: Request, res: Response) => {
  const signature = req.headers["x-fotohub-signature"] as string;
  const rawBody = req.body as Buffer;

  if (!signature || !rawBody) {
    return res.status(401).json({ error: "Missing signature or body" });
  }

  // 1. Calculate HMAC-SHA256
  const expectedSignature = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");

  // 2. Constant-time comparison
  const isValid =
    signature.length === expectedSignature.length &&
    crypto.timingSafeEqual(
      Buffer.from(signature, "utf-8"),
      Buffer.from(expectedSignature, "utf-8")
    );

  if (!isValid) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const payload = JSON.parse(rawBody.toString("utf-8"));

  // 3. Replay attack defense
  const eventTime = new Date(payload.timestamp).getTime();
  const now = Date.now();
  if (Math.abs(now - eventTime) > MAX_SKEW_SECONDS * 1000) {
    return res.status(400).json({ error: "Timestamp skew exceeds 300s window" });
  }

  // 4. Return 200 immediately
  res.status(200).json({ received: true });
});

app.listen(3000, () => console.log("Webhook server listening on :3000"));
typescript
// app/api/webhooks/fotohub/route.ts
import { NextRequest, NextResponse } from "next/server";

const WEBHOOK_SECRET = process.env.FOTOHUB_WEBHOOK_SECRET!;
const MAX_SKEW_MS = 300 * 1000;

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const signature = req.headers.get("x-fotohub-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 401 });
  }

  // Verify using Web Crypto API (Edge-compatible)
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(WEBHOOK_SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const calculatedSigBuffer = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(rawBody)
  );

  const calculatedHex = Array.from(new Uint8Array(calculatedSigBuffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  // Timing safe equality
  if (calculatedHex !== signature) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const payload = JSON.parse(rawBody);

  // Timestamp replay defense
  const eventTime = new Date(payload.timestamp).getTime();
  if (Math.abs(Date.now() - eventTime) > MAX_SKEW_MS) {
    return NextResponse.json({ error: "Expired timestamp" }, { status: 400 });
  }

  return NextResponse.json({ received: true });
}
go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"math"
	"net/http"
	"time"
)

const webhookSecret = "whsec_your_secret_from_console"
const maxSkewSeconds = 300.0

type WebhookEnvelope struct {
	Event     string                 `json:"event"`
	Timestamp string                 `json:"timestamp"`
	Attempt   int                    `json:"attempt"`
	Data      map[string]interface{} `json:"data"`
}

func verifySignature(rawBody []byte, signature string) bool {
	mac := hmac.New(sha256.New, []byte(webhookSecret))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(signature), []byte(expected))
}

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

	sig := r.Header.Get("X-FotoHub-Signature")
	if !verifySignature(rawBody, sig) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	var env WebhookEnvelope
	if err := json.Unmarshal(rawBody, &env); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// Replay defense
	t, err := time.Parse(time.RFC3339, env.Timestamp)
	if err != nil || math.Abs(time.Since(t).Seconds()) > maxSkewSeconds {
		http.Error(w, "Timestamp outside 300s window", http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"received":true}`))
}

func main() {
	http.HandleFunc("/webhooks/fotohub", webhookHandler)
	http.ListenAndServe(":3000", nil)
}
bash
# Test webhook endpoint locally with generated HMAC-SHA256 signature
SECRET="whsec_your_secret_from_console"
PAYLOAD='{"event":"generation.completed","timestamp":"'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'","attempt":1,"data":{"job_id":"job_123"}}'

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -X POST http://localhost:3000/webhooks/fotohub \
  -H "Content-Type: application/json" \
  -H "X-FotoHub-Signature: $SIGNATURE" \
  -d "$PAYLOAD"

Idempotency Implementation

Webhooks may be retried across network interruptions. Always guarantee single-processing semantics using atomic locks or database constraints.

python
import redis

r = redis.Redis(host="localhost", port=6379, db=0)

def is_duplicate_event(event_type: str, entity_id: str) -> bool:
    key = f"fotohub:webhook:{event_type}:{entity_id}"
    # Atomically sets the key only if it does not exist (24-hour TTL)
    is_new = r.set(key, "processed", nx=True, ex=86400)
    return not is_new
sql
CREATE TABLE processed_webhooks (
    event_id text PRIMARY KEY,
    event_type text NOT NULL,
    processed_at timestamptz DEFAULT now()
);

-- When processing:
INSERT INTO processed_webhooks (event_id, event_type)
VALUES ('shorts.clip.rendered:clip_3821a9ef', 'shorts.clip.rendered')
ON CONFLICT (event_id) DO NOTHING;
-- Check affected rows: if 0, skip downstream processing.

Retry Schedules & Delivery Guarantees

FOTOhub delivers webhooks with automatic exponential backoff:

AttemptBackoff DelayCumulative TimeOutcome on Failure
Attempt 1Immediate0sRetry if 5xx or connection timeout
Attempt 21 second+1sRetry if 5xx or connection timeout
Attempt 34 seconds+5sMarked as failed in delivery log

IMPORTANT

HTTP 4xx Non-Retry Policy: If your endpoint returns an HTTP 4xx status code (e.g. 401 Unauthorized or 400 Bad Request), delivery fails immediately and will not be retried. Only 5xx server errors and network timeouts trigger retries.

Querying Dead-Letter Logs

If an endpoint is unreachable during a generation, you can query delivery logs via the API to inspect response codes and payload contents:

bash
curl -X GET "https://apis.fotohub.app/v1/webhooks/wh_98a12bc/deliveries?status=failed&limit=10" \
  -H "Authorization: Bearer $FOTOHUB_API_KEY"