LinkLoom

Webhook Integration Guide

Everything you need to receive content from LinkLoom and publish it to your site, regardless of platform or framework.

Payload Structure

When you publish content from LinkLoom, the following JSON payload is sent via POST to your configured webhook URL.

payload.json
{
  "title": "My Article Title",
  "content": "<p>HTML content of the article...</p>",
  "excerpt": "Brief summary of the article",
  "author": "John Doe",
  "published_at": "2026-01-18T12:00:00Z",
  "site_name": "My Tech Blog",
  "cover_image_url": "https://example.com/image.jpg",
  "slug": "my-article-title",
  "url_format": "/blog/{slug}",
  "url_path": "/blog/my-article-title",
  "category": { "id": "uuid-here", "name": "Technology", "slug": "technology" },
  "request_id": "req_abc123_1705579200000"
}

Path Fields

Field Description
slugURL-safe identifier for the content
url_pathPre-computed full URL path (e.g. /blog/my-article-title)
url_formatTemplate pattern with placeholder (e.g. /blog/{slug})
categoryObject with id, name, and slug

Idempotency

Field Description
request_idUnique identifier per publish event. Use this to prevent duplicate content if the same webhook fires more than once.

Security

Every webhook request includes an authorization header. Validate this token before processing any payload.

HTTP Header
Authorization: Bearer your-secret-token

The token is configured in your LinkLoom dashboard under Site Settings → Publishing → Webhook Settings. Always reject requests where the token does not match your stored secret.

Static Site Receivers

For static sites on Cloudflare Pages, Vercel, or Netlify. The function receives the webhook, commits a markdown file to GitHub, and your hosting provider auto-deploys.

Environment Variables

Variable Required Description
LINKLOOM_WEBHOOK_SECRETYesToken to validate incoming requests
GITHUB_TOKENYesPersonal access token with repo scope
GITHUB_REPOYesRepository in owner/repo format
GITHUB_BRANCHNoTarget branch (defaults to main)
CONTENT_PATHNoDirectory for content files (defaults vary by platform)
functions/api/receive-content.ts
interface Env {
  LINKLOOM_WEBHOOK_SECRET: string;
  GITHUB_TOKEN: string;
  GITHUB_REPO: string;
  GITHUB_BRANCH?: string;
  CONTENT_PATH?: string;
}
interface Payload {
  title: string; content: string; excerpt?: string; author?: string;
  published_at?: string; cover_image_url?: string; slug: string;
  url_path?: string; category?: { name?: string; slug?: string };
  request_id?: string;
}
const CORS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, content-type',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
};

export const onRequestOptions: PagesFunction = async () =>
  new Response(null, { headers: CORS });

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const token = (request.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '');
  if (!env.LINKLOOM_WEBHOOK_SECRET || token !== env.LINKLOOM_WEBHOOK_SECRET)
    return json({ error: 'Unauthorized' }, 401);

  let p: Payload;
  try { p = await request.json(); } catch { return json({ error: 'Invalid JSON' }, 400); }
  if (!p?.slug || !p?.title || !p?.content)
    return json({ error: 'Missing required fields' }, 400);

  const slug = p.slug.toLowerCase().replace(/[^a-z0-9-_]/g, '-').replace(/-+/g, '-');
  const branch = env.GITHUB_BRANCH || 'main';
  const root = (env.CONTENT_PATH || 'src/pages/blog').replace(/^\/|\/$/g, '');
  const path = `${root}/${slug}/index.md`;
  const gh = {
    Authorization: `Bearer ${env.GITHUB_TOKEN}`,
    Accept: 'application/vnd.github+json',
    'User-Agent': 'linkloom-webhook',
    'X-GitHub-Api-Version': '2022-11-28',
  };

  const get = await fetch(
    `https://api.github.com/repos/${env.GITHUB_REPO}/contents/${path}?ref=${branch}`,
    { headers: gh }
  );
  let sha: string | undefined;
  if (get.status === 200) {
    const data: any = await get.json();
    sha = data.sha;
    const existing = atob(data.content.replace(/\s+/g, ''));
    const reqId = existing.match(/^request_id:\s*"?([^"\n]+)"?/m)?.[1];
    if (p.request_id && reqId === p.request_id)
      return json({ success: true, action: 'skipped', duplicate: true });
  }

  const fm = ['---', `title: ${JSON.stringify(p.title)}`];
  if (p.excerpt) fm.push(`description: ${JSON.stringify(p.excerpt)}`);
  if (p.author) fm.push(`author: ${JSON.stringify(p.author)}`);
  if (p.published_at) fm.push(`date: ${JSON.stringify(p.published_at)}`);
  if (p.cover_image_url) fm.push(`cover: ${JSON.stringify(p.cover_image_url)}`);
  if (p.category?.name) fm.push(`category: ${JSON.stringify(p.category.name)}`);
  if (p.request_id) fm.push(`request_id: ${JSON.stringify(p.request_id)}`);
  fm.push('---', '', p.content, '');
  const md = fm.join('\n');

  const put = await fetch(
    `https://api.github.com/repos/${env.GITHUB_REPO}/contents/${path}`,
    {
      method: 'PUT',
      headers: { ...gh, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: sha ? `Update: ${p.title}` : `Add: ${p.title}`,
        content: btoa(unescape(encodeURIComponent(md))),
        branch,
        ...(sha ? { sha } : {}),
      }),
    }
  );
  if (!put.ok) return json({ error: 'GitHub commit failed' }, 502);
  return json({
    success: true,
    action: sha ? 'updated' : 'published',
    url_path: p.url_path || `/blog/${slug}/`,
  });
};

function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { 'Content-Type': 'application/json', ...CORS },
  });
}

Setup

  1. Place the file at functions/api/receive-content.ts
  2. Add environment variables in the Cloudflare Pages dashboard under Settings → Environment variables
  3. Push to your repository — Cloudflare auto-deploys the function
  4. Webhook URL: https://yourdomain.com/api/receive-content

Server-Side Receivers

For dynamic websites with a database. Content is stored directly and rendered on the next request — no rebuild needed.

supabase/functions/receive-content/index.ts
import { serve } from "https://deno.land/[email protected]/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

serve(async (req: Request) => {
  if (req.method === "OPTIONS")
    return new Response(null, {
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "authorization, content-type",
      },
    });

  const token = (req.headers.get("Authorization") || "")
    .replace(/^Bearer\s+/i, "");
  const secret = Deno.env.get("LINKLOOM_WEBHOOK_SECRET");
  if (!secret || token !== secret)
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });

  const p = await req.json();
  if (!p?.slug || !p?.title || !p?.content)
    return new Response(JSON.stringify({ error: "Missing fields" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );

  // Idempotency check
  if (p.request_id) {
    const { data: existing } = await supabase
      .from("published_content")
      .select("id")
      .eq("request_id", p.request_id)
      .maybeSingle();
    if (existing)
      return new Response(
        JSON.stringify({
          success: true,
          duplicate: true,
          content_id: existing.id,
        }),
        { headers: { "Content-Type": "application/json" } }
      );
  }

  const { data, error } = await supabase
    .from("published_content")
    .insert({
      request_id: p.request_id,
      title: p.title,
      slug: p.slug,
      content: p.content,
      excerpt: p.excerpt,
      author: p.author,
      category_name: p.category?.name,
      category_slug: p.category?.slug,
      url_path: p.url_path,
      cover_image_url: p.cover_image_url,
      source_site: p.site_name,
      published_at: p.published_at,
      status: "published",
    })
    .select("id")
    .single();

  if (error)
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });

  return new Response(
    JSON.stringify({
      success: true,
      action: "published",
      content_id: data.id,
      url_path: p.url_path,
    }),
    { headers: { "Content-Type": "application/json" } }
  );
});
supabase/functions/receive-content/config.toml
[functions.receive-content]
verify_jwt = false
Terminal
# Create the function
supabase functions new receive-content

# Set the secret
supabase secrets set LINKLOOM_WEBHOOK_SECRET="your-secret-token"

# Deploy
supabase functions deploy receive-content

Database Schema

Reference PostgreSQL schema used by the Supabase and Node.js examples. Adapt column types for MySQL or other databases as needed.

schema.sql
CREATE TABLE IF NOT EXISTS published_content (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  request_id      TEXT UNIQUE,
  title           TEXT NOT NULL,
  slug            TEXT NOT NULL,
  content         TEXT NOT NULL,
  excerpt         TEXT,
  author          TEXT,
  category_name   TEXT,
  category_slug   TEXT,
  url_path        TEXT,
  url_format      TEXT,
  cover_image_url TEXT,
  source_site     TEXT,
  published_at    TIMESTAMPTZ,
  status          TEXT DEFAULT 'received',
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_content_slug ON published_content(slug);
CREATE INDEX idx_content_request_id ON published_content(request_id);

Setup Steps

Follow these steps to get your webhook integration up and running.

  1. 1

    Choose your integration type

    Static site (GitHub commit pattern) for Astro, Next.js, Hugo, etc. or server-side (database pattern) for dynamic sites with a database.

  2. 2

    Deploy the receiver code

    Use one of the platform-specific examples above. Drop the file into your project and deploy.

  3. 3

    Set the webhook secret

    Add LINKLOOM_WEBHOOK_SECRET as an environment variable on your hosting platform.

  4. 4

    Configure in LinkLoom

    Go to Site Settings → Publishing → Webhook. Enter your receiver URL and the same secret token.

  5. 5

    Test the webhook

    Click "Test Webhook" in Site Settings to send a test payload and verify everything works end to end.