Docs

Webhook payload reference

Every header and field GrowWriter sends to your webhook endpoint, with its type, whether it is always present, its exact format, and what you can rely on.

Last updated on

This page is the contract, written for whoever builds your endpoint. If that isn't you, you can stop here. The guide covers the setup; this page lists exactly what arrives, so nobody has to guess.

How to read this page

Every table has the same five columns:

  • Field: the path inside the JSON body, in code. images[].url means the url of each item in the images array.
  • Type: string, string[] (a list of strings), object, object[], number, boolean.
  • Present: one of three words. Always means the key is in every delivery. Always, may be empty means the key is there but the value can be an empty string or an empty list. Omitted when unset means the key is simply missing from the JSON when there is no value. It is never null.
  • Format and guarantees: what the value looks like and what you can rely on. Where something is not enforced, the table says so.
  • Example: a real-looking value.

Two rules apply everywhere. First, the latest delivery is authoritative: if a value differs between two deliveries of the same article, use the newest. Second, ignore fields you don't recognize: new optional fields can appear without a version change (see Versioning).

A tiny glossary for the terms in the tables: an ISO 8601 date looks like 2026-09-01T09:30:00.000Z (year-month-day, a T, the time to the millisecond, and Z meaning UTC). A UUID is a random id like 9d2f7c1e-6a3b-4c8d-b5e0-1f2a3b4c5d6e.

The request

GrowWriter sends one POST per delivery to the endpoint URL you entered.

PropertyValue
MethodPOST
Content-Typeapplication/json
User-AgentGrowWriter-Webhooks/1.0
Body sizeNo cap. A standard article is typically a few hundred kilobytes because the content travels three times (markdown, HTML, blocks). Long articles can exceed 1 MB.

What counts as success, how retries work, and what to answer are in Delivery. The headers you need:

HeaderTypeFormat and guaranteesExample
X-GrowWriter-Eventstringping, article.published, article.updated, or article.unpublished. Same value as event in the body.article.published
X-GrowWriter-DeliverystringUUID v4. Identical across all attempts of one delivery. Same value as deliveryId in the body.9d2f7c1e-6a3b-4c8d-b5e0-1f2a3b4c5d6e
X-GrowWriter-Signaturestringt=<unix seconds>,v1=<64 hex characters>. v1 is HMAC-SHA256 of "<t>.<raw body>" keyed with your whole signing secret. Fresh on every attempt.t=1756718400,v1=5257a8…

Delivery

One publish to one connection is one delivery, and a delivery makes up to three attempts before it gives up:

AttemptWhen it happens
1The moment you publish
21 second after attempt 1 fails
33 seconds after attempt 2 fails

The rules your code can rely on:

  • Timeout. Each attempt waits up to 10 seconds for your answer.
  • Success. Any 2xx status. Answer with JSON {"url": "https://…"} to name the live post and GrowWriter shows a View post link to it (see The response); any other success body is ignored. When an attempt is rejected, GrowWriter shows the status and the first 200 characters of the body in its dialog, so a short plain-text reason helps you debug.
  • Failure. A timeout, any 3xx (redirects are never followed; every attempt goes to the exact URL you entered), or any other status.
  • Same id, fresh signature. All attempts share one X-GrowWriter-Delivery id, and each attempt is signed with its own timestamp, so a retry never trips your five-minute check.
  • At least once. A slow 2xx can arrive after GrowWriter has already retried, so the same delivery id can reach you twice. Treat a repeated id as already done.
  • Nothing after the third attempt. GrowWriter does not retry later. The channel shows a Retry button in the publish dialog, which starts a new delivery with a new id. Publishing the article again later also delivers it again, as article.updated.
  • Rate. Publishing is limited to 20 publish requests per hour per account, one request per channel. The test event is limited to 60 per hour. Deliveries themselves have no rate limit.

The test event (Send test event in the connect dialog) follows the same rules, so an endpoint that never answers takes about half a minute to fail.

The response

GrowWriter only needs a 2xx status from you. Optionally, tell it where the post went live so the publish dialog can link to it:

Success response (optional body)
{ "url": "https://www.example.com/blog/my-article" }

