For whoever builds your webhook

Receive posts at one URL

One route, one JSON event per post, about an hour. Paste the prompt into your AI, or use the REST API.

AuthAuthorization: Bearer <token>A shared secret you generate. Wrong or missing: answer 401.
POSTyour HTTPS URLOne route. One JSON event per post.
Eventpublish_articlesNew posts, in data.articles. Upsert on id.
Eventupdate_articleA changed post, in data.article. No status means keep it.

What you are building

One route, e.g. POST /api/webhooks/blogwriter, that:

  1. checks the bearer token (401 if wrong);
  2. reads the event: publish_articles (new posts) or update_article (one changed post);
  3. saves each article, updating the one with the same id instead of adding a copy;
  4. answers 200.

Need blogwriter to read posts back or check fields? Use the REST API instead.


Authentication

A long random token, shared between the site and the writer. It arrives as a bearer token:

POST /api/webhooks/blogwriter
Authorization: Bearer 3f9c1a0e7b...
Content-Type: application/json

Wrong or missing token: 401. Compare in constant time. The URL must be HTTPS.

Generate one with:

openssl rand -hex 32

Do not redirect the webhook path. A redirect between example.com and www.example.com drops the Authorization header, so every post is refused.


The events

Every request is JSON with event_type, timestamp (ISO 8601) and data.

publish_articles

A new post, in the data.articles array. Usually one; handle any number.

{
  "event_type": "publish_articles",
  "timestamp": "2026-09-24T12:00:00.000Z",
  "data": {
    "articles": [
      {
        "id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
        "title": "Gym Schedule and Timetable Templates",
        "content_markdown": "> **In short:** ...\n\n## Why timetables fail\n\n...",
        "content_html": "<blockquote><strong>In short:</strong> ...</blockquote>\n<h2 id=\"why-timetables-fail\">Why timetables fail</h2>...",
        "meta_description": "How to build a class timetable that members turn up to.",
        "created_at": "2026-09-24T00:00:00.000Z",
        "image_url": "https://cdn.example.com/hero.png",
        "slug": "gym-schedule-timetable",
        "tags": ["gym", "scheduling"],
        "status": "draft"
      }
    ]
  }
}

update_article

A post sent before, changed. The whole post, not a diff, in data.article.

{
  "event_type": "update_article",
  "timestamp": "2026-09-25T09:30:00.000Z",
  "data": {
    "article": {
      "id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
      "title": "Gym Schedule and Timetable Templates",
      "content_markdown": "...",
      "content_html": "...",
      "meta_description": "...",
      "created_at": "2026-09-24T00:00:00.000Z",
      "image_url": "https://cdn.example.com/hero.png",
      "slug": "gym-schedule-timetable",
      "tags": ["gym", "scheduling"]
    }
  }
}

No status here means: keep the status it has.


The article object

FieldTypeNotes
idstringalwaysblogwriter's id for the post. Never changes. Match on it.
titlestringalwaysPlain text, no markup.
slugstringalwaysThe address. Can change between sends when a post is retitled.
content_markdownstringalwaysThe body as Markdown, with some HTML blocks.
content_htmlstringalwaysThe same body as ready-to-render HTML.
meta_descriptionstringalwaysEmpty string when there is none.
created_atstringalwaysISO 8601. The post's date.
image_urlstringoptionalThe cover. Absolute URL, or null.
tagsstring[]alwaysPlain strings; may be empty.
statusenumoptional"draft" or "published". Absent on an update means keep it.
publish_atstringoptionalISO 8601. Present when the writer scheduled the post.

Store content_html or content_markdown, whichever the site renders.


Matching a post

Look up by id first, then by slug. Found: update it. Not found: create it.

A retitled post keeps its id but gets a new slug: move it and 301 the old address.

Handle both events the same way. Upserting on id makes a retry or a duplicate harmless.


Status

  • status present: set it. New posts arrive as "draft" unless the writer published them.
  • status absent on an update: keep the current status. Never reset a live post to draft.
  • status absent on a new post: treat it as a draft.

No draft state on the site? Publish on arrival, and tell the writer that is what happens.

publish_at means scheduled: hold it as a draft until then. Echo it in your reply if you can.


Rendering the body

The body includes a table of contents and embeds (videos, X and Reddit posts, TradingView charts). Let them through your sanitizer; the allowed elements and hosts are in the REST API spec and in the prompt below.


Responding

Answer 200 once the post is stored. This body is the most useful:

200 OK

{
  "ok": true,
  "id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
  "url": "https://example.com/blog/gym-schedule-timetable",
  "status": "draft",
  "publish_at": null
}

url is shown to the writer as the link to the post. publish_at echoed back confirms a schedule.

StatusWhen
200Stored.
400Bad JSON, unknown event, or a missing field. Say which.
401Missing or wrong token.
500Your side failed. The writer sees your message, so make it say what.

blogwriter does not retry. A failed send is shown to the writer with your message.


Images

Show

Images arrive as absolute public URLs. Storing the URL is enough. Accept SVG: diagrams are SVG.


Reference implementation

Show

A Next.js route handler. Same shape in Express, Laravel, Rails or Django.

// app/api/webhooks/blogwriter/route.ts
import { timingSafeEqual } from "node:crypto";

const TOKEN = process.env.BLOGWRITER_WEBHOOK_TOKEN!;

function authed(req: Request) {
  const h = req.headers.get("authorization") ?? "";
  if (!h.startsWith("Bearer ")) return false;
  const a = Buffer.from(h.slice(7)), b = Buffer.from(TOKEN);
  return a.length === b.length && timingSafeEqual(a, b);
}

async function save(article: any) {
  for (const f of ["id", "title", "slug"])
    if (!article?.[f]) throw Object.assign(new Error(`article.${f} is required`), { status: 400 });

  // id first, so a retitled post is updated rather than copied; slug second, to adopt old posts.
  const existing =
    (await db.posts.findUnique({ where: { external_id: article.id } })) ??
    (await db.posts.findUnique({ where: { slug: article.slug } }));

  const fields = {
    external_id: article.id,
    title: article.title,
    slug: article.slug,
    content: article.content_markdown,        // or content_html, if the site stores HTML
    excerpt: article.meta_description || null,
    cover_image_url: article.image_url || null,
    tags: article.tags ?? [],
    ...(article.status ? { status: article.status } : {}),   // absent = keep what it has
    ...(article.publish_at ? { publish_at: article.publish_at } : {})
  };

  const row = existing
    ? await db.posts.update({ where: { id: existing.id }, data: fields })
    : await db.posts.create({ data: { status: "draft", created_at: article.created_at, ...fields } });

  if (existing && existing.slug !== row.slug)
    await db.redirects.upsert({ where: { from: `/blog/${existing.slug}` },
      create: { from: `/blog/${existing.slug}`, to: `/blog/${row.slug}` }, update: { to: `/blog/${row.slug}` } });
  return row;
}

export async function POST(req: Request) {
  if (!authed(req)) return Response.json({ error: "Invalid access token." }, { status: 401 });
  let body: any;
  try { body = await req.json(); } catch { return Response.json({ error: "Body is not JSON." }, { status: 400 }); }

  const articles =
    body.event_type === "publish_articles" ? body.data?.articles ?? [] :
    body.event_type === "update_article" ? [body.data?.article] : null;
  if (!articles) return Response.json({ error: `Unknown event_type "${body.event_type}".` }, { status: 400 });

  try {
    const rows = [];
    for (const a of articles) rows.push(await save(a));
    const last = rows[rows.length - 1];
    return Response.json({ ok: true, id: last?.external_id, url: `https://example.com/blog/${last?.slug}`,
                           status: last?.status, publish_at: last?.publish_at ?? null });
  } catch (e: any) {
    return Response.json({ error: e.message }, { status: e.status ?? 500 });
  }
}

The posts table needs one extra column: external_id, unique, holding the article id.


Before you ship

Show
  • [ ] No token or a wrong one: 401, nothing stored.
  • [ ] HTTPS, with no redirect in front of it.
  • [ ] Sending the same publish_articles twice leaves one post.
  • [ ] An update with a new slug moves the post and redirects the old one.
  • [ ] A draft is not on the site, the sitemap or the feed.
  • [ ] Updating a live post with no status keeps it live.
  • [ ] An unknown event_type gets 400.
  • [ ] A bw-embed block renders as a frame, not just its caption.
  • [ ] An SVG image_url renders.

Setting one up for a new client

  1. Make a token: openssl rand -hex 32.
  2. Get the receiver built: send this page, or paste the prompt below into an AI tool in the site's repo.
  3. Connect it: Settings → The client's CMS → Webhook, or blogwriter cms setup webhook.
  4. Check it: blogwriter cms push <post.md> --dry-run shows the exact event.
  5. Send one draft and look at it before publishing.

blogwriter saves the article id in the post as cmsId. Keep it: that is what makes the next send an update.


Give this to your AI

Open your AI coding tool (Claude Code, Cursor, Lovable…) in the site's repo and paste the box below. It builds and tests everything, then gives you the URL and token for blogwriter.

Prompt for your AI
You are working in the repository of a website with a blog. Build a webhook receiver so this site
can receive blog posts from blogwriter, an AI writing tool, and make the blog render everything
blogwriter writes. Everything you need is in this message; do not guess at anything it specifies.
Work through the steps in order and do not stop until the "DONE WHEN" list at the end passes.

STEP 0 — LOOK BEFORE CHANGING ANYTHING
Find out and tell me in two or three lines:
- the framework and how API routes are written here (Next.js app or pages router, Express, Astro,
  SvelteKit, Laravel, Rails, Django, etc.);
- where blog posts are stored (a database table and its ORM, a headless CMS, Markdown files in the
  repo) and the fields each post has;
- how a post body becomes HTML on the blog page (Markdown renderer, sanitizer, MDX, templates);
- whether the site sends a Content-Security-Policy header;
- where environment variables live and how the site is deployed.
Change as little as that allows. Reuse the existing posts store and the existing blog page. If there
is no blog yet, create one: a posts table (below), a /blog index page listing published posts
newest first, and a /blog/[slug] page.

STEP 1 — THE POSTS STORE
Each post needs these fields. Add only the ones that are missing:
  id (the site's own), external_id (text, UNIQUE — blogwriter's article id), title, slug (UNIQUE),
  status ("draft" or "published", default "draft"), content (the body), excerpt (nullable),
  cover_image_url (nullable), tags (list of strings), created_at, updated_at,
  publish_at (nullable timestamp), published_at (nullable timestamp).
The unique constraints matter: they are what stops a retry from creating a second copy.
If posts are Markdown files in the repo rather than a database, the receiver cannot write to the
repo on a serverless host; tell me, and propose storing webhook posts in a small database table
(or the platform's KV/blob store) that the blog pages also read from.
Also make somewhere to keep redirects from old slugs to new ones (a table, or the framework's
redirect config if it can be updated at runtime).

STEP 2 — THE ACCESS TOKEN
Generate a random token (e.g. `openssl rand -hex 32`). Put it in the local env file as
BLOGWRITER_WEBHOOK_TOKEN and make sure that file is git-ignored. Never commit it and never write it
into source code. Tell me to add the same variable to the hosting provider's environment settings,
and tell me the exact name.

STEP 3 — THE RECEIVER ROUTE: POST /api/webhooks/blogwriter
Authentication:
- Every request carries `Authorization: Bearer <token>`. Compare it to BLOGWRITER_WEBHOOK_TOKEN in
  constant time (e.g. crypto.timingSafeEqual on equal-length buffers). Missing or wrong: answer 401
  with {"error":"Invalid access token."} and store nothing.
- Only POST. Never accept the token from a query string.
- The route must answer at the site's canonical host with no redirect in front of it (a redirect
  between example.com and www.example.com drops the Authorization header). If the site or its host
  redirects apex <-> www, exempt /api/webhooks/* or tell me which host to use.
- Exclude this route from any auth middleware, CSRF protection or bot protection that would block a
  server-to-server POST.

The request body is JSON with three keys: event_type, timestamp (ISO 8601), data.
Two event types:

  event_type "publish_articles": data.articles is an ARRAY of article objects (usually one).
  event_type "update_article":   data.article is ONE article object (the whole post, not a diff).
  Anything else: answer 400 with {"error":"Unknown event_type \"<value>\"."}.
  A body that is not JSON: answer 400.

An example publish_articles body:
  {
    "event_type": "publish_articles",
    "timestamp": "2026-09-24T12:00:00.000Z",
    "data": { "articles": [ {
      "id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
      "title": "Gym Schedule and Timetable Templates",
      "content_markdown": "> **In short:** ...\n\n## Why timetables fail\n\n...",
      "content_html": "<blockquote><strong>In short:</strong> ...</blockquote>\n<h2 id=\"why-timetables-fail\">Why timetables fail</h2>...",
      "meta_description": "How to build a class timetable that members turn up to.",
      "created_at": "2026-09-24T00:00:00.000Z",
      "image_url": "https://cdn.example.com/hero.png",
      "slug": "gym-schedule-timetable",
      "tags": ["gym", "scheduling"],
      "status": "draft"
    } ] }
  }
An update_article body is the same article under "data": { "article": { ... } }, and often has no
"status" key at all.

Article fields:
  id                string, always      blogwriter's id; stable across every send of this post
  title             string, always      plain text
  slug              string, always      the post's address; may change when a post is retitled
  content_markdown  string, always      the body as Markdown, with some HTML blocks on purpose
  content_html      string, always      the same body as HTML: heading ids set, embeds in place
  meta_description  string, always      "" when there is none
  created_at        ISO 8601, always    the post's date
  image_url         string or null      the cover image, an absolute URL
  tags              string[], always    may be empty
  status            "draft"|"published", sometimes absent
  publish_at        ISO 8601, sometimes present — a schedule

Handling each article (the same code for both event types):
1. If id, title or slug is missing: answer 400 naming the field.
2. Find the stored post by external_id = article.id; if none, by slug = article.slug.
3. If found, update it; if not, create it. This makes every event safe to receive twice, and a
   retitled post (new slug, same id) is updated instead of copied.
4. Store: external_id=id, title, slug, excerpt=meta_description (null if ""), cover_image_url=
   image_url, tags, and the body. Store content_markdown if the blog renders Markdown itself;
   store content_html if the blog stores and renders HTML. Pick one based on STEP 0 and say which.
5. Status — this is the rule that matters most:
   - status present: set it.
   - status ABSENT on an existing post: do not touch its status. A published post stays published.
     Never default a missing status to "draft" on an update: that takes a live article off the
     site and its URL starts answering 404.
   - status absent on a new post: create it as "draft".
   - When a post becomes "published" and published_at is empty, set published_at to now. Never
     change published_at on a post that already has it.
6. publish_at present: keep the post a draft and publish it at that time. If the site has a cron or
   scheduled job mechanism, use it; if not, have the public pages treat a post as published once
   publish_at has passed. If neither is possible, ignore publish_at and tell me.
7. Slug changed on an existing post: move it to the new slug and add a 301 redirect from the old
   /blog/<old-slug> to the new one.
8. Images: image_url and images inside the body are absolute public URLs already. Storing the URLs
   is enough. Do not strip SVG images.

Response: on success answer 200 with
  {"ok":true,"id":"<article id>","url":"<public URL of the post>","status":"<its status now>",
   "publish_at":"<ISO or null>"}
(for several articles, describe the last one). On a failure on this side answer 500 with
{"error":"<a message a person can act on>"}. Answer within 30 seconds; do slow work (image
downloads, search indexing, cache purges) after responding or in the background.

After storing, make the post visible without a redeploy: revalidate/purge the blog index, the post
page, the sitemap and the RSS feed if the site caches them (e.g. Next.js revalidatePath).

STEP 4 — THE PUBLIC SITE
- The blog index, the post page, the sitemap and the RSS feed show only status = "published" posts
  (and scheduled ones whose publish_at has passed). Drafts are never public.
- The post page uses the title, excerpt as meta description, cover_image_url as og:image, and
  created_at / published_at as the date.

THE BLOG PAGES — render everything blogwriter writes
A post body is Markdown (tables included) with a few HTML blocks in it on purpose. Render it with
these on top, and change nothing about how existing posts look.

1. Heading ids. Every heading gets an `id` so the contents list can jump to it. The rule: the
   heading's text, lowercased, apostrophes removed, every run of characters that is not a letter or
   digit turned into one hyphen, hyphens trimmed from both ends, cut to 72 characters. This is the
   GitHub / rehype-slug / markdown-it-anchor convention, so a renderer already doing that is fine.
   Example: "What is Hype Coin (HYPE)? Price & 2026 Outlook" -> what-is-hype-coin-hype-price-2026-outlook

2. Raw HTML that must be rendered, not stripped or escaped. Keep the site's sanitizer and allow only:
   - <nav class="bw-toc"> with p, ol, li and a[href] inside: the table of contents.
   - <figure class="bw-embed bw-embed-<kind>"> blocks: embeds. Allow inside them:
       figure[class, style]; iframe[src, title, loading, referrerpolicy, sandbox, allow,
       allowfullscreen, style, width, height]; figcaption, blockquote, p, cite, strong, span [style];
       a[href, rel, target, style].
     Render an iframe only if its src is https:// and its host matches its kind:
       bw-embed-youtube    www.youtube-nocookie.com, www.youtube.com
       bw-embed-x          platform.twitter.com         (an X/Twitter post)
       bw-embed-tv-chart   s.tradingview.com            (live chart)
       bw-embed-tv-price   s.tradingview.com            (live price)
       bw-embed-reddit     www.redditmedia.com
       bw-embed-instagram  www.instagram.com
       bw-embed-website    any https host; add sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
       bw-embed-link       no iframe: a link card
   - Keep inline `style` attributes on these elements. They carry each embed's size (a chart's
     aspect ratio, a price widget's height, an X post's height), and the site has none of
     blogwriter's CSS. None of these blocks contains <script>, and none may ever be allowed one.
   If the site already turns YouTube links into its own player, keep that, but do not let it
   swallow the other kinds.

3. Blockquotes. Every post opens with `> **In short:** ...`, the answer in two or three lines. Style
   blockquotes so it reads as a summary box, not as small grey text.

4. Images. Markdown images and the cover image are usually absolute URLs; also accept root-relative
   paths (/images/x.png) resolved against this site, and SVG files (diagrams are SVG). Never drop
   an SVG.

5. Metadata. Use the meta title / meta description when set (else title / excerpt), the canonical
   URL when set, and the cover image as the social (og:image) image. Drafts never render publicly,
   are never in the sitemap, and never in the RSS feed.

6. Content-Security-Policy. If the site sends one, frame-src must include every host in the embed
   list above and img-src must allow https images.

THE TEST POST (real blogwriter output — use it to check the rendering)
---
title: "blogwriter render test"
slug: "blogwriter-render-test"
description: "A test post with every block blogwriter writes."
---

> **In short:** if this sentence sits in a styled quote box, the blockquote works.

Opening paragraph with a [link to another post](/blog/another-post) and **bold** text.

<nav class="bw-toc" aria-label="Table of contents">
<p class="bw-toc-title">Table of contents</p>
<ol>
<li><a href="#what-is-hype-coin-hype-price-2026-outlook">What is Hype Coin (HYPE)? Price &amp; 2026 Outlook</a>
</li>
<li><a href="#live-data">Live data</a>
</li>
<li><a href="#what-people-said">What people said</a>
</li>
</ol>
</nav>

## What is Hype Coin (HYPE)? Price & 2026 Outlook

A table:

| Measure | Figure |
|---|---|
| Price | $91.52 |
| Market cap | $20.4 billion |

![A diagram drawn as SVG](https://blogwriter.pro/favicon.svg)

## Live data

<figure class="bw-embed bw-embed-tv-chart" style="margin:2rem 0">
  <iframe src="https://s.tradingview.com/widgetembed/?symbol=BYBIT%3AHYPEUSDT&interval=D&theme=light&style=1&locale=en&hide_side_toolbar=1&allow_symbol_change=0&saveimage=0&withdateranges=1" title="BYBIT:HYPEUSDT live chart" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;aspect-ratio:16/10;border-radius:12px"></iframe>
  <figcaption style="font-size:.875rem;opacity:.72;margin-top:.6rem;line-height:1.5">Live chart · <a href="https://www.tradingview.com/symbols/BYBIT-HYPEUSDT/" rel="noopener nofollow" target="_blank">BYBIT:HYPEUSDT on TradingView</a></figcaption>
</figure>

## What people said

<figure class="bw-embed bw-embed-x" style="margin:2rem 0">
  <iframe src="https://platform.twitter.com/embed/Tweet.html?id=20&dnt=true" title="Post on X by jack" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;max-width:550px;height:234px;border-radius:12px"></iframe>
  <figcaption style="font-size:.875rem;opacity:.72;margin-top:.6rem;line-height:1.5">jack · <a href="https://x.com/jack/status/20" rel="noopener nofollow" target="_blank">See the post on X</a></figcaption>
</figure>

<figure class="bw-embed bw-embed-link" style="margin:2rem 0">
  <a href="https://www.coingecko.com/en/coins/hyperliquid" rel="noopener" target="_blank" style="display:block;padding:1rem 1.2rem;border:1px solid rgba(127,127,127,.3);border-radius:12px;text-decoration:none;color:inherit">
    <strong style="display:block">Hyperliquid price</strong><span style="font-size:.875rem;opacity:.7">coingecko.com</span>
  </a>
</figure>

Check the test post on desktop and on mobile:
- the In short box is styled;
- the contents list links jump to their headings;
- the table, the SVG image and the link card render;
- the TradingView chart and the X post render as real frames, not just their captions;
- an iframe from a host not in the list, or a <script> inside a bw-embed figure, is dropped.

STEP 5 — TEST IT, FOR REAL
Run the site locally and send these with curl (replace TOKEN and the port). Show me each response.
a) No token — expect 401, nothing stored:
   curl -i -X POST http://localhost:3000/api/webhooks/blogwriter -H 'Content-Type: application/json' -d '{}'
b) New draft — expect 200, post stored as draft, not visible on /blog:
   curl -i -X POST http://localhost:3000/api/webhooks/blogwriter -H 'Authorization: Bearer TOKEN' \
     -H 'Content-Type: application/json' \
     -d '{"event_type":"publish_articles","timestamp":"2026-09-24T12:00:00Z","data":{"articles":[{"id":"test-1","title":"Webhook test","content_markdown":"> **In short:** it works.\n\n## First\n\nHello.","content_html":"<blockquote><strong>In short:</strong> it works.</blockquote><h2 id=\"first\">First</h2><p>Hello.</p>","meta_description":"A test.","created_at":"2026-09-24T00:00:00Z","image_url":null,"slug":"webhook-test","tags":["test"],"status":"draft"}]}}'
c) The same request again — expect 200 and still exactly one post.
d) Publish it — send (b) with "status":"published" — expect it on /blog/webhook-test.
e) Update with NO status and a new slug — expect it still published, at /blog/webhook-test-2, and
   /blog/webhook-test redirecting there:
   curl -i -X POST http://localhost:3000/api/webhooks/blogwriter -H 'Authorization: Bearer TOKEN' \
     -H 'Content-Type: application/json' \
     -d '{"event_type":"update_article","timestamp":"2026-09-25T09:00:00Z","data":{"article":{"id":"test-1","title":"Webhook test, renamed","content_markdown":"Changed.","content_html":"<p>Changed.</p>","meta_description":"","created_at":"2026-09-24T00:00:00Z","image_url":null,"slug":"webhook-test-2","tags":[]}}}'
f) Unknown event — "event_type":"ping" — expect 400.
g) Send THE TEST POST above as a published article (its body as content_markdown, and rendered as
   content_html) and check it against the list under it, on desktop and mobile widths.
Then delete the test posts. If the project has an automated test setup, add tests for (a)–(f).

DONE WHEN
- All of STEP 5 behaves as described, and every existing post renders exactly as it did before.
- You have told me, at the end, in this exact form:
    Webhook URL:   https://<the production domain>/api/webhooks/blogwriter
    Access token:  <the token> (also set BLOGWRITER_WEBHOOK_TOKEN on the host)
    Body stored:   content_markdown | content_html
    Drafts:        supported | not supported (posts publish on arrival)
    Scheduling:    supported | not supported
  plus anything I still have to do by hand (deploying, adding the env variable on the host).
I will paste the URL and the token into blogwriter: Settings -> The client's CMS -> Webhook, or
`blogwriter cms setup webhook`.