Documentation

Webhook integration

GrowGanic POSTs every finished article to a URL you control. Use it to publish to any CMS we don't natively support (custom Next.js or Express stacks, Make, Zapier, n8n, an internal admin tool, or anything that can receive a POST).

Currently our highest-volume integration in production. This page is the source of truth for what GrowGanic sends and what your endpoint should return.

#Payload

We POST JSON with these fields. The body is signed (see below) when you set a signing secret in your Integrations page.

{
  "event": "article.publish",
  "articleId": "9f2a18c0-3b5e-4d7a-8c1f-b2e9d4a76301",
  "timestamp": "2026-05-11T18:34:17.000Z",
  "article": {
    "title": "Best form builder for small business",
    "content": "Best form builder...\n\nMarkdown body of the article.",
    "contentHtml": "<p>HTML body, ready to render.</p>",
    "slug": "best-form-builder",
    "metaTitle": "Best Form Builder for Small Business (2026)",
    "metaDescription": "Honest, tested guide to the best form builders for solo founders.",
    "excerpt": "Honest, tested guide to the form builders solo founders trust.",
    "schemaMarkup": {
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": "Best form builder for small business",
      "datePublished": "2026-05-11"
    },
    "featuredImageUrl": "https://images.example.com/hero.png",
    "tags": ["form-builder", "saas"],
    "canonicalUrl": "https://yoursite.com/blog/best-form-builder",
    "status": "publish"
  }
}

articleId is your idempotency key. The same articleId may arrive more than once (we retry on transient failures), so treat the second occurrence as an update, not a new record.

#Response we expect

Return HTTP 200 with this JSON shape. We store the id and urlagainst the article so the user's dashboard shows a working "View live" link.

{
  "id": "your-internal-post-id",
  "url": "https://yoursite.com/blog/best-form-builder"
}

Both fields are optional. If you don't return JSON, we fall back to a deterministic id derived from articleId so subsequent updates remain addressable.

#Headers we send

  • User-Agent: GrowGanic-Webhook/1.0. Identifies our requests so you can allowlist them in WAF rules.
  • X-GrowGanic-Event: publish | update | delete | test
  • X-GrowGanic-Delivery-Id. Unique-per-attempt UUID. Use this if you need to log or trace individual delivery attempts. Distinct from articleId (the idempotency key).
  • X-GrowGanic-Signature: t=<unix>,v1=<hex>. Stripe-style HMAC-SHA256 over timestamp.body (the unix timestamp, then a literal dot, then the raw request body). Sent only when you set a signing secret in the Integrations page.
  • Content-Type: application/json

#Verify the signature

When you set a signing secret in the GrowGanic Integrations page, we sign every payload. Verifying on your side rejects spoofed requests and replays. The window is 5 minutes; older deliveries should be dropped.

Generic Node:

import crypto from "node:crypto";

export function verifyGrowGanic(req, secret) {
  // Signature is Stripe-style: t=<unix>,v1=<hex>
  const header = req.headers["x-growganic-signature"];
  if (!header) return false;
  const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
  if (!m) return false;
  const [, timestamp, v1] = m;
  // Reject anything older than 5 minutes to prevent replay attacks
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > 300) return false;
  const rawBody = req.rawBody.toString("utf8");
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(v1, "hex"),
  );
}

Next.js App Router route handler:

// app/api/growganic/route.ts (Next.js App Router)
import { NextResponse } from "next/server";
import crypto from "node:crypto";

const SECRET = process.env.GROWGANIC_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const raw = await req.text();
  const sig = req.headers.get("x-growganic-signature") ?? "";
  const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(sig);
  if (!m) return NextResponse.json({ error: "bad sig" }, { status: 401 });
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${m[1]}.${raw}`)
    .digest("hex");
  if (
    !crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(m[2], "hex"),
    )
  ) {
    return NextResponse.json({ error: "bad sig" }, { status: 401 });
  }

  const payload = JSON.parse(raw);
  // Idempotency: dedupe on articleId + event.
  // Persist payload.article to your CMS / DB / file system here.

  return NextResponse.json({
    id: payload.articleId,
    url: `https://yoursite.com/blog/${payload.article.slug}`,
  });
}

#Retry policy

We retry transient failures with exponential backoff:

  • 5xx and 408 → retry up to 3 attempts (250ms, 750ms backoff).
  • 429 → honor Retry-After up to 5 minutes, then re-enqueue.
  • 3xx redirects → NOT followed (security policy). We surface a clear error asking you to point GrowGanic at the final URL.
  • 4xx other than 408/429→ considered permanent. We mark your connection inactive and surface a notification in the user's dashboard.

#Test event

When you click Connect & Test on the Integrations page, we send a single payload with { "event": "test", "timestamp": "…" } and X-GrowGanic-Event: test. Return 200 to mark the connection active. If your receiver requires a full article shape, short-circuit on the test event to avoid a 400 just for setup.

#SSRF policy

For security we refuse to POST to private IP ranges (10/8, 172.16/12, 192.168/16, 127/8, link-local, cloud metadata addresses, etc) and resolve DNS at send time to defeat rebinding attacks. If your endpoint is behind a tunnel for development, expose it via ngrok or similar.

#Set it up

Go to the Integrations page, choose Custom Integration, paste your endpoint URL and (optionally) a signing secret. We send a test payload immediately and confirm the round-trip works before saving.