Skip to content
Developers

Webhooks

Get a signed HTTPS POST from Fovea when creatives land, finish AI analysis, collections are created or a Meta sync completes.

Add an endpoint in Settings > Integrations > Webhooks (workspace Admins, paid plans). Pick the events, then copy the signing secret from the dialog: it is shown once.

Events

EventWhen
asset.createdA creative finished uploading to the library
asset.analyzedAI analysis finished for a creative (also after a re-analysis)
collection.createdA collection was created in the app, through the API, over MCP or by Fovi
ad_sync.completedA Meta ads sync finished for the workspace

Send test delivers a webhook.test event straight away so you can check your receiver.

Payload

{
  "id": "4b1f0c8e-...",
  "type": "asset.analyzed",
  "created_at": "2026-10-21T14:03:11+00:00",
  "workspace_id": "0b5e...",
  "data": {
    "id": "8f2c...", "name": "UGC hook v3", "media_type": "video",
    "tags": ["ugc", "summer"], "analysis_status": "completed",
    "hook": "Stop scrolling", "angle": "Problem solution", "style": "UGC",
    "url": "https://foveahq.com/library/8f2c...", "created_at": "2026-10-21T13:58:40"
  }
}

Webhook payloads never include spend, revenue or ROAS. id is the event id: the same event sent to two endpoints shares it, and a retry repeats it, so use it to ignore duplicates.

Headers and signature

HeaderValue
Fovea-EventThe event type
Fovea-Event-IdThe event id
Fovea-DeliveryThis delivery's id
Fovea-Signaturet=<unix seconds>,v1=<hex>

v1 is an HMAC-SHA256 of t, a dot, and the raw request body, keyed with your signing secret. Compare it in constant time and reject timestamps older than five minutes:

import crypto from "node:crypto";

function verify(rawBody, header, secret, toleranceSec = 300) {
  const items = header.split(",").map((p) => p.split("="));
  const t = items.find(([k]) => k === "t")?.[1];
  const sigs = items.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sigs.some((s) => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

Retries

Answer with any 2xx within 10 seconds. Anything else is retried after 30 seconds, 2 minutes, 10 minutes and 1 hour (5 attempts), then marked failed. Redirects are not followed. After 15 failed deliveries in a row the endpoint is turned off; fix it and switch it back on. Every attempt, with your response code and the first 1,000 characters of the body, is in Settings > Integrations > Event log, where a failed delivery can be sent again.

Rotating the secret

Rotate secret issues a new one. For 24 hours each delivery carries two v1= values (new and old secret), so you can deploy the new secret without dropping events.

Endpoint rules

Endpoints must be public https:// URLs. Addresses inside private networks, localhost and cloud metadata addresses are refused, both when you save the endpoint and on every delivery.