Publish with webhooks
Send every article you publish in GrowWriter straight to your own website. Set up the webhook integration step by step, with ready-to-use code.
Your website might not run on one of the platforms GrowWriter connects to out of the box. That's fine. With the webhook integration, GrowWriter delivers every article straight to your site the moment you hit Publish. Custom stack, homemade CMS, static site, anything. If it can receive a web request, it can receive your articles.
This guide walks you through the whole setup. No webhook experience needed. By the end, publishing an article in GrowWriter will create or update a post on your own website automatically.
What is a webhook?
A webhook is the simplest way for two apps to talk: when something happens in one app, it sends a message to a web address you choose.
Think of it as a delivery service. You give GrowWriter your address (a URL on your website). When an article is ready, GrowWriter knocks on that door and hands over the full article as structured data. Your site takes it from there: save it, format it, publish it your way.
Why this beats the alternatives:
- No copy-paste. The article arrives with its title, content, images, and SEO fields already organized.
- No checking for updates. Your site doesn't have to ask "anything new?" every few minutes. GrowWriter only calls when there's something to deliver.
- No platform lock-in. Webhooks speak plain HTTPS and JSON, which every web stack understands.
How it works
The whole flow takes about twenty seconds to understand:
- You give GrowWriter a URL on your site, and GrowWriter gives you a secret key in return.
- When you publish an article, GrowWriter sends one
POSTrequest to your URL. The request body is the complete article as JSON, and the request is signed with your secret so you can be sure it really came from GrowWriter. - Your site verifies the signature, saves the article, and answers with a
success status (any
2xx). Done.
Set it up, step by step
Connect the webhook in GrowWriter
Open the Publish page in GrowWriter and find the Webhook card under Automation. Click Connect.
Enter your Endpoint URL. This is the address on your website that will
receive articles, for example https://example.com/webhooks/growwriter. Two
rules: it must start with https://, and it must be reachable from the
internet (a localhost address won't work).
Don't have the endpoint built yet? No problem. GrowWriter doesn't test the URL at this point, so you can connect first and write the code right after.
Click Create Connection.

Copy your signing secret
The dialog now shows your Signing Secret. It starts with whsec_ and it
proves that a request really came from GrowWriter.
Click Copy secret and store it somewhere safe on your server, typically as an environment variable:
GROWWRITER_WEBHOOK_SECRET=whsec_your_secret_hereThis secret is shown only once
GrowWriter stores it encrypted and can't show it again. If you lose it, disconnect and reconnect the webhook to get a fresh one. Reconnecting always creates a new secret, and the old one stops working.

Build your receiver
Now add the endpoint to your website. It needs to do four things: read the
raw request body, verify the signature, answer quickly with a 2xx status,
and save the article.
Jump to the code examples below and copy the one that matches your stack. Deploy it at the URL you entered in step 1.
Send a test event
Back in the GrowWriter dialog, click Send test event. GrowWriter sends a
small, signed ping to your endpoint. It looks like this:
{
"version": "2026-08-01",
"event": "ping",
"deliveryId": "5f0c9a1e-4d2b-4f6a-9c3e-8b7d6a5e4f3c",
"sentAt": "2026-09-01T09:30:00.000Z"
}Note there's no article in it. That's how your code can tell a test from the real thing.
When your endpoint answers with a 2xx, the dialog shows Test event
delivered. Click Done and you're connected. If you see Test event
failed instead, check the troubleshooting section
below and try again. A failed test never breaks the connection, so you can
retry as often as you like.
Publish an article
Open one of your finished articles and publish it. Pick your webhook as a
destination and GrowWriter delivers the full article to your endpoint as an
article.published event.
Publish the same article again later (after edits, for example) and your
endpoint receives an article.updated event instead, with the same article
id. That id is how your site knows to update the existing post instead of
creating a duplicate.
Code examples
Both examples below do the full job: verify the signature, answer the test ping, respond fast, and hand the article to your own save logic.
One rule matters more than all the others: verify the signature over the raw request body, exactly as it arrived. If your framework parses the JSON first and you re-serialize it, the bytes change and the signature check fails.
import { createHmac, timingSafeEqual } from 'node:crypto';
// How many seconds old a delivery may be before we reject it.
// Protects against someone replaying an old captured request.
const MAX_AGE_SECONDS = 5 * 60;
function verifySignature(rawBody: string, signatureHeader: string, secret: string) {
// The header looks like: t=1756718400,v1=5257a869e7...
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => part.split('='))
);
// 1. The timestamp must be recent. GrowWriter re-signs every retry,
// so a fresh delivery always carries a fresh timestamp.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!(age < MAX_AGE_SECONDS)) {
return false;
}
// 2. Recompute the signature: HMAC-SHA256 of "timestamp.rawBody"
// using your signing secret, hex-encoded.
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
// 3. Compare in constant time so the check can't be timed.
const provided = Buffer.from(parts.v1 ?? '', 'hex');
const wanted = Buffer.from(expected, 'hex');
return provided.length === wanted.length && timingSafeEqual(provided, wanted);
}
export async function POST(request: Request) {
// Read the body as raw text FIRST. Don't use request.json() before
// verifying: the signature covers these exact bytes.
const rawBody = await request.text();
const signature = request.headers.get('x-growwriter-signature') ?? '';
const secret = process.env.GROWWRITER_WEBHOOK_SECRET ?? '';
if (!verifySignature(rawBody, signature, secret)) {
return new Response('Invalid signature', { status: 401 });
}
const payload = JSON.parse(rawBody);
// The "Send test event" button sends a ping with no article in it.
// Answering 200 here is what makes the test succeed.
if (payload.event === 'ping') {
return new Response('pong', { status: 200 });
}
// GrowWriter retries failed deliveries with the SAME delivery id.
// If you've already processed this id, just say OK again.
const deliveryId = request.headers.get('x-growwriter-delivery');
// if (await alreadyProcessed(deliveryId)) return new Response('OK');
const { article } = payload;
// Upsert on article.id: "article.published" means it's new,
// "article.updated" means you've seen this id before.
// Save what you need. article.content gives you the same article
// as markdown, as HTML, and as structured blocks. Pick one.
await saveArticle({
externalId: article.id, // stable across republishes
title: article.title,
slug: article.slug,
html: article.content.html,
metaDescription: article.metaDescription,
publishedAt: article.dates.firstPublishedAt,
updatedAt: article.dates.modifiedAt,
});
// GrowWriter waits at most 10 seconds. Answer fast; do heavy work
// (like downloading images) in a background job after responding.
return new Response('OK', { status: 200 });
}What's in the payload
Every delivery is a POST with Content-Type: application/json and three
headers you'll care about:
| Header | What it tells you |
|---|---|
X-GrowWriter-Event | article.published, article.updated, or ping |
X-GrowWriter-Delivery | Unique id for this delivery. Retries keep the same id, so use it to avoid processing twice |
X-GrowWriter-Signature | t=<timestamp>,v1=<signature> for the verification shown above |
Here's a trimmed example of the body for a real article:
{
"version": "2026-08-01",
"event": "article.published",
"deliveryId": "9d2f7c1e-6a3b-4c8d-b5e0-1f2a3b4c5d6e",
"sentAt": "2026-09-01T09:30:00.000Z",
"publishRecord": {
"firstPublishedAt": "2026-09-01T09:30:00.000Z",
"lastPublishedAt": "2026-09-01T09:30:00.000Z"
},
"article": {
"id": "cme8x2k9r0001l7042n5q8w3v",
"language": "en",
"title": "How to Choose Running Shoes That Actually Fit",
"slug": "how-to-choose-running-shoes",
"metaDescription": "Learn how to pick running shoes that fit your feet and your stride, with simple checks you can do in any store.",
"summary": "A practical guide to finding running shoes...",
"keywords": {
"primary": "how to choose running shoes",
"secondary": ["running shoe fit", "running shoe guide"]
},
"takeaways": ["Fit beats brand every time", "..."],
"coverImage": {
"url": "https://cdn.example.com/articleimages/.../cover.jpg",
"altText": "Runner lacing up shoes on a park bench"
},
"images": [
{
"url": "https://cdn.example.com/articleimages/.../gait-check.jpg",
"altText": "Side view of a runner mid-stride",
"caption": "A quick gait check tells you more than any spec sheet.",
"sectionSlug": "check-your-gait"
}
],
"content": {
"markdown": "Finding the right pair starts with...",
"html": "<p>Finding the right pair starts with...</p>",
"blocks": [{ "type": "paragraph", "children": [{ "type": "text", "text": "..." }] }]
},
"dates": {
"createdAt": "2026-08-28T14:00:00.000Z",
"firstPublishedAt": "2026-09-01T09:30:00.000Z",
"modifiedAt": "2026-09-01T09:29:45.000Z"
},
"brand": {
"name": "Stride Lab",
"websiteUrl": "https://stridelab.example.com"
}
}
}The fields that do the heavy lifting:
| Field | How to use it |
|---|---|
article.id | Your stable key. Store it, and update the existing post when the same id returns |
article.content | The article in three formats: markdown, ready-made html, and structured blocks for custom renderers. Use whichever fits your site |
article.title | The headline. It's not repeated inside the content, so render it yourself |
article.metaDescription | At most 160 characters, written for your <meta name="description"> tag |
article.dates | Map firstPublishedAt to your published date and modifiedAt to your updated date |
article.coverImage / article.images | Cover plus every inline image in order, each with alt text and captions when available |
Good to know
- Download the images. Image URLs are public and work at delivery time,
but GrowWriter isn't a long-term image host. Save each image (cover and
inline) to your own storage, and remember the same URLs also appear inside
content.htmlandcontent.markdown, so rewrite them there too. - Deliveries retry themselves. If your endpoint is briefly down,
GrowWriter tries up to 3 times (waiting 1 second, then 3). Each retry is
freshly signed and keeps the same
X-GrowWriter-Deliveryid. - Any
2xxcounts as success. Redirects don't. If your site answers301or308, the delivery fails, so point the webhook at the final URL. - There's no delete event. GrowWriter only ever creates and updates. Removing a post from your site is always your call.
- One connection, one secret. Disconnecting and reconnecting the webhook creates a new secret and retires the old one. Update your environment variable when you do.
Troubleshooting
"Your endpoint did not respond in time" — GrowWriter waits 10 seconds
per attempt. If your code downloads images or calls other services before
responding, move that work into a background job and answer 200 as soon as
the article is saved.
"Your endpoint rejected the delivery" — Your endpoint answered with
something other than a 2xx. Common causes: the signature check failed
because the body was parsed before verifying (use the raw body), the wrong
secret is configured (compare with the one from the dialog), or the URL
redirects (use the final address directly).
"Delivery failed after several attempts" — GrowWriter couldn't reach
your endpoint at all. Check that the URL is live, uses https://, and is
publicly reachable. Addresses on private networks or localhost are blocked
for security. While developing locally, a tunnel service can give your local
server a temporary public HTTPS address.
The test succeeds but real articles fail — The ping is tiny; a full article can be a few hundred kilobytes. Check that your server accepts JSON bodies of that size and that your save logic handles the full payload.
Still stuck? Contact us from the app and we'll figure it out together.