Webhooks
Webhooks let an external frontend revalidate when your content changes. Floggy POSTs a small payload to your endpoint whenever a post is published or updated, so you can purge a cache or trigger an ISR revalidation instead of polling.
Delivery is best-effort and fire-and-forget: it never blocks your publish, and a failed delivery is recorded on the endpoint (last status, last fired) but not retried.
Events
| Event | Fires when |
|---|---|
post.published |
A post goes from draft to published, or is created already published. |
post.updated |
An already-published post is edited. |
Notes:
- Drafts never fire anything.
- Switching a post back to draft fires nothing.
- Known limitation: scheduled (future-dated) posts do not fire at write time. The event only fires when the post is live now, so a post scheduled for next week will not emit
post.publishedwhen that date arrives.
Managing endpoints
You create and manage webhook endpoints in the Dashboard (Settings -> Developer). Add an endpoint URL (the scheme auto-adds https:// when omitted), pick the events to subscribe to, and toggle delivery on or off. Up to 10 endpoints per account.
When you create an endpoint, the signing secret (whsec_...) is shown once. Store it now - it is used to verify deliveries and is not shown again. Keep it in an env var such as FLOGGY_WEBHOOK_SECRET on the app that receives the webhook.
Delivery payload
Floggy POSTs JSON to your endpoint:
{
"event": "post.published",
"post": {
"id": "V1StGXR8_Z5jdHi6B-myT",
"slug": "launch-day",
"title": "Launch Day",
"publishedAt": "2026-06-29T10:00:00.000Z",
"updatedAt": "2026-06-29T10:00:00.000Z",
"url": "https://projecta.floggy.xyz/launch-day"
},
"timestamp": "2026-06-29T10:00:00.123Z"
}
Headers on the request:
Content-Type: application/jsonUser-Agent: Floggy-Webhooks/1.0X-Floggy-Signature: sha256=<hex>
Verifying the signature
X-Floggy-Signature is sha256= followed by the HMAC-SHA256 of the raw request body, keyed with your endpoint secret, lowercase hex. Recompute it over the exact bytes you received (do not re-serialize the parsed JSON) and compare in constant time.
// Web Crypto. Works in Workers, Deno, Bun, Node 18+, and the browser.
// Pass the RAW request body string and the X-Floggy-Signature header value.
export async function verifyFloggySignature(
rawBody: string,
signatureHeader: string | null,
secret: string,
): Promise<boolean> {
if (!signatureHeader) return false;
const expectedHex = signatureHeader.startsWith("sha256=")
? signatureHeader.slice("sha256=".length)
: signatureHeader;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBuf = await crypto.subtle.sign("HMAC", key, enc.encode(rawBody));
const actualHex = [...new Uint8Array(sigBuf)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return timingSafeEqual(actualHex, expectedHex);
}
// Constant-time string compare: never short-circuit on the first mismatch.
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
Next.js route handler example
// app/api/floggy-webhook/route.ts
import { verifyFloggySignature } from "@/lib/floggy-webhook";
import { revalidatePath } from "next/cache";
export async function POST(req: Request) {
const raw = await req.text(); // read raw body BEFORE JSON.parse
const ok = await verifyFloggySignature(
raw,
req.headers.get("X-Floggy-Signature"),
process.env.FLOGGY_WEBHOOK_SECRET!,
);
if (!ok) return new Response("invalid signature", { status: 401 });
const { event, post } = JSON.parse(raw) as {
event: string;
post: { slug: string };
};
revalidatePath(`/${post.slug}`);
revalidatePath("/");
return new Response("ok");
}
Reject any request whose signature does not match. The signature is the only proof the request came from Floggy and not a spoofed POST to your public endpoint.