Docs/CMS Integrations/Generic Webhook Integration

Generic Webhook Integration

CMS Integrations
10 min read

Generic Webhook Integration

The Generic Webhook integration lets you connect iContentForge to any custom backend — whether it's a headless CMS, a static site generator, a NestJS/Express server, or a serverless function. When an article is ready, iContentForge sends a signed POST request containing the full article content as JSON.

Configuration

Navigate to your project's Settings → CMS Integration and select "Generic Webhook" from the connector list. You'll need to provide two settings:

  1. Endpoint URL — The full https:// URL of your server that will receive the POST request.
  2. Secret Key — A shared string used to sign each request. Your server uses this to verify the request came from iContentForge.
鈿狅笍

Your endpoint must use HTTPS. iContentForge will not deliver webhooks to plain http:// URLs.


Default Payload (No Template)

When no custom template is configured, iContentForge sends a flat JSON object with all article fields:

馃挕

html is the recommended field. As of March 2026, iContentForge converts all Markdown to clean HTML using a full parser before delivery. Headings, bold text, bullet lists, and comparison tables are all properly rendered. Always prefer html for direct insertion into your CMS. Use markdown / contentMd only if your platform accepts Markdown natively (e.g. Medium).

{
  "pageId":          "cma1b2c3d4e5f6g7h8i9j0",
  "title":           "How to Optimize Images for SEO in 2026",
  "slug":            "how-to-optimize-images-for-seo",
  "html":            "<h2>Introduction</h2><p>Image optimization is...</p>",
  "markdown":        "## Introduction\n\nImage optimization is...",
  "contentMd":       "## Introduction\n\nImage optimization is...",
  "jsonLd":          "{\"@context\":\"https://schema.org\",\"@type\":\"BlogPosting\",\"headline\":\"...\",\"@graph\":[{\"@type\":\"FAQPage\",\"mainEntity\":[...]}]}",
  "excerpt":         "Learn the key techniques for compressing and tagging images.",
  "metaTitle":       "Image SEO: A Complete Optimization Guide | YourSite",
  "metaDescription": "Step-by-step guide to image SEO. Reduce file sizes and improve Core Web Vitals.",
  "keyword":         "image seo",
  "wordCount":       1250,
  "readingTime":     5,
  "categories":      ["SEO", "Technical SEO"],
  "tags":            ["images", "page speed", "alt text"],
  "publishedAt":     "2026-03-30T15:00:00.000Z",
  "projectName":     "My SEO Blog",
  "projectDomain":   "https://example.com",
  "authorName":      "Alex Johnson"
}

Default Payload Field Reference

FieldTypeDescription
pageIdstringUnique internal ID of the article in iContentForge.
titlestringFull article title.
slugstringURL-friendly slug (kebab-case).
htmlstringPreferred. Article content as clean HTML — headings, bold, lists, and tables fully rendered. Does NOT contain the <script> tag.
jsonLdstring | undefined🔍 SEO. Raw JSON-LD structured data (BlogPosting + FAQPage schema). Inject into your page's <head> as <script type="application/ld+json">. Present only when the article has structured data.
markdownstringClean article body as raw Markdown (no JSON-LD block). Alias for contentMd.
contentMdstringClean Markdown body (legacy field name, kept for backwards compatibility).
excerptstringShort summary / first paragraph.
metaTitlestringSEO meta title.
metaDescriptionstringSEO meta description.
keywordstringPrimary target keyword.
wordCountintegerApproximate word count.
readingTimeintegerEstimated reading time in minutes.
categoriesstring[]Array of assigned category names.
tagsstring[]Array of assigned tag names.
publishedAtstringISO 8601 publication timestamp.
projectNamestringName of the iContentForge project.
projectDomainstringDomain associated with the project.
authorNamestringAuthor display name.

Template Mode (Custom JSON Shape)

If your API requires a different JSON structure or different field names, enable Template Mode in the connector settings. Write your JSON payload using {{variableName}} placeholders — iContentForge will substitute the actual values before sending.

Available Template Variables