The rules:

  • Absolute http or https URL. Relative paths and any other scheme are ignored, because the value is rendered as a link.
  • Other keys are ignored. You can return the url alongside your own fields.
  • Nothing is ever an error. A plain OK, an empty body, non-JSON, or JSON without url all count as success; the dialog simply shows no link.
  • Keep it small. GrowWriter reads the first 4 KB of the body.
  • Every delivery replaces the link. The stored link is whatever the latest successful delivery returned. Answer without a url on a republish and the link goes away.

Return the url when your handler creates the post before answering. If you answer first and create the post in a background job, you don't know the url yet: skip it, and the dialog shows no link for that publish.

The envelope

The top-level fields, present in every delivery:

FieldTypePresentFormat and guaranteesExample
versionstringAlwaysA date naming the payload shape. Changes only for breaking changes; see Versioning."2026-09-06"
eventstringAlwaysping, article.published, article.updated, or article.unpublished."article.published"
deliveryIdstringAlwaysUUID v4. One value per delivery, shared by its retries."9d2f7c1e-…"
sentAtstringAlwaysISO 8601, UTC, milliseconds. When the payload was built (once per delivery, not per attempt)."2026-09-01T09:30:00.412Z"

The four events:

EventWhen it firesBody
pingYou click Send test event in the connect dialog.Envelope only
article.publishedThe first successful delivery of an article to this webhook connection.Envelope + the full article
article.updatedAny later publish of the same article to this connection, edited or not.Envelope + the full article
article.unpublishedThe article is deleted in GrowWriter, or this webhook connection is disconnected (once per article it had published).Envelope + article.id, article.slug, article.language only

"This connection" matters: publishing the same article to a second webhook connection sends article.published to that connection, because it has never seen the article. A first attempt that failed also counts as never seen, so the retry is article.published again. Both publish events carry the full article, so your handler can treat them identically: create or update the post keyed by article.id. On article.unpublished, hide or delete the post with that id; which one is your call, and no content travels with a removal.

The ping event

Send test event in the connect dialog delivers a ping. It carries only the envelope, no article:

ping
{
  "version": "2026-09-06",
  "event": "ping",
  "deliveryId": "5f0c9a1e-4d2b-4f6a-9c3e-8b7d6a5e4f3c",
  "sentAt": "2026-09-01T09:30:00.000Z"
}

The unpublished event

Deleting an article in GrowWriter, or disconnecting the webhook, delivers article.unpublished for every article this connection had published. It identifies the post and nothing more:

article.unpublished
{
  "version": "2026-09-06",
  "event": "article.unpublished",
  "deliveryId": "0b7d3e2a-9c41-4f58-8e6d-2a1b3c4d5e6f",
  "sentAt": "2026-09-03T08:12:44.000Z",
  "article": {
    "id": "cme8x2k9r0001l7042n5q8w3v",
    "slug": "how-to-choose-running-shoes-that-actually-fit",
    "language": "en"
  }
}

Hide or delete the post keyed by article.id. The same delivery rules apply (three attempts, then nothing), so a receiver that was down misses the removal: treat a later article.published for an id you still hold as the authoritative state.

article: identity

FieldTypePresentFormat and guaranteesExample
article.idstringAlways25 characters, lowercase letters and digits, starts with c. Safe in URLs and file names. Never changes. This is your key."cme8x2k9r0001l7042n5q8w3v"
article.languagestringAlwaysTwo lowercase letters, ISO 639-1. Never a region tag (es, never es-ES). Norwegian is nb. Defaults to en."es"
article.slugstringAlwaysLowercase, derived from the title when the brief is generated. Never empty. Not unique, no maximum length, may contain non-Latin letters."how-to-choose-running-shoes"

Notes on identity:

  • Each language is its own article. A translation is a separate draft with its own id. Pair languages with your own data, using article.language.
  • The 41 possible language codes: ar bg ca cs da de el en es et fa fi fr he hi hr hu hy id is it ja ko lt lv nb nl pl pt ro ru sk sl sr sv th tl tr uk vi zh.
  • How the slug is built. Latin accents are folded (qué becomes que), spaces become hyphens, punctuation is dropped, and letters from other scripts are kept, so a Japanese title gives a Japanese slug. Percent-encode it before putting it in a URL (encodeURIComponent(article.slug) in JavaScript).
  • When the slug changes. Editing the title in the editor does not change it. Picking a new title in the writer regenerates the brief and the slug, so an article.updated can carry a new slug under the same id.
  • Never key on the slug. It can change on an update and two articles can share one. Store it as display data next to the id.

