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.
{
"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 |
|---|---|
| slug | URL-safe identifier for the content |
| url_path | Pre-computed full URL path (e.g. /blog/my-article-title) |
| url_format | Template pattern with placeholder (e.g. /blog/{slug}) |
| category | Object with id, name, and slug |
Idempotency
| Field | Description |
|---|---|
| request_id | Unique 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.
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_SECRET | Yes | Token to validate incoming requests |
| GITHUB_TOKEN | Yes | Personal access token with repo scope |
| GITHUB_REPO | Yes | Repository in owner/repo format |
| GITHUB_BRANCH | No | Target branch (defaults to main) |
| CONTENT_PATH | No | Directory for content files (defaults vary by platform) |
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
- Place the file at
functions/api/receive-content.ts - Add environment variables in the Cloudflare Pages dashboard under Settings → Environment variables
- Push to your repository — Cloudflare auto-deploys the function
- Webhook URL:
https://yourdomain.com/api/receive-content
export const runtime = 'edge';
export async function POST(request: Request) {
const token = (request.headers.get('Authorization') || '')
.replace(/^Bearer\s+/i, '');
if (token !== process.env.LINKLOOM_WEBHOOK_SECRET)
return Response.json({ error: 'Unauthorized' }, { status: 401 });
const p: any = await request.json();
if (!p?.slug || !p?.title || !p?.content)
return Response.json({ error: 'Missing fields' }, { status: 400 });
const slug = p.slug.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
const branch = process.env.GITHUB_BRANCH || 'main';
const path = `${process.env.CONTENT_PATH || 'content/blog'}/${slug}.md`;
const repo = process.env.GITHUB_REPO!;
const gh = {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'User-Agent': 'linkloom-webhook',
};
const fm = `---
title: ${JSON.stringify(p.title)}
${p.excerpt ? `description: ${JSON.stringify(p.excerpt)}\n` : ''}\${p.author ? `author: ${JSON.stringify(p.author)}\n` : ''}\${p.published_at ? `date: ${JSON.stringify(p.published_at)}\n` : ''}\${p.request_id ? `request_id: ${JSON.stringify(p.request_id)}\n` : ''}---
${p.content}
`;
const get = await fetch(
`https://api.github.com/repos/${repo}/contents/${path}?ref=${branch}`,
{ headers: gh }
);
let sha: string | undefined;
if (get.status === 200) {
const d: any = await get.json();
sha = d.sha;
const ex = Buffer.from(d.content, 'base64').toString();
const rid = ex.match(/^request_id:\s*"?([^"\n]+)"?/m)?.[1];
if (p.request_id && rid === p.request_id)
return Response.json({ success: true, duplicate: true });
}
const put = await fetch(
`https://api.github.com/repos/${repo}/contents/${path}`,
{
method: 'PUT',
headers: { ...gh, 'Content-Type': 'application/json' },
body: JSON.stringify({
message: sha ? `Update: ${p.title}` : `Add: ${p.title}`,
content: Buffer.from(fm).toString('base64'),
branch,
...(sha ? { sha } : {}),
}),
}
);
if (!put.ok)
return Response.json({ error: 'Commit failed' }, { status: 502 });
return Response.json({
success: true,
action: sha ? 'updated' : 'published',
});
} Setup
- Place the file at
app/api/receive-content/route.ts - Add environment variables in Vercel dashboard under Settings → Environment Variables
- Deploy — the route is available immediately
- Webhook URL:
https://yourdomain.com/api/receive-content
import type { Handler } from '@netlify/functions';
export const handler: Handler = async (event) => {
const token = (event.headers.authorization || '')
.replace(/^Bearer\s+/i, '');
if (token !== process.env.LINKLOOM_WEBHOOK_SECRET)
return {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' }),
};
const p = JSON.parse(event.body || '{}');
if (!p?.slug || !p?.title || !p?.content)
return {
statusCode: 400,
body: JSON.stringify({ error: 'Missing fields' }),
};
// Same GitHub commit logic as Cloudflare/Vercel examples
// 1. Build markdown frontmatter + content
// 2. Check existing file via GitHub API (idempotency)
// 3. PUT to GitHub Contents API to create/update
return {
statusCode: 200,
body: JSON.stringify({ success: true }),
};
}; Setup
- Place the file at
netlify/functions/receive-content.ts - Add environment variables in Netlify dashboard under Site settings → Environment variables
- Deploy — the function is available at
/.netlify/functions/receive-content
Server-Side Receivers
For dynamic websites with a database. Content is stored directly and rendered on the next request — no rebuild needed.
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" } }
);
}); [functions.receive-content]
verify_jwt = false # 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 const express = require('express');
const router = express.Router();
const db = require('../db');
router.post('/receive-content', async (req, res) => {
const token = (req.headers.authorization || '')
.replace(/^Bearer\s+/i, '');
if (token !== process.env.LINKLOOM_WEBHOOK_SECRET)
return res.status(401).json({ error: 'Unauthorized' });
const {
title, content, excerpt, author, published_at, site_name,
cover_image_url, slug, url_path, category, request_id,
} = req.body;
if (!slug || !title || !content)
return res.status(400).json({ error: 'Missing fields' });
try {
if (request_id) {
const ex = await db.query(
'SELECT id FROM posts WHERE request_id = $1',
[request_id]
);
if (ex.rows.length)
return res.json({
success: true,
duplicate: true,
content_id: ex.rows[0].id,
});
}
const result = await db.query(
`INSERT INTO posts (
request_id, title, slug, content, excerpt, author,
category_name, category_slug, url_path, cover_image_url,
source_site, published_at, status
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING id`,
[
request_id, title, slug, content, excerpt, author,
category?.name, category?.slug, url_path, cover_image_url,
site_name, published_at, 'published',
]
);
res.json({
success: true,
action: 'published',
content_id: result.rows[0].id,
url_path,
});
} catch (e) {
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router; <?php
header('Content-Type: application/json');
$token = str_replace('Bearer ', '', $_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (!getenv('LINKLOOM_WEBHOOK_SECRET') || $token !== getenv('LINKLOOM_WEBHOOK_SECRET')) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$p = json_decode(file_get_contents('php://input'), true);
if (empty($p['slug']) || empty($p['title']) || empty($p['content'])) {
http_response_code(400);
echo json_encode(['error' => 'Missing fields']);
exit;
}
try {
$pdo = new PDO(
'mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_NAME'),
getenv('DB_USER'),
getenv('DB_PASS')
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if (!empty($p['request_id'])) {
$s = $pdo->prepare('SELECT id FROM posts WHERE request_id = ?');
$s->execute([$p['request_id']]);
if ($ex = $s->fetch()) {
echo json_encode([
'success' => true,
'duplicate' => true,
'content_id' => $ex['id'],
]);
exit;
}
}
$s = $pdo->prepare(
'INSERT INTO posts (
request_id, title, slug, content, excerpt, author,
category_name, url_path, cover_image_url, source_site,
published_at, status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,"published",NOW())'
);
$s->execute([
$p['request_id'], $p['title'], $p['slug'], $p['content'],
$p['excerpt'] ?? null, $p['author'] ?? null,
$p['category']['name'] ?? null, $p['url_path'] ?? null,
$p['cover_image_url'] ?? null, $p['site_name'] ?? null,
$p['published_at'] ?? null,
]);
echo json_encode([
'success' => true,
'action' => 'published',
'content_id' => $pdo->lastInsertId(),
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
} import json, os
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import Post
@csrf_exempt
@require_POST
def receive_content(request):
auth = request.headers.get('Authorization', '')
token = auth.replace('Bearer ', '', 1) if auth.startswith('Bearer ') else ''
secret = os.environ.get('LINKLOOM_WEBHOOK_SECRET')
if not secret or token != secret:
return JsonResponse({'error': 'Unauthorized'}, status=401)
payload = json.loads(request.body)
if not all([payload.get('slug'), payload.get('title'), payload.get('content')]):
return JsonResponse({'error': 'Missing fields'}, status=400)
request_id = payload.get('request_id')
if request_id:
existing = Post.objects.filter(request_id=request_id).first()
if existing:
return JsonResponse({
'success': True,
'duplicate': True,
'content_id': str(existing.id),
})
cat = payload.get('category', {})
post, created = Post.objects.update_or_create(
slug=payload['slug'],
defaults={
'request_id': request_id,
'title': payload['title'],
'content': payload['content'],
'excerpt': payload.get('excerpt', ''),
'author': payload.get('author', ''),
'category_name': cat.get('name', ''),
'url_path': payload.get('url_path', ''),
'cover_image_url': payload.get('cover_image_url', ''),
'source_site': payload.get('site_name', ''),
'published_at': payload.get('published_at'),
'status': 'published',
},
)
return JsonResponse({
'success': True,
'action': 'published' if created else 'updated',
'content_id': str(post.id),
}) <?php
/**
* Plugin Name: LinkLoom Webhook Receiver
* Description: Receives content from LinkLoom and creates WordPress posts.
*/
add_action('rest_api_init', function () {
register_rest_route('linkloom/v1', '/receive-content', [
'methods' => 'POST',
'callback' => 'linkloom_receive',
'permission_callback' => '__return_true',
]);
});
function linkloom_receive(WP_REST_Request $req) {
$token = str_replace('Bearer ', '', $req->get_header('Authorization') ?? '');
if (empty(LINKLOOM_WEBHOOK_SECRET) || $token !== LINKLOOM_WEBHOOK_SECRET)
return new WP_REST_Response(['error' => 'Unauthorized'], 401);
$p = $req->get_json_params();
$slug = sanitize_title($p['slug'] ?? '');
$title = sanitize_text_field($p['title'] ?? '');
$content = wp_kses_post($p['content'] ?? '');
if (empty($slug) || empty($title) || empty($content))
return new WP_REST_Response(['error' => 'Missing fields'], 400);
$rid = sanitize_text_field($p['request_id'] ?? '');
if ($rid) {
$ex = get_posts([
'meta_key' => '_linkloom_rid',
'meta_value' => $rid,
'post_type' => 'post',
'numberposts' => 1,
]);
if (!empty($ex))
return new WP_REST_Response([
'success' => true,
'duplicate' => true,
'content_id' => $ex[0]->ID,
]);
}
$existing = get_page_by_path($slug, OBJECT, 'post');
$data = [
'post_title' => $title,
'post_content' => $content,
'post_name' => $slug,
'post_status' => 'publish',
'post_excerpt' => $p['excerpt'] ?? '',
];
if ($existing) {
$data['ID'] = $existing->ID;
$pid = wp_update_post($data);
$act = 'updated';
} else {
$pid = wp_insert_post($data);
$act = 'published';
}
if (is_wp_error($pid))
return new WP_REST_Response(['error' => $pid->get_error_message()], 500);
if ($rid) update_post_meta($pid, '_linkloom_rid', $rid);
if (!empty($p['category']['name'])) {
$cat = get_cat_ID($p['category']['name']);
if (!$cat) $cat = wp_create_category($p['category']['name']);
wp_set_post_categories($pid, [$cat]);
}
return new WP_REST_Response([
'success' => true,
'action' => $act,
'content_id' => $pid,
'url' => get_permalink($pid),
]);
} Setup
- Save as a plugin file in
wp-content/plugins/linkloom-webhook/linkloom-webhook.php - Add
define('LINKLOOM_WEBHOOK_SECRET', 'your-token');towp-config.php - Activate the plugin in WordPress admin
- Webhook URL:
https://yoursite.com/wp-json/linkloom/v1/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.
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
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
Deploy the receiver code
Use one of the platform-specific examples above. Drop the file into your project and deploy.
- 3
Set the webhook secret
Add
LINKLOOM_WEBHOOK_SECRETas an environment variable on your hosting platform. - 4
Configure in LinkLoom
Go to Site Settings → Publishing → Webhook. Enter your receiver URL and the same secret token.
- 5
Test the webhook
Click "Test Webhook" in Site Settings to send a test payload and verify everything works end to end.