All fields from the default payload are available as template variables:

VariableType
{{title}}string
{{slug}}string
{{html}}string (HTML) — ⭐ preferred
{{jsonLd}}string (raw JSON-LD) — inject into <head>
{{markdown}}string (Markdown)
{{contentMd}}string (Markdown, legacy alias)
{{excerpt}}string
{{metaTitle}}string
{{metaDescription}}string
{{keyword}}string
{{wordCount}}number
{{readingTime}}number
{{categories}}JSON array
{{tags}}JSON array
{{publishedAt}}ISO 8601 string
{{projectName}}string
{{projectDomain}}string
{{authorName}}string

Template Example

The key names on the left are what your receiver will see — you can name them anything. The {{variable}} on the right is where the iContentForge value is injected.

{
  "title":        "{{title}}",
  "slug":         "{{slug}}",
  "content":      "{{html}}",
  "summary":      "{{excerpt}}",
  "seo_title":    "{{metaTitle}}",
  "seo_desc":     "{{metaDescription}}",
  "tags":         "{{tags}}",
  "published_at": "{{publishedAt}}"
}
鈿狅笍

Field name mismatch is the most common mistake. When using Template Mode, your receiver must read exactly the key names you defined on the left (e.g. content, seo_title), not the iContentForge variable names on the right (e.g. html, metaTitle).

For example, if your template has "content": "{{html}}", your server must read payload.contentnot payload.html.

鈩癸笍

Array variables ({{categories}}, {{tags}}) are serialized as valid JSON arrays automatically. Do not add surrounding quotes.

Correct: "tags": {{tags}}
Wrong: "tags": "{{tags}}"


JSON-LD Structured Data

iContentForge automatically generates Schema.org structured data (JSON-LD) for every article:

  • BlogPosting — article metadata: headline, author, date, description, keywords
  • FAQPage — if the article includes an FAQ section, each Q&A pair is included in the schema

The jsonLd field in the payload contains the raw JSON string (without the <script> wrapper). Your receiver should inject it into the page <head> like this:

<!-- In your page <head> -->
<script type="application/ld+json">
  <!-- paste payload.jsonLd here -->
</script>

Next.js Example (App Router)

// After saving the article to your database:
import type { Metadata } from "next";

// In your blog post page component:
export default function BlogPost({ post }: { post: Post }) {
  return (
    <>
      {post.jsonLd && (
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: post.jsonLd }}
        />
      )}
      <article dangerouslySetInnerHTML={{ __html: post.html }} />
    </>
  );
}
馃挕

Place the <script type="application/ld+json"> tag as close to the top of <head> as possible. Google recommends structured data to appear before the main page content in the DOM.

鈩癸笍

jsonLd is undefined for articles generated before March 2026 or if schema generation was skipped. Always check if (payload.jsonLd) before using it.


Security & Signature Verification

Every webhook request iContentForge sends includes an HMAC-SHA256 signature in the HTTP header. You must verify this signature to ensure the request is genuine and has not been tampered with.

How the Signature Is Computed

HMAC-SHA256(secret, rawRequestBodyBytes) → hex string

The signature is computed over the raw request body bytes exactly as received — before any JSON parsing. This is critical: if you call JSON.parse() and then JSON.stringify() on the body, the byte sequence may change (field ordering, whitespace), causing a signature mismatch.

HTTP Headers Sent by iContentForge

HeaderExample ValueDescription
Content-Typeapplication/jsonBody is JSON-encoded.
X-ContentForge-Signaturea3f2...dc91HMAC-SHA256 hex digest of the raw body.
X-ContentForge-Eventpage.publishedEvent type that triggered the webhook.

Receiver Implementation Examples

Node.js / Next.js App Router

