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 | testX-GrowGanic-Delivery-Id. Unique-per-attempt UUID. Use this if you need to log or trace individual delivery attempts. Distinct fromarticleId(the idempotency key).X-GrowGanic-Signature: t=<unix>,v1=<hex>. Stripe-style HMAC-SHA256 overtimestamp.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-Afterup 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.