Docs

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:

  1. You give GrowWriter a URL on your site, and GrowWriter gives you a secret key in return.
  2. When you publish an article, GrowWriter sends one POST request 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.
  3. Your site verifies the signature, saves the article, and answers with a success status (any 2xx). Done.
Three-step flow diagram: clicking Publish in GrowWriter sends one signed JSON request that your website receives and saves as a published post.
One click in GrowWriter, one request to your site, one new post.

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.

The GrowWriter Publish page showing the Webhook card in the Automation group with its Connect button.
The Webhook card lives under Automation on the Publish page.

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:

.env
GROWWRITER_WEBHOOK_SECRET=whsec_your_secret_here

This 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.

Step 2 of the Connect Webhook dialog showing the Signing Secret field with the Copy secret and Send test event buttons.
Copy the secret before you close the dialog. It's shown only once.

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:

Test event payload
{
  "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.

app/webhooks/growwriter/route.ts
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:

HeaderWhat it tells you
X-GrowWriter-Eventarticle.published, article.updated, or ping
X-GrowWriter-DeliveryUnique id for this delivery. Retries keep the same id, so use it to avoid processing twice
X-GrowWriter-Signaturet=<timestamp>,v1=<signature> for the verification shown above

Here's a trimmed example of the body for a real article:

article.published payload (trimmed)
{
  "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:

FieldHow to use it
article.idYour stable key. Store it, and update the existing post when the same id returns
article.contentThe article in three formats: markdown, ready-made html, and structured blocks for custom renderers. Use whichever fits your site
article.titleThe headline. It's not repeated inside the content, so render it yourself
article.metaDescriptionAt most 160 characters, written for your <meta name="description"> tag
article.datesMap firstPublishedAt to your published date and modifiedAt to your updated date
article.coverImage / article.imagesCover 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.html and content.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-Delivery id.
  • Any 2xx counts as success. Redirects don't. If your site answers 301 or 308, 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.

On this page