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[].urlmeans theurlof each item in theimagesarray. - 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.
| Property | Value |
|---|---|
| Method | POST |
Content-Type | application/json |
User-Agent | GrowWriter-Webhooks/1.0 |
| Body size | No 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:
| Header | Type | Format and guarantees | Example |
|---|---|---|---|
X-GrowWriter-Event | string | ping, article.published, article.updated, or article.unpublished. Same value as event in the body. | article.published |
X-GrowWriter-Delivery | string | UUID v4. Identical across all attempts of one delivery. Same value as deliveryId in the body. | 9d2f7c1e-6a3b-4c8d-b5e0-1f2a3b4c5d6e |
X-GrowWriter-Signature | string | t=<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:
| Attempt | When it happens |
|---|---|
| 1 | The moment you publish |
| 2 | 1 second after attempt 1 fails |
| 3 | 3 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
2xxstatus. 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-Deliveryid, and each attempt is signed with its own timestamp, so a retry never trips your five-minute check. - At least once. A slow
2xxcan 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:
{ "url": "https://www.example.com/blog/my-article" }The rules:
- Absolute
httporhttpsURL. 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 withouturlall 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:
| Field | Type | Present | Format and guarantees | Example |
|---|---|---|---|---|
version | string | Always | A date naming the payload shape. Changes only for breaking changes; see Versioning. | "2026-09-06" |
event | string | Always | ping, article.published, article.updated, or article.unpublished. | "article.published" |
deliveryId | string | Always | UUID v4. One value per delivery, shared by its retries. | "9d2f7c1e-…" |
sentAt | string | Always | ISO 8601, UTC, milliseconds. When the payload was built (once per delivery, not per attempt). | "2026-09-01T09:30:00.412Z" |
The four events:
| Event | When it fires | Body |
|---|---|---|
ping | You click Send test event in the connect dialog. | Envelope only |
article.published | The first successful delivery of an article to this webhook connection. | Envelope + the full article |
article.updated | Any later publish of the same article to this connection, edited or not. | Envelope + the full article |
article.unpublished | The 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:
{
"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:
{
"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
| Field | Type | Present | Format and guarantees | Example |
|---|---|---|---|---|
article.id | string | Always | 25 characters, lowercase letters and digits, starts with c. Safe in URLs and file names. Never changes. This is your key. | "cme8x2k9r0001l7042n5q8w3v" |
article.language | string | Always | Two lowercase letters, ISO 639-1. Never a region tag (es, never es-ES). Norwegian is nb. Defaults to en. | "es" |
article.slug | string | Always | Lowercase, 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, usingarticle.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ébecomesque), 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.updatedcan carry a new slug under the sameid. - 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
| Field | Type | Present | Format and guarantees | Example |
|---|---|---|---|---|
article.title | string | Always | Unicode. No maximum length. Not repeated inside the content, so render it yourself. | "How to Choose Running Shoes That Actually Fit" |
article.summary | string | Omitted when unset | Plain text, one paragraph. No maximum length. | "A practical guide to…" |
article.metaDescription | string | Omitted when unset | Plain 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.takeaways | string[] | Always, may be empty | Plain-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.keyword | string | Omitted when unset | The 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.relatedQuestions | string[] | Always, may be empty | Google'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:
| Field | Type | Present | Format and guarantees | Example |
|---|---|---|---|---|
url | string | Always | Public 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/…" |
altText | string | Omitted when unset | Accessibility text. | "Side view of a runner mid-stride" |
caption | string | Omitted when unset | Visible caption. Also appears in content.markdown as an italic paragraph right after the image. | "A quick gait check…" |
filename | string | Omitted when unset | Suggested 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" |
sectionSlug | string | Omitted when unset | The 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" |
attribution | object | Omitted 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.coverImageis omitted when unset. It never hassectionSlug.article.imagesis 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.sectionSlugis 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
| Field | Type | Present | Format and guarantees |
|---|---|---|---|
article.content.markdown | string | Always | The article as CommonMark-style markdown, normalized by GrowWriter. See what survives. |
article.content.html | string | Always | Ready-to-insert HTML built from the same structure. See what the HTML contains. |
article.content.blocks | object[] | Always | The 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://…),. Blocks are separated by blank lines. - Images:
, 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)*. Onlyhttp(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:
h1toh6,p,ul,ol(withstartwhen needed),li,blockquote,pre,code(withclass="language-…"),hr,figure,img,figcaption,a,strong,em,del, and onespanused 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://orhttps://addresses; anything else becomes plain text. - Images render as
<figure><img src alt><figcaption>…</figcaption></figure>, without thefigcaptionwhen there is no caption or attribution. An image whose address is nothttp(s)is dropped entirely. - No wrapper element and no doctype: insert it into your page as is.
article.dates
| Field | Type | Present | Format and guarantees | Example |
|---|---|---|---|---|
article.dates.createdAt | string | Always | ISO 8601, UTC, milliseconds. When the draft was created in GrowWriter. | "2026-08-28T14:00:00.000Z" |
article.dates.firstPublishedAt | string | Always | ISO 8601. When the article was first published to this connection. Identical in every later delivery. | "2026-09-01T09:30:00.000Z" |
article.dates.modifiedAt | string | Always | ISO 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,
firstPublishedAtandmodifiedAtare equal. modifiedAtis not "last edited". Editing without republishing changes nothing; republishing without edits moves it forward. Use theeventto 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
| Field | Type | Present | Format and guarantees | Example |
|---|---|---|---|---|
article.brand | object | Omitted when unset | Present when the article belongs to a brand kit (the normal case). | |
article.brand.name | string | Always inside brand | The brand's display name. For publisher/organization markup, not an author. | "Stride Lab" |
article.brand.websiteUrl | string | Omitted when unset | https:// 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:
| Block | Fields |
|---|---|
heading | level (1 to 6) and children (inline nodes) |
paragraph | children (inline nodes) |
list | ordered (boolean), optional start number, and items; each item has its own blocks array, so nested lists appear as list blocks inside an item |
blockquote | blocks: full blocks nested inside the quote |
code | optional language, and code as one string |
hr | nothing else; a thematic break |
image | src, 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.
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.
Webhook troubleshooting
Fix a failed test event or delivery by symptom, with the exact messages GrowWriter shows, what each one means, and what to do.