// app/api/webhook/contentforge/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export async function POST(req: NextRequest) {
  // 1. Read the RAW body BEFORE any parsing — required for correct signature verification
  const rawBody = await req.text();

  // 2. Read the signature header
  const signature =
    req.headers.get("X-ContentForge-Signature") ??
    req.headers.get("x-contentforge-signature") ?? // case-insensitive fallback
    "";

  // 3. Compute expected signature using the raw body bytes
  const secret = process.env.CONTENTFORGE_SECRET!;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)   // ← MUST be the raw string, not JSON.stringify(parsed)
    .digest("hex");

  // 4. Timing-safe comparison (prevents timing attacks)
  const valid = (() => {
    try {
      return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    } catch {
      return false;
    }
  })();

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

  // 5. NOW safe to parse
  const payload = JSON.parse(rawBody);

  const title = payload.title;
  const slug  = payload.slug;

  // ✅ Always prefer `html` — fully rendered, safe to insert into your CMS directly.
  // Fall back to `markdown` / `contentMd` only if your renderer handles Markdown natively.
  const body = payload.html ?? payload.markdown ?? payload.contentMd;

  // await db.posts.create({ title, slug, body });
  console.log(`[icf-webhook] Received: ${slug}`);

  return NextResponse.json({ success: true }, { status: 200 });
}

NestJS

// blog.controller.ts — use RawBodyMiddleware so rawBody Buffer is available
import * as crypto from "crypto";

@Post("webhook/contentforge")
async handleWebhook(
  @Req() req: Request,
  @Res() res: Response,
  @Headers("x-contentforge-signature") sig: string,
) {
  const rawBody = (req as any).rawBody as Buffer;
  const secret  = process.env.CONTENTFORGE_WEBHOOK_SECRET!;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const valid = (() => {
    try {
      return crypto.timingSafeEqual(Buffer.from(sig ?? ""), Buffer.from(expected));
    } catch { return false; }
  })();

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

  const payload = JSON.parse(rawBody.toString());

  // Prefer html — rendered and safe to insert into your CMS directly
  const body = payload.html ?? payload.contentMd;

  // await this.blogService.createPost({ title: payload.title, body, slug: payload.slug });
  return res.status(200).json({ received: true });
}

Express.js

const express = require("express");
const crypto  = require("crypto");
const app     = express();

// ⚠️ Use express.raw() NOT express.json() — to preserve raw body bytes for HMAC
app.use("/webhook/contentforge", express.raw({ type: "application/json" }));

app.post("/webhook/contentforge", (req, res) => {
  const rawBody   = req.body;  // Buffer
  const signature = req.headers["x-contentforge-signature"] ?? "";
  const secret    = process.env.CONTENTFORGE_SECRET;

  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const payload = JSON.parse(rawBody.toString());

  // ✅ Use html for direct rendering
  const body = payload.html ?? payload.contentMd;
  console.log("Published:", payload.title);

  res.status(200).json({ received: true });
});

Python / Flask

import hashlib, hmac, json, os
from flask import request, jsonify, abort

@app.route("/webhook/contentforge", methods=["POST"])
def handle_icf_webhook():
    secret    = os.environ["CONTENTFORGE_SECRET"].encode()
    signature = request.headers.get("X-ContentForge-Signature", "")
    raw_body  = request.get_data()  # ← raw bytes, NOT request.json

    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401)

    payload = json.loads(raw_body)

    # ✅ Prefer html field — fully rendered HTML ready for your CMS
    body = payload.get("html") or payload.get("contentMd")
    print(f"Published: {payload['title']}")

    return jsonify({"received": True}), 200
馃挕

Always use timingSafeEqual / hmac.compare_digest for signature comparison. Regular string equality (==) is vulnerable to timing attacks that could leak your secret key.


Response & Retry Logic

Your responseiContentForge behavior
2xxSuccess — article marked as published.
4xx / 5xxFailure — error saved to publish log.
Timeout (>30s)Failure — treated as delivery failure.

Failed deliveries are logged in the Publish Hub → Error Logs. You can retry them manually from the dashboard.


Testing Your Endpoint

Use webhook.site to generate a temporary URL and inspect the full payload — including all headers — before writing your receiver code.

You can also trigger a test delivery from the iContentForge dashboard by clicking "Send Test" after saving your connector configuration.


Next Steps