article: text fields

FieldTypePresentFormat and guaranteesExample
article.titlestringAlwaysUnicode. No maximum length. Not repeated inside the content, so render it yourself."How to Choose Running Shoes That Actually Fit"
article.summarystringOmitted when unsetPlain text, one paragraph. No maximum length."A practical guide to…"
article.metaDescriptionstringOmitted when unsetPlain text written to be 140 to 160 characters. Never longer than 160: anything over is cut at a word boundary before delivery."Learn how to pick running shoes…"
article.takeawaysstring[]Always, may be emptyPlain-text sentences, 3 to 5 as generated; treat the count as unbounded. Each is "<point>: <detail>" or just <detail>.["Fit beats brand every time: …"]
article.keywordstringOmitted when unsetThe one search term the article was written for, as the author typed it. For your SEO tooling; not a tag."how to choose running shoes"
article.relatedQuestionsstring[]Always, may be emptyGoogle's "people also ask" questions for the keyword, in the article's language and country, captured when the titles were generated. At most 8, in Google's order. FAQ material: show them under the article or as FAQPage markup. The article does not promise to answer each one, and they are not tags.["how often should I replace running shoes?"]

article.coverImage and article.images

The cover and every inline image share one object shape:

FieldTypePresentFormat and guaranteesExample
urlstringAlwaysPublic https:// URL on GrowWriter's storage. Does not expire today, but is not a long-term host: download it."https://….public.blob.vercel-storage.com/…"
altTextstringOmitted when unsetAccessibility text."Side view of a runner mid-stride"
captionstringOmitted when unsetVisible caption. Also appears in content.markdown as an italic paragraph right after the image."A quick gait check…"
filenamestringOmitted when unsetSuggested name without an extension. Not enforced: the generator is asked for 3 to 7 lowercase ASCII words joined by hyphens. Generate your own names from article.id if you need certainty."runner-gait-check"
sectionSlugstringOmitted when unsetThe outline section the image was generated for, or a user-… value for images the author inserted by hand. Never on the cover. Don't parse it, don't key on it."section-1-4f1a4848"
attributionobjectOmitted when unset{ authorName?, pageUrl?, licenseShortName?, licenseUrl? }, every field optional. Present for Wikimedia Commons photos. Also rendered as an italic line under the caption in content.markdown.see sample

Rules that differ between the two:

  • article.coverImage is omitted when unset. It never has sectionSlug.
  • article.images is always present and may be empty. It lists the inline images in document order and does not include the cover. There is no maximum count.
  • sectionSlug is stable for the life of the article; only regenerating the outline in the writer produces new ids.

Image files

  • Format: JPEG, PNG, WebP, or AVIF. Never GIF or SVG.
  • Size: files are stored as fetched, at their original resolution, with no re-encoding. Stock photos are commonly 1 to 6 MB; some originals exceed 10 MB. Resize and compress for your site.
  • Stability: an image that didn't change keeps the exact same URL in later deliveries. A replaced image gets a new URL. Old URLs keep working.

article.content

FieldTypePresentFormat and guarantees
article.content.markdownstringAlwaysThe article as CommonMark-style markdown, normalized by GrowWriter. See what survives.
article.content.htmlstringAlwaysReady-to-insert HTML built from the same structure. See what the HTML contains.
article.content.blocksobject[]AlwaysThe article as a JSON tree for custom renderers. See the blocks format.

All three carry the same article. Pick one when you render; the title is in none of them.

What survives in markdown

