Marketinist

Publishing to your own site

If Mark can’t publish to your site directly, it can post each finished article to an address you choose. This page is everything needed to receive one. It’s written for whoever looks after the site — if that isn’t you, this is the page to send them.

You give Mark two things on the connect screen: the address to post to, and a signing secret (any long random string — Mark never shows it again, so keep a copy). Your endpoint checks the signature against that same secret and publishes the article however your site publishes things.

For your coding agent

This whole contract — the rules, the test vectors and a working receiver — is one Markdown document written to be implemented from directly. Copy it into your agent, or point the agent at its address and let it fetch the current version.

Or open it at https://marketinist.com/webhook.md

A prompt that works on its own:

Implement a receiver for Mark's article webhook in this project. The full contract, test vectors and a reference implementation are at https://marketinist.com/webhook.md — fetch it and follow it exactly. Verify the HMAC over the raw request body, make publishing idempotent, and add a unit test using the published test vectors before you finish.

The document is in English whatever language this page is in. It is a technical contract read by machines and by whoever writes the code, and one source beats two.

The request

A POST with content-type: application/json and two headers:

Verify against the raw body bytes. Parsing the JSON and re-serialising it will not reproduce them, and the signature will not match.

Verifying it

Node, and the same code Mark signs with:

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);
}

Rejecting replays is your endpoint’s job, not ours. The timestamp is there so you can do it, and nothing on our side prevents the same signed request arriving twice — a network retry does exactly that. Check the age, and if publishing twice would create two pages, key on something in the article and make the second one a no-op.

Answer 401 or 403 when the signature is wrong. Mark tells the customer their secret doesn’t match, which is almost always what has happened.

Test vectors

Two signed requests with known answers. A receiver that accepts both and refuses a tampered body is signing what Mark signs — which is the one thing that actually goes wrong. Test against these before the customer connects, not after.

// 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);

The secret above is published and is only for this test. Every real connection uses the customer’s own.

The three events

ping

Body is { "event": "ping" }. Sent once, when the customer connects. Answer any 2xx — the body is ignored. Mark refuses to save a connection until this succeeds, so the endpoint has to be live before they press connect.

article.publish

{
  "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"
  }
}

Only title and body are always present; everything else may be absent. body is simple HTML — <h2> headings, <p> paragraphs, <ul>/<li> lists, never Markdown. slug is a suggestion — use your own if you’d rather, and tell us which in the reply.

heroImageRef is a public URL on Mark’s storage. Fetch it and host it on your own site rather than linking straight to it — otherwise your page depends on our bucket for as long as it exists. (This is what Mark’s direct integrations do: they re-upload the image into the site’s own media library.)

Answer 2xx with both of these:

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

publishedRef is whatever your site calls this post — Mark sends it back when asking for a rewrite, and never interprets it. url is the address the article is actually at, which is what Mark reports on later. Both are required: without them the article is published and unreachable, so Mark treats a reply missing either as a failure and says so in chat.

article.update

Same shape plus the publishedRef you gave us, when Mark rewrites an article that is already live. Replace the existing page — don’t create a second one. Answer 2xx with { "url": "…" }: the address after the update, since a site that re-slugs on a title change has moved the page.

Blogs in more than one language

language is the language this article’s prose is in, as a BCP-47 tag — en, es, pt-BR. Use it to decide where the article belongs: a locale folder, a frontmatter field, a column. It is not a setting about your site, so map it onto whatever your own scheme calls that language.

If your site is in one language, ignore the field entirely.

Publishing twice

The same publish can reach you more than once — Mark’s own retry after a timeout, or a network retry on the way. Two pages for one article compete with each other for the same search term, on your own domain, which is the problem this product sells a fix for. Key on the slug when there is one, and make the second arrival return the first one’s answer.

What your answer means

A complete receiver

Next.js App Router, no dependencies, every branch of the contract. The two functions at the bottom are the only ones that touch your own storage. For any other stack, the contract document above is what to hand your agent.

// 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');
}

Sites built from Markdown

If your site is a static generator reading Markdown out of a repository, nothing in the contract changes — your receiver commits a file instead of writing a row, and publishedRef is that file’s path. Four things are specific to this shape, and three of them fail without an error anywhere.

  1. Check that your pipeline renders raw HTML before you paste Mark’s body into a .md file. Many do not. Hugo does not by default — markup.goldmark.renderer.unsafe is false unless you set it — and a sanitiser in the chain behaves the same way. The article then commits, the build goes green, and the page is blank. Convert the body to Markdown instead, or turn raw HTML on knowing that you have.
  2. Answer with the address the article will be at. Your commit has only just started a build, so the page is not live for a minute or two — return it anyway, because a permalink is deterministic and that makes it a fact rather than a guess. Just know Mark tells the customer it is live at that moment, so an immediate click can land on a 404.
  3. Quote and escape anything you write into frontmatter. A title with a colon or a double quote in it is ordinary, and it breaks YAML. This is the one of the four that fails loudly, as a red build.
  4. Do not rename the file when Mark rewrites an article. Keep the path you returned as publishedRef even if the title changed. A rename moves the page, and the address is what Mark joins search performance on — so it throws away everything known about how that article has done. If you must re-slug, leave a redirect behind.

Converting the body

The body is a closed set of tags, so an exact converter is short and needs nothing installed. It throws on a tag it does not know rather than dropping it — a silently discarded section is the first rule above happening all over again. If it throws, answer 422 and Mark tells the customer.

// 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]);
}

Frontmatter and slugs

slug is optional, so fall back to slugifying the title.

/**
 * 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);
}

A receiver that commits to a repository

The article, its frontmatter and the hero image, written into a Git repository. It is idempotent because the file existing is the record. Swap the constants at the top; the two imports are the helpers above.

// 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);
}

Test your endpoint

Send the same signed ping the connect step sends, and see what your endpoint answers. Nothing is saved — this is the connect check without the connection.

Testing sends a request from our servers to an address you type, so it is for customers only. Sign in and it appears here.

Dates and retries

Publishing to your own site · Mark