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).
#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://cdn.growganic.io/articles/3b1b.../9f2a18c0-3b5e-4d7a-8c1f-b2e9d4a76301/1473829601.webp",
"tags": ["form-builder", "saas"],
"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. Every field is optional, and these are the only ones we read:
{
"externalId": "your-internal-post-id",
"url": "https://yoursite.com/blog/best-form-builder",
"featuredImageUpdated": true,
"featuredImageUrl": "https://cdn.growganic.io/articles/3b1b.../9f2a18c0-3b5e-4d7a-8c1f-b2e9d4a76301/1473829601.webp"
}externalId (or id, on a create). Your id for the article. We store it and send it back as externalId on every update and delete. On an update, return externalId only if it changed.url (or publishedUrl). The article's public address. We store it so the dashboard's View live link works, and on a create it is what proves your copy exists.featuredImageUpdated + featuredImageUrl. Answer featuredImageUpdated: true (the boolean) with featuredImageUrl equal to the article.featuredImageUrl we sent, exactly, to confirm your copy carries the hero. Anything else is treated as unproven and the hero is sent again next time, which costs you nothing.
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. On a create it is the create's idempotency key, the same on every re-send of that create (also sent asX-GrowGanic-Idempotency-Key). On updates and deletes it is a new id per attempt. Deduping onarticleIdplus the event is always safe.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");
// timingSafeEqual throws on unequal lengths: a malformed signature is
// a plain false here, never an exception.
if (v1.length !== expected.length) return false;
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 });
// Reject anything older than 5 minutes (replay protection).
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(m[1]));
if (age > 300) return NextResponse.json({ error: "stale" }, { status: 401 });
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${m[1]}.${raw}`)
.digest("hex");
if (
m[2].length !== expected.length ||
!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 (a create can arrive twice,
// see Retry policy). An article.delete carries externalId instead.
// 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
What happens when your endpoint does not answer 2xx:
- Creates are never retried blindly. With a public article URL pattern set, a create whose answer never reached us is confirmed by reading that page instead of being sent again. Without one, it may be sent again up to twice, so dedupe on
articleId. - Updates and deletes: on HTTP 408, 500, 502, 503, 504 and 520 to 524 and on network errors we retry up to 3 attempts (250ms, 750ms backoff); any other status is not retried. A 429 honours your
Retry-Afteronce. - 3xx redirects → NOT followed (security policy). We surface a clear error asking you to point GrowGanic at the final URL.
- 401 or 403 on a publish or update → your endpoint refused our credentials. We mark the connection inactive and show a Reconnect badge. Any other 4xx is reported on that article and the connection stays on.
#Test event
When you click Connect 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.