content.markdown is re-rendered by GrowWriter from the article structure, so it is predictable:

  • Present: headings # to ######, paragraphs, ordered and unordered lists (nested with two spaces), blockquotes, fenced code blocks with a language, --- breaks, **strong**, *em*, ~~strikethrough~~, `code`, [text](https://…), ![alt](url). Blocks are separated by blank lines.
  • Images: ![alt](url), then an italic paragraph with the caption, then, only for attributed photos, an italic line like *Photo: Jane Doe · [Source](url) · [CC BY-SA 4.0](license-url)*. Only http(s) image addresses are kept; anything else is dropped.
  • Not present: tables, footnotes, task lists. If an author typed them, they arrive as plain paragraphs.
  • Raw HTML: if an author pasted HTML into the editor, it arrives as literal text, not as markup.
  • Escaping: ordinary prose contains {, } and <. Escape them if you compile the markdown as MDX, JSX, or a template.

What the HTML contains

content.html is built element by element, never copied from anywhere:

  • Tags used: h1 to h6, p, ul, ol (with start when needed), li, blockquote, pre, code (with class="language-…"), hr, figure, img, figcaption, a, strong, em, del, and one span used as a separator inside captions. No other tags, no ids, no inline styles, no scripts.
  • Text is always escaped (&, <, >, ").
  • Links are kept only with http:// or https:// addresses; anything else becomes plain text.
  • Images render as <figure><img src alt><figcaption>…</figcaption></figure>, without the figcaption when there is no caption or attribution. An image whose address is not http(s) is dropped entirely.
  • No wrapper element and no doctype: insert it into your page as is.

article.dates

FieldTypePresentFormat and guaranteesExample
article.dates.createdAtstringAlwaysISO 8601, UTC, milliseconds. When the draft was created in GrowWriter."2026-08-28T14:00:00.000Z"
article.dates.firstPublishedAtstringAlwaysISO 8601. When the article was first published to this connection. Identical in every later delivery."2026-09-01T09:30:00.000Z"
article.dates.modifiedAtstringAlwaysISO 8601. The moment this publish was requested. Moves forward on every publish, even without edits. Never earlier than firstPublishedAt."2026-09-01T09:30:00.000Z"

How the three relate:

  • On the first delivery, firstPublishedAt and modifiedAt are equal.
  • modifiedAt is not "last edited". Editing without republishing changes nothing; republishing without edits moves it forward. Use the event to tell a first publish from an update, not a date comparison.
  • Date-only fields in a CMS usually need YYYY-MM-DD: take the first ten characters.

article.brand

FieldTypePresentFormat and guaranteesExample
article.brandobjectOmitted when unsetPresent when the article belongs to a brand kit (the normal case).
article.brand.namestringAlways inside brandThe brand's display name. For publisher/organization markup, not an author."Stride Lab"
article.brand.websiteUrlstringOmitted when unsethttps:// URL of the brand's website."https://stridelab.example.com"

The payload names a brand, never a person. If your posts need an author byline, set it in your own save logic.

The blocks format

content.blocks is the article as a JSON tree, for sites that render content themselves instead of trusting ready-made HTML. It's an array of block objects, and every block is one of seven types:

BlockFields
headinglevel (1 to 6) and children (inline nodes)
paragraphchildren (inline nodes)
listordered (boolean), optional start number, and items; each item has its own blocks array, so nested lists appear as list blocks inside an item
blockquoteblocks: full blocks nested inside the quote
codeoptional language, and code as one string
hrnothing else; a thematic break
imagesrc, plus optional alt, caption, filename, sectionSlug, and attribution (same meaning as in images)

Wherever a block has children, those are inline nodes: text (with text), code (with text), link (with href and children), and strong / em / del (each with children, so styles can nest).

A paragraph with bold text, for example:

{
  "type": "paragraph",
  "children": [
    { "type": "text", "text": "That's the whole secret: " },
    { "type": "strong", "children": [{ "type": "text", "text": "fit first" }] },
    { "type": "text", "text": ", everything else second." }
  ]
}

Full sample payload

A complete article.published delivery, generated by the same code that will call your endpoint. Image URLs are placeholders: real ones point to GrowWriter's public storage.

Versioning

The current version is 2026-09-06, and this page describes it in full. The version field is a date that names the payload's shape. New optional fields may appear, and a key that was omitted may start appearing, without that date changing, so write your endpoint to ignore fields it doesn't recognize instead of rejecting them. Nothing is removed, renamed, or retyped within a version. The date only changes for a breaking change, and any such change is announced in the changelog before it reaches your endpoint.

On this page