# Mark article webhook — receiver contract

Source of truth: https://marketinist.com/webhook.md (this document). Human version: https://marketinist.com/webhook

Mark is a marketing assistant that writes and publishes blog articles for its customers. When a customer's website is not WordPress or Wix, Mark **POSTs each finished article to an HTTPS endpoint the customer supplies**, signed with a shared secret. This document specifies everything the endpoint must do. It is written for the developer (or coding agent) implementing that endpoint.

## 1. What the customer configures in Mark

Two values, entered on Mark's connect screen:

1. **Address** — an `https://` URL. Plain `http://` is refused by Mark before anything is sent.
2. **Signing secret** — any long random string. The endpoint must hold the same string. Mark never shows it again after saving.

When the customer presses **Connect**, Mark immediately sends a signed `ping` (section 5) and **refuses to save the connection unless the endpoint answers 2xx**. The endpoint must therefore be deployed and reachable before the customer connects.

## 2. Every request

`POST <address>` with:

```
content-type: application/json
accept: application/json
X-Mark-Timestamp: <unix seconds, when Mark signed the request>
X-Mark-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<rawBody>" keyed with the secret>
```

The signed string is the timestamp, a literal full stop (`.`), then the **raw request body bytes**. The body is compact JSON (`JSON.stringify` output: no whitespace, keys in the order shown below).

## 3. Verifying a request (MUST)

1. **Read the raw body bytes first.** Compute the HMAC over exactly those bytes. Do not parse and re-serialise — the bytes will differ and the signature will not match.
2. Compute `expected = "sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + rawBody))`.
3. **Compare in constant time** (`crypto.timingSafeEqual` or equivalent). A plain string comparison leaks the signature byte by byte.
4. **Reject stale timestamps yourself.** Mark does nothing to prevent the same signed request arriving twice — a network retry does exactly that. Refuse anything older than about five minutes.
5. On a bad or missing signature answer **`401`** or **`403`**. Mark then tells the customer their secret does not match.

Reference implementation (Node):

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// The raw body, exactly as received — parse it only AFTER verifying.
// JSON.parse followed by JSON.stringify will not reproduce these bytes.
export function verify(rawBody, headers, secret) {
  const timestamp = headers['x-mark-timestamp'];
  const signature = headers['x-mark-signature'];
  if (!timestamp || !signature) return false;

  // Your call, not ours: reject anything older than a few minutes.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected =
    'sha256=' +
    createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');

  // Constant-time. A plain === leaks the signature one byte at a time.
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

## 4. Test vectors (acceptance test)

A receiver whose `verify` accepts both vectors below and rejects a tampered body is signing what Mark signs. Run these as a unit test before connecting.

### Vector: `ping`

| | |
|---|---|
| secret | `mark-test-secret` |
| `X-Mark-Timestamp` | `1700000000` |
| body (exact bytes) | `{"event":"ping"}` |
| `X-Mark-Signature` | `sha256=9e8b25f1a52f67bacb60f730d495c2d530b463091f29cae717c923fdc8e33810` |

### Vector: `article.publish`

| | |
|---|---|
| secret | `mark-test-secret` |
| `X-Mark-Timestamp` | `1700000000` |
| body (exact bytes) | `{"event":"article.publish","article":{"title":"Hello from Mark","body":"<h2>Hello</h2><p>A test article.</p>","slug":"hello-from-mark"}}` |
| `X-Mark-Signature` | `sha256=6414e8a283c8d363851aa91d36b4cae467bf000f6ffb3fa802d28e848115e29a` |

```js
// Passes against your verify() if — and only if — it signs what Mark signs.
assert(verify("{\"event\":\"ping\"}", { 'x-mark-timestamp': '1700000000', 'x-mark-signature': 'sha256=9e8b25f1a52f67bacb60f730d495c2d530b463091f29cae717c923fdc8e33810' }, 'mark-test-secret') === true);
assert(verify("{\"event\":\"article.publish\",\"article\":{\"title\":\"Hello from Mark\",\"body\":\"<h2>Hello</h2><p>A test article.</p>\",\"slug\":\"hello-from-mark\"}}", { 'x-mark-timestamp': '1700000000', 'x-mark-signature': 'sha256=6414e8a283c8d363851aa91d36b4cae467bf000f6ffb3fa802d28e848115e29a' }, 'mark-test-secret') === true);
// And a tampered body must fail:
assert(verify('{"event":"pong"}', { 'x-mark-timestamp': '1700000000', 'x-mark-signature': 'sha256=9e8b25f1a52f67bacb60f730d495c2d530b463091f29cae717c923fdc8e33810' }, 'mark-test-secret') === false);
```

## 5. The three events

The body is always a JSON object with an `event` field.

### `ping`

Body: `{"event":"ping"}`. Sent once, when the customer connects (and whenever they use **Test my endpoint** on https://marketinist.com/webhook). Answer any 2xx; the response body is ignored. `204` is fine.

### `article.publish`

Publish a new page. Body:

```json
{
  "event": "article.publish",
  "article": {
    "title": "VAT for freelancers, explained",
    "body": "<h2>Who has to register</h2><p>…</p><ul><li>…</li></ul>",
    "excerpt": "A short summary.",
    "tags": [
      "tax",
      "freelancing"
    ],
    "slug": "vat-for-freelancers",
    "heroImageRef": "https://media.example.com/hero.png",
    "seo": {
      "metaTitle": "…",
      "metaDescription": "…"
    },
    "language": "en"
  }
}
```

Answer **2xx with a JSON body carrying both fields**:

```json
{ "publishedRef": "post_8127", "url": "https://yoursite.com/blog/vat-for-freelancers" }
```

- `publishedRef` — whatever your site calls this post (an id, a path). Mark never interprets it; it only sends it back on an update.
- `url` — the **public address the article is actually at**. Mark stores this and later reports search performance against it. If you did not use the suggested slug, this is where you say so.

A 2xx **without both fields is treated as a failure** and reported to the customer: the article would be live and unreachable.

### `article.update`

Mark has rewritten an article that is already live. Body is the publish shape plus your own `publishedRef` at the top level:

```json
{
  "event": "article.update",
  "publishedRef": "post_8127",
  "article": {
    "title": "VAT for freelancers, explained",
    "body": "<h2>Who has to register</h2><p>…</p><ul><li>…</li></ul>",
    "excerpt": "A short summary.",
    "tags": [
      "tax",
      "freelancing"
    ],
    "slug": "vat-for-freelancers",
    "heroImageRef": "https://media.example.com/hero.png",
    "seo": {
      "metaTitle": "…",
      "metaDescription": "…"
    },
    "language": "en"
  }
}
```

**Replace the existing page in place. Never create a second one.** Answer 2xx with:

```json
{ "url": "https://yoursite.com/blog/vat-for-freelancers" }
```

`url` is the address **after** the update — a site that re-slugs on a title change has moved the page. Answer `404` if the `publishedRef` is unknown.

## 6. The article object

| field | presence | meaning |
|---|---|---|
| `title` | always | plain text |
| `body` | always | simple HTML: `<h2>`, `<p>`, `<ul>`/`<li>`, occasionally `<strong>`/`<em>`/`<a>`. **Never Markdown.** Store or render it as HTML. |
| `excerpt` | optional | one or two sentences |
| `tags` | optional | array of strings |
| `slug` | optional | a **suggestion** for the URL path segment. Use it, or use your own — and return the real one in `url`. |
| `heroImageRef` | optional | a **public URL on Mark's storage**. Download it and host it on your own site rather than linking to it; do not depend on Mark's bucket. |
| `language` | optional | the language the article is **written in**, BCP-47 (`en`, `es`, `pt-BR`). Absent when the customer has never stated one — see section 6.1. |
| `seo.metaTitle` | optional | for `<title>` / og:title |
| `seo.metaDescription` | optional | for the meta description |

Only `title` and `body` are guaranteed. Treat every other field as absent unless present.

### 6.1 `language`, and blogs that carry more than one

`language` is the language **this article's prose is in**, not a setting about your site. Use it to decide where the article belongs — a locale directory, a frontmatter field, a column — and map it onto whatever your own scheme calls that language.

Three things to know, because the field promises less than it looks like it promises:

1. **It is often absent, and you still need a default.** Mark only sends it when the customer has actually told it what language to write in. Nothing is inferred — not from their country, not from the language they speak to Mark in, not from reading their website — so a brand whose owner has never been asked simply has no language, and the field is missing rather than guessed. Treat it as a refinement on your default, never as something to require.
2. **One site can receive from more than one brand, and that is how a bilingual blog works here.** A customer running an English business and a Spanish one points both at your endpoint. The articles arrive on the same address, signed with the same secret if they chose to reuse it, and `language` is the only thing distinguishing them. If you support multiple languages, key on it.
3. **Mark does not produce translations.** Two languages means two independent brands writing independently. There is never a counterpart article, so there is nothing to pair: do not build a "read this in Spanish" link expecting Mark to fill the other side, because it never will. If you want paired translations, that is work you do, not something arriving in this payload.

If your site is single-language, ignore the field entirely.

## 7. Idempotency (MUST)

The same `article.publish` request can arrive more than once (a retry, a customer pressing publish twice). Publishing it twice would create two pages competing for the same search term on the customer's own domain. Key on something stable — the `slug` when present, else a hash of `title` — and make the second arrival a no-op that returns the same `publishedRef` and `url`.

## 8. Status codes Mark understands

| you answer | Mark does |
|---|---|
| 2xx with the required JSON | records `publishedRef`/`url`, tells the customer the article is live |
| 2xx without the required JSON | treats it as failed; tells the customer the site did not say where the article went |
| 401 / 403 | tells the customer the signing secret does not match |
| 429 / 5xx | marks the failure as temporary and tries again later; tells the customer only if the last attempt fails too |
| any other 4xx | tells the customer the site answered with that status |
| unreachable / timeout | tries again later, like a 5xx |

**Mark retries a temporary failure on its own** — up to five attempts with exponential backoff from fifteen seconds, each one a freshly signed request carrying the same article and the same `slug`. That is why section 7 is a MUST: a timeout after you stored the article looks, from our side, exactly like one before.

## 9. What Mark will never send

- **A future publish date.** There is no scheduling field. A scheduled article is held by Mark and sent at its time, so publish whatever arrives, the moment it lands.
- **Markdown.** `body` is HTML.

## 10. Reference implementation (Next.js App Router)

Complete and dependency-free. The two functions at the bottom are the only ones that touch the site's own storage.

```ts
// app/api/mark/route.ts — a complete receiver for Mark's article webhook.
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.MARK_WEBHOOK_SECRET!; // the same string pasted into Mark
const MAX_AGE_SECONDS = 300;

export async function POST(req: Request) {
  // 1. Verify over the RAW bytes, before parsing anything.
  const raw = await req.text();
  const timestamp = req.headers.get('x-mark-timestamp') ?? '';
  const signature = req.headers.get('x-mark-signature') ?? '';
  if (!verify(raw, timestamp, signature)) {
    return new Response('bad signature', { status: 401 });
  }

  const payload = JSON.parse(raw) as
    | { event: 'ping' }
    | { event: 'article.publish'; article: Article }
    | { event: 'article.update'; publishedRef: string; article: Article };

  // 2. Dispatch on the event.
  switch (payload.event) {
    case 'ping':
      return new Response(null, { status: 204 });

    case 'article.publish': {
      // Idempotency is yours: the same signed request can arrive twice.
      const existing = await findPostBySlug(payload.article.slug);
      const post = existing ?? (await createPost(payload.article));
      return Response.json({ publishedRef: post.id, url: post.url });
    }

    case 'article.update': {
      // Replace the page in place — never create a second one.
      const post = await updatePost(payload.publishedRef, payload.article);
      if (!post) return new Response('unknown publishedRef', { status: 404 });
      return Response.json({ url: post.url }); // the address AFTER the update
    }

    default:
      return new Response('unknown event', { status: 400 });
  }
}

function verify(raw: string, timestamp: string, signature: string): boolean {
  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_AGE_SECONDS) return false;
  const expected =
    'sha256=' + createHmac('sha256', SECRET).update(`${timestamp}.${raw}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

// ── Your site's storage. These are the only two things to write. ──────────────

interface Article {
  title: string;
  body: string; // simple HTML: h2, p, ul/li — never Markdown
  excerpt?: string;
  tags?: string[];
  slug?: string; // a suggestion; use your own and return the real url
  heroImageRef?: string; // a public URL on Mark's storage — download and host it yourself
  seo?: { metaTitle?: string; metaDescription?: string };
  language?: string; // BCP-47, and often absent — see section 6.1
}

interface Post {
  id: string;
  url: string;
}

async function findPostBySlug(slug: string | undefined): Promise<Post | null> {
  /* look the slug up in your CMS / database / content directory */
  return null;
}

async function createPost(article: Article): Promise<Post> {
  /* write the article where your site reads posts from; return its id and public URL */
  throw new Error('not implemented');
}

async function updatePost(id: string, article: Article): Promise<Post | null> {
  /* replace the existing post's content; return its (possibly new) public URL */
  throw new Error('not implemented');
}
```

## 11. Sites built from Markdown at deploy time

If your site is a static site generator reading Markdown files out of a repository (Astro, Eleventy, Hugo, Jekyll, Next.js with MDX, Gatsby), nothing in this contract changes — the receiver commits a file instead of writing a database row, and `publishedRef` is the file's path. Four things are specific to this shape, and **three of them fail silently**, so they are worth reading before writing any code.

### 11.1 The four rules

1. **Do not put Mark's HTML straight into a `.md` file without checking your pipeline renders it.** CommonMark passes raw HTML through, but many setups do not. **Hugo does not, by default**: `markup.goldmark.renderer.unsafe` is `false` unless you set it, and raw HTML mixed into Markdown is then not rendered. Any pipeline with a sanitizer (`rehype-sanitize`) behaves the same way. The result is an article that commits fine, builds fine, and is **blank on the page**, with nothing anywhere reporting an error. Either convert the body to Markdown (section 11.2) or turn raw HTML on deliberately and know you have.
2. **Answer with the URL the article *will* be at.** Your commit has only just triggered a build; the page is not live for another minute or two. That is fine and you should still return the address — a static site's permalink is deterministic, so it is a fact, not a guess. Be aware that Mark tells the customer the article is live at that moment, so they can reach a 404 briefly if they click immediately.
3. **Quote and escape everything you write into frontmatter.** A title containing a colon or a double quote is ordinary and breaks YAML. This is the one failure here that is loud — a red build — which makes it the least dangerous of the four. Section 11.3 has a helper.
4. **Do not rename the file on `article.update`.** Keep the path you returned as `publishedRef`, even if the title changed. Renaming moves the page, and the URL is what Mark joins search performance on, so a rename silently throws away everything known about how that article performs. If you must re-slug, leave a redirect at the old address.

Two smaller ones: `slug` is optional, so slugify the title when it is absent (section 11.3); and two commits landing on one branch together can conflict, so expect the occasional `409` from your host's API and retry once.

**If your site is multilingual**, `language` (section 6.1) is what picks the directory — `src/content/${language}/blog/${slug}.md` rather than one `blog` folder — and it usually belongs in the frontmatter as well, since most generators want it there too. Fall back to your primary language when the field is absent, which it often is. The receiver in 11.4 does neither, because it is written for the single-language case; adding it is one line in each of two places.

### 11.2 Converting the body to Markdown

The body is a **closed set** of tags — headings, paragraphs, lists, links, images, and the odd `<strong>`, `<em>` and `<code>` — so an exact converter is short and needs no dependency.

It **throws on a tag it does not recognise** rather than dropping it. That is deliberate: a silently discarded section is a published, half-empty article that nothing reports, which is rule 1 above all over again. If it throws, answer `422` and Mark tells the customer.

```js
// Mark's article body -> Markdown. No dependencies.
//
// Mark emits a small, fixed set of tags (see the contract). This converts
// exactly that set and THROWS on anything else rather than dropping it: a
// silently discarded section is the failure this section of the contract is
// about. If it throws, answer 4xx and Mark tells the customer.

const BLOCK = /<(h2|h3|p|ul|ol|blockquote|pre)\b[^>]*>([\s\S]*?)<\/\1\s*>|<(hr|img)\b[^>]*>/gi;

export function htmlToMarkdown(html) {
  const out = [];
  let cursor = 0;

  for (const m of html.matchAll(BLOCK)) {
    refuseStrayMarkup(html.slice(cursor, m.index));
    cursor = m.index + m[0].length;

    const tag = (m[1] ?? m[3]).toLowerCase();
    const inner = m[2] ?? '';

    if (tag === 'h2') out.push(`## ${inline(inner)}`);
    else if (tag === 'h3') out.push(`### ${inline(inner)}`);
    else if (tag === 'p') out.push(inline(inner));
    else if (tag === 'hr') out.push('---');
    else if (tag === 'img') out.push(inline(m[0]));
    else if (tag === 'blockquote') out.push(`> ${inline(inner).replace(/\n/g, '\n> ')}`);
    else if (tag === 'pre') out.push('```\n' + plain(inner.replace(/<\/?code\b[^>]*>/gi, '')) + '\n```');
    else out.push(listOf(inner, tag === 'ol'));
  }

  refuseStrayMarkup(html.slice(cursor));
  return out.filter(Boolean).join('\n\n') + '\n';
}

function listOf(inner, ordered) {
  const items = [...inner.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li\s*>/gi)];
  if (!items.length) throw new Error('A list with no <li> items');
  return items.map((li, i) => `${ordered ? `${i + 1}.` : '-'} ${inline(li[1])}`).join('\n');
}

/** Between two blocks there may be whitespace and nothing else. */
function refuseStrayMarkup(between) {
  if (between.trim()) throw new Error(`Unsupported markup: ${between.trim().slice(0, 60)}`);
}

const OPEN = { strong: '**', b: '**', em: '*', i: '*', code: '`' };

/**
 * Walks the run token by token so that **text is escaped and markup is not** —
 * escaping the finished Markdown instead is the obvious mistake, and it turns
 * every bold run into literal asterisks.
 */
function inline(html) {
  const token = /<\/?([a-z0-9]+)\b([^>]*)>/gi;
  let out = '';
  let cursor = 0;
  const open = [];

  for (const m of html.matchAll(token)) {
    out += escapeText(html.slice(cursor, m.index));
    cursor = m.index + m[0].length;

    const name = m[1].toLowerCase();
    const closing = m[0].startsWith('</');

    // A backslash, not two spaces: the whitespace normaliser below would eat
    // trailing spaces and turn the break back into one long line.
    if (name === 'br') { out += '\\\n'; continue; }
    if (name === 'img') { out += `![${attr(m[2], 'alt')}](${attr(m[2], 'src')})`; continue; }
    if (name === 'a') {
      if (closing) out += `](${open.pop() ?? ''})`;
      else { out += '['; open.push(attr(m[2], 'href')); }
      continue;
    }
    if (name in OPEN) { out += OPEN[name]; continue; }
    throw new Error(`Unsupported markup: <${closing ? '/' : ''}${name}>`);
  }

  out += escapeText(html.slice(cursor));
  return out.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
}

function attr(attrs, name) {
  return decode(attrs.match(new RegExp(`\\b${name}=["']([^"']*)["']`, 'i'))?.[1] ?? '');
}

/** A text run: entities resolved, then Markdown's own punctuation defused. */
function escapeText(s) {
  return decode(s).replace(/([\\`*_[\]])/g, '\\$1');
}

/** Inside a fenced block nothing is escaped, but entities still resolve. */
function plain(s) {
  return decode(s).trim();
}

/**
 * `&lt;` and `&gt;` are deliberately NOT decoded: a bare `<thing>` in Markdown
 * is raw HTML, which is exactly what half of these pipelines refuse to render.
 */
function decode(s) {
  return s.replace(/&(amp|quot|#39|apos|nbsp|mdash|ndash|hellip);/g, (_, e) =>
    ({ amp: '&', quot: '"', '#39': "'", apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…' })[e]);
}
```

### 11.3 Frontmatter and slugs

```js
/**
 * YAML frontmatter that survives a title with a colon, a quote or a newline —
 * the one thing here that breaks a build rather than failing quietly.
 * Every scalar is double-quoted and escaped; nothing is emitted bare.
 */
export function frontmatter(fields) {
  const lines = ['---'];
  for (const [key, value] of Object.entries(fields)) {
    if (value === undefined || value === null || value === '') continue;
    if (Array.isArray(value)) {
      if (!value.length) continue;
      lines.push(`${key}:`, ...value.map((v) => `  - ${yaml(v)}`));
    } else {
      lines.push(`${key}: ${yaml(value)}`);
    }
  }
  lines.push('---');
  return lines.join('\n');
}

function yaml(value) {
  if (typeof value === 'boolean' || typeof value === 'number') return String(value);
  return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
}

/** A filename-safe slug, for when Mark suggests none. */
export function slugify(title) {
  return title
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '')
    .slice(0, 80);
}
```

### 11.4 A complete receiver that commits to a repository

Writes the article, the frontmatter and the hero image into a Git repository through the GitHub contents API, and is idempotent because the file existing *is* the record. Swap the constants at the top, and the two imports are the helpers above.

```ts
// app/api/mark/route.ts — a receiver for a site built from Markdown in a repo.
//
// It commits a file instead of writing a database row. Everything specific to
// a build-time site is here: Markdown instead of HTML, frontmatter, a hero
// image committed beside the article, and a URL predicted from the slug
// because the page does not exist until the build finishes.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { htmlToMarkdown } from '@/lib/html-to-markdown'; // section 11.2
import { frontmatter, slugify } from '@/lib/frontmatter'; // section 11.3

const SECRET = process.env.MARK_WEBHOOK_SECRET!;
const REPO = 'acme/website';               // owner/repo
const BRANCH = 'main';
const GITHUB_TOKEN = process.env.GITHUB_TOKEN!; // contents: write
const SITE = 'https://yoursite.com';
const POSTS = 'src/content/blog';          // where your SSG reads posts from
const IMAGES = 'public/images';

interface Article {
  title: string;
  body: string;
  excerpt?: string;
  tags?: string[];
  slug?: string;
  heroImageRef?: string;
  seo?: { metaTitle?: string; metaDescription?: string };
  language?: string; // BCP-47, and often absent — see section 6.1
}

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verify(raw, req.headers.get('x-mark-timestamp') ?? '', req.headers.get('x-mark-signature') ?? '')) {
    return new Response('bad signature', { status: 401 });
  }

  const payload = JSON.parse(raw);

  try {
    switch (payload.event) {
      case 'ping':
        return new Response(null, { status: 204 });

      case 'article.publish': {
        const slug = payload.article.slug || slugify(payload.article.title);
        const path = `${POSTS}/${slug}.md`;

        // Idempotency: the same publish can arrive twice. The file existing IS
        // the record, so a second arrival returns the first one's answer.
        const existing = await getFile(path);
        if (!existing) await writeArticle(path, slug, payload.article, undefined);

        // The URL the article WILL be at. The commit has only just triggered a
        // build, so the page is not live for another minute or two — but the
        // address is deterministic, so this is the truth and not a guess.
        return Response.json({ publishedRef: path, url: `${SITE}/blog/${slug}/` });
      }

      case 'article.update': {
        const path: string = payload.publishedRef;
        const current = await getFile(path);
        if (!current) return new Response('unknown publishedRef', { status: 404 });

        // The file KEEPS ITS PATH even if the title changed. Renaming it would
        // move the page, and the address is what Mark joins search performance
        // on — so a rename silently throws away the article's history. If you
        // ever do rename, leave a redirect behind.
        const slug = path.split('/').pop()!.replace(/\.md$/, '');
        await writeArticle(path, slug, payload.article, current.sha);
        return Response.json({ url: `${SITE}/blog/${slug}/` });
      }

      default:
        return new Response('unknown event', { status: 400 });
    }
  } catch (err) {
    // A conversion that refused, or GitHub saying no. Answer a status rather
    // than a 2xx: Mark tells the customer, who can publish again.
    console.error('mark webhook', err);
    return new Response(String((err as Error).message), { status: 422 });
  }
}

/** Article + frontmatter + hero image, committed together. */
async function writeArticle(path: string, slug: string, article: Article, sha: string | undefined) {
  let hero: string | undefined;
  if (article.heroImageRef) {
    // Downloaded and committed, never hotlinked: the page must not depend on
    // Mark's storage for as long as it exists.
    const res = await fetch(article.heroImageRef);
    if (!res.ok) throw new Error(`hero image ${res.status}`);
    const ext = (new URL(article.heroImageRef).pathname.match(/\.[a-z0-9]+$/i)?.[0] ?? '.png');
    hero = `${IMAGES}/${slug}${ext}`;
    await commit(hero, Buffer.from(await res.arrayBuffer()).toString('base64'), `Image for ${slug}`, (await getFile(hero))?.sha);
  }

  const meta = frontmatter({
    title: article.title,
    description: article.seo?.metaDescription ?? article.excerpt,
    tags: article.tags,
    date: new Date().toISOString().slice(0, 10),
    image: hero ? `/${hero.replace(/^public\//, '')}` : undefined,
    // Absent for a customer who never stated one, so fall back rather than
    // require it. On a multilingual site this also picks the directory.
    lang: article.language ?? 'en',
  });

  // htmlToMarkdown THROWS on a tag it does not know, which is deliberate: a
  // silently dropped section is worse than a failed publish.
  const file = `${meta}\n\n${htmlToMarkdown(article.body)}`;
  await commit(path, Buffer.from(file, 'utf8').toString('base64'), `${sha ? 'Update' : 'Publish'}: ${article.title}`, sha);
}

async function getFile(path: string): Promise<{ sha: string } | null> {
  const res = await gh(`${path}?ref=${BRANCH}`);
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`GitHub ${res.status}`);
  return (await res.json()) as { sha: string };
}

async function commit(path: string, contentBase64: string, message: string, sha?: string) {
  const res = await gh(path, {
    method: 'PUT',
    body: JSON.stringify({ message, content: contentBase64, branch: BRANCH, ...(sha ? { sha } : {}) }),
  });
  // 409 means somebody else committed between the read and the write. One
  // retry is enough; two concurrent publishes to one branch is rare.
  if (!res.ok) throw new Error(`GitHub ${res.status} writing ${path}`);
}

function gh(path: string, init: RequestInit = {}) {
  return fetch(`https://api.github.com/repos/${REPO}/contents/${path}`, {
    ...init,
    headers: {
      authorization: `Bearer ${GITHUB_TOKEN}`,
      accept: 'application/vnd.github+json',
      'content-type': 'application/json',
    },
  });
}

function verify(raw: string, timestamp: string, signature: string): boolean {
  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = 'sha256=' + createHmac('sha256', SECRET).update(`${timestamp}.${raw}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

## 12. Checklist before the customer connects

- [ ] Endpoint is deployed at an `https://` URL and answers `204` to a signed `ping`.
- [ ] Signature is computed over the **raw body**, as `<timestamp>.<body>`, compared in constant time.
- [ ] Both test vectors in section 4 pass; a tampered body fails.
- [ ] Requests older than ~5 minutes are refused.
- [ ] `article.publish` returns `{ publishedRef, url }`; `article.update` returns `{ url }`.
- [ ] A second identical publish does not create a second page.
- [ ] `heroImageRef` is downloaded and re-hosted, not hotlinked.
- [ ] The customer has the signing secret to paste into Mark.

If your site is built from Markdown at deploy time, also:

- [ ] A built page actually shows the article body — not a blank page because raw HTML was stripped (section 11.1).
- [ ] A title containing a colon and a double quote does not break the build.
- [ ] `article.update` rewrites the file in place and does not rename it.
- [ ] The URL you return matches the permalink your generator actually produces.

A signed-in Mark customer with a blog on their plan can send a live `ping` to the endpoint from https://marketinist.com/webhook#test before connecting.
