Connecting a Custom API

Complete guide to connecting any CMS or blog platform via Custom API, including required fields, request format, expected response structure, and troubleshooting tips.

Custom API Integration

ScaleBlogger's Custom API option lets you connect any CMS, blog platform, or content endpoint that accepts REST API requests. This guide covers the complete API contract your endpoint must implement, all content types ScaleBlogger sends, and how to render them.

Understanding the Two URL Fields

The Custom API form has two URL-related fields that serve different purposes:

FieldWhat to enterExample
Site URL Your actual website address — the public URL where your blog or content lives. https://myblog.com
Posts Endpoint The API URL that receives article data — this is the technical endpoint ScaleBlogger sends requests to. https://api.myblog.com/v1/posts or a webhook URL

Key point: Do not put your API URL in the Site URL field. The Site URL is your public website; the Posts Endpoint is where ScaleBlogger sends article data.

How they interact:

  • If you provide a Posts Endpoint, ScaleBlogger sends articles directly to that URL. The Site URL is stored for reference but is not used for publishing.
  • If you leave the Posts Endpoint blank, ScaleBlogger falls back to {site_url}/posts.

Impact on published article URLs: The Site URL does not determine the URL of published articles. The published URL comes entirely from what your API returns in its response (the url, link, or permalink field).

All Connection Fields

Required Fields

FieldDescription
Connection Name A friendly label for this connection (e.g., "My Blog API")
Site URL Your website's public URL (e.g., https://myblog.com). This is your actual website address, not the API endpoint.
API Token / Bearer Token Your API authentication token. Sent as a Bearer token in the Authorization header.

Optional Fields

FieldDefaultDescription
Posts Endpoint {site_url}/posts The full API URL where ScaleBlogger sends article data.
HTTP Method POST The HTTP method for creating new articles (POST or PUT).
Custom Headers (JSON) None Additional HTTP headers as a JSON object. Example: {"X-Custom-Header": "value"}

API Contract — Three Routes Your Endpoint Must Support

ScaleBlogger uses three HTTP methods against your endpoint. All three must be implemented for the full publishing workflow to function correctly.

1. GET — Connection Test & Slug Lookup

ScaleBlogger sends a GET request with a slug query parameter to test the connection and to check for existing articles before publishing.

GET {endpoint}?slug=your-article-slug
Authorization: Bearer {your_api_token}

Expected response:

  • 200 OK with a JSON array of matching posts, or an empty array [] if no match.
  • Each item in the array should include at least id and slug.
// No match (slug is available):
[]

// Match found (article already exists):
[{"id": "abc-123", "slug": "your-article-slug"}]

When this is called:

  • During the connection test (Step 1 in setup) — ScaleBlogger sends ?slug=__scaleblogger_connection_test__ to verify connectivity and authentication. Your endpoint should return [] for this test slug.
  • Before publishing, to check if an article with the same slug already exists (slug collision detection).

2. POST — Create Article

ScaleBlogger sends a POST request to create a new article.

POST {endpoint}
Authorization: Bearer {your_api_token}
Content-Type: application/json

{
  "title": "Your Article Title",
  "content": "<p>Full HTML content...</p>",
  "slug": "your-article-title",
  "status": "published",
  "excerpt": "Brief summary or meta description",
  "meta_description": "SEO meta description text",
  "featured_image_url": "https://example.com/image.jpg",
  "author": "Jane Smith",
  "seo_title": "SEO-Optimized Title (2026)",
  "og_title": "SEO-Optimized Title (2026)",
  "og_image": "https://example.com/social-card.jpg",
  "canonical_url": null,
  "schema_markup": {
    "@context": "https://schema.org",
    "@graph": [
      { "@type": "Article", "headline": "..." },
      { "@type": "BreadcrumbList", "itemListElement": [] },
      { "@type": "Organization", "name": "..." }
    ]
  },
  "tags": ["seo", "content marketing", "blogging"],
  "categories": ["Marketing"],
  "focus_keywords": ["seo", "content marketing"]
}
FieldTypeDescription
titlestringThe article title
contentstringFull article body as rich HTML (see Content Types below)
slugstringURL-friendly slug for the article
statusstringAlways "published"
excerptstringShort summary derived from the meta description (may be empty)
meta_descriptionstringSEO meta description
featured_image_urlstring | nullURL of the hero/featured image, or null if none
authorstring | nullArticle author name — use for bylines and E-E-A-T signals. Sourced from the draft's default author or the CMS connection's default author.
seo_titlestring | nullSEO-optimized title (may differ from title) — use for <title> tag
og_titlestring | nullOpen Graph title for social sharing — use for <meta property="og:title">
og_imagestring | nullOpen Graph image URL for social cards — use for <meta property="og:image">
canonical_urlstring | nullCanonical URL for syndicated content — use for <link rel="canonical">. Currently null (reserved for future use).
schema_markupobject | nullJSON-LD structured data (Schema.org). May be a single schema object or a @graph array containing Article, BreadcrumbList, FAQPage, HowTo, and Organization schemas. Inject as <script type="application/ld+json">.
tagsstring[]Combined target keywords and focus keywords (deduplicated)
categoriesstring[]Category names derived from the content cluster. Auto-create categories if they don't exist in your CMS.
focus_keywordsstring[]Primary SEO keywords the article is optimized for

Expected response (201 or 200):

{
  "id": "abc-123",
  "url": "https://myblog.com/blog/your-article-title"
}

ScaleBlogger checks for url, link, or permalink (in that order) to record the published URL. The id is stored for future updates.

3. PUT — Update Article

When ScaleBlogger updates a previously published article, it sends a PUT request to {endpoint}/{post_id}.

PUT {endpoint}/{post_id}
Authorization: Bearer {your_api_token}
Content-Type: application/json

{
  "content": "<p>Updated HTML content...</p>",
  "excerpt": "Updated excerpt",
  "featured_image_url": "https://example.com/new-image.jpg",
  "author": "Jane Smith",
  "seo_title": "Updated SEO Title (2026)",
  "og_title": "Updated SEO Title (2026)",
  "og_image": "https://example.com/new-social-card.jpg",
  "schema_markup": { "@context": "https://schema.org", "@type": "Article", "headline": "..." },
  "tags": ["seo", "content marketing"],
  "focus_keywords": ["seo", "content marketing"]
}

Note: title, slug, categories, and canonical_url are not included in update requests — only the content body, images, author, and SEO metadata are sent.

Expected response (200):

{
  "id": "abc-123",
  "url": "https://myblog.com/blog/your-article-title"
}

Important for Supabase Edge Functions: If your endpoint is a Supabase Edge Function, the post ID will appear in the URL path as /functions/v1/your-function-name/{post_id}. Your path parsing must extract the ID from the segment immediately after the function name — there is no /posts/ segment in the URL.

How to Use the SEO Fields

ScaleBlogger generates SEO, GEO, and AISEO-optimized content. Use these fields in your blog template to maximize search visibility:

HTML Head Tags

<!-- Page title (use seo_title if available, fall back to title) -->
<title>{{ seo_title || title }}</title>
<meta name="description" content="{{ meta_description }}">

<!-- Open Graph tags for social sharing -->
<meta property="og:title" content="{{ og_title || seo_title || title }}">
<meta property="og:description" content="{{ meta_description }}">
<meta property="og:image" content="{{ og_image || featured_image_url }}">

<!-- Canonical URL (only if provided) -->
{% if canonical_url %}
<link rel="canonical" href="{{ canonical_url }}">
{% endif %}

<!-- JSON-LD structured data -->
{% if schema_markup %}
<script type="application/ld+json">
{{ schema_markup | json }}
</script>
{% endif %}

Categories and Tags

The categories array contains category names (e.g., ["Web Development"]). Auto-create categories in your CMS if they don't already exist. The tags array contains deduplicated keywords from the article's target keywords and focus keywords — use these for tag clouds, related articles, or internal search.

Field Availability

All SEO fields are always present in the payload but may be null or empty arrays. Your API should accept and store them gracefully. For example:

  • seo_title and og_title may be null — fall back to title
  • og_image may be null — fall back to featured_image_url
  • canonical_url is currently always null (reserved for future syndication support)
  • schema_markup may be null if no structured data was generated
  • tags, categories, and focus_keywords may be empty arrays []
  • author may be null if no default author is configured — use your own fallback or leave blank

What ScaleBlogger Sends — Rich Content Types

The content field contains full HTML that may include a variety of rich content types beyond plain text. Your website's blog template should be prepared to render all of these.

Top-Level Fields vs. HTML Content

The featured_image_url is a separate top-level field (not embedded in the HTML). Your template should render it above or alongside the article. Everything else below is embedded inside the content HTML string.

Text Content

  • Headings: <h2> through <h6> tags for section structure
  • Paragraphs: <p> tags, may contain inline <strong>, <em>, <code>, and <a> links
  • Lists: <ul> and <ol> with <li> items

Images (Inline)

Inline images appear inside the article body as:

<figure>
  <img src="https://..." alt="description" />
  <figcaption>Optional caption text</figcaption>
</figure>

These are different from the featured_image_url field. A single article may contain multiple inline images throughout the text.

Video Embeds

YouTube and other video embeds appear as:

<div class="video-embed" data-video-id="dQw4w9WgXcQ"
     style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden">
  <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"
          style="position:absolute;top:0;left:0;width:100%;height:100%"
          frameborder="0" allowfullscreen></iframe>
</div>

Alternatively, if a custom embed HTML was provided, the raw embed HTML is used directly. The data-video-id attribute contains the YouTube video ID.

Quiz Embeds

Interactive quiz placeholders appear as:

<div class="quiz-embed" data-quiz-id="quiz-abc-123"></div>

Your frontend should use the data-quiz-id to load and render the quiz interactively (e.g., via JavaScript). If your site doesn't support quizzes, you can safely hide or ignore these elements.

Infographic Embeds

Data visualizations and infographics appear as:

<figure class="infographic">
  <img src="https://..." alt="Infographic description" />
</figure>

Downloadable Templates

Downloadable files (spreadsheets, checklists, PDFs, etc.) appear as:

<div class="template-download">
  <a href="https://..." target="_blank" rel="noopener">Download Template</a>
</div>

Tables

Data tables appear as standard HTML:

<table class="content-table">
  <thead>
    <tr><th>Header 1</th><th>Header 2</th></tr>
  </thead>
  <tbody>
    <tr><td>Cell 1</td><td>Cell 2</td></tr>
  </tbody>
</table>

Code Blocks

Code snippets with optional syntax highlighting:

<pre><code class="language-javascript">
const greeting = "Hello, world!";
</code></pre>

The class="language-{lang}" attribute indicates the programming language for syntax highlighting (e.g., via Prism.js or Highlight.js).

Blockquotes

Quotes with optional attribution:

<blockquote>
  <p>Quote text here.</p>
  <cite>Author Name</cite>
</blockquote>

Callouts & Quick Answers

Highlighted info boxes and quick-answer summaries:

<div class="callout callout-info">
  <p>Important information here.</p>
</div>

<div class="callout callout-info" data-section-type="quick-answer">
  <p><strong>Quick answer text</strong></p>
</div>

The callout-info, callout-warning, callout-tip classes indicate the callout style.

Recommended CSS for Rich Content

Add these styles to your blog template so embedded content renders correctly:

/* Video embeds - responsive 16:9 */
.video-embed {
  position: relative;
  padding-bottom: 56.25%;
  height: 0;
  overflow: hidden;
  margin: 1.5rem 0;
}
.video-embed iframe {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
}

/* Quiz placeholders */
.quiz-embed {
  border: 2px dashed #ccc;
  padding: 2rem;
  text-align: center;
  margin: 1.5rem 0;
  border-radius: 8px;
  background: #f9f9f9;
}

/* Infographics */
.infographic img {
  max-width: 100%;
  height: auto;
  margin: 1.5rem 0;
}

/* Downloadable templates */
.template-download {
  background: #f0f7ff;
  border: 1px solid #cce0ff;
  border-radius: 8px;
  padding: 1rem 1.5rem;
  margin: 1.5rem 0;
}
.template-download a {
  font-weight: 600;
  text-decoration: none;
}

/* Tables */
.content-table {
  width: 100%;
  border-collapse: collapse;
  margin: 1.5rem 0;
}
.content-table th, .content-table td {
  border: 1px solid #ddd;
  padding: 0.75rem;
  text-align: left;
}
.content-table thead th {
  background: #f5f5f5;
  font-weight: 600;
}

/* Callouts */
.callout {
  border-left: 4px solid #3b82f6;
  background: #eff6ff;
  padding: 1rem 1.5rem;
  margin: 1.5rem 0;
  border-radius: 0 8px 8px 0;
}
.callout-warning {
  border-left-color: #f59e0b;
  background: #fffbeb;
}
.callout-tip {
  border-left-color: #10b981;
  background: #ecfdf5;
}

/* Inline images */
figure {
  margin: 1.5rem 0;
}
figure img {
  max-width: 100%;
  height: auto;
  border-radius: 8px;
}
figcaption {
  font-size: 0.875rem;
  color: #666;
  margin-top: 0.5rem;
  text-align: center;
}

/* Code blocks */
pre {
  background: #1e1e1e;
  color: #d4d4d4;
  padding: 1rem;
  border-radius: 8px;
  overflow-x: auto;
  margin: 1.5rem 0;
}
code {
  font-family: "Fira Code", "Consolas", monospace;
  font-size: 0.9em;
}

How Authentication Works

ScaleBlogger sends two default headers with every request (GET, POST, and PUT):

  • Content-Type: application/json
  • Authorization: Bearer {your_api_token}

Custom headers you define are merged on top, so you can override these defaults or add additional headers.

Quick Setup Example

If your website is https://myblog.com and you have a webhook at https://hooks.example.com/api/posts:

  1. Connection Name: My Blog
  2. Site URL: https://myblog.com
  3. API Token: your-api-key-here
  4. Posts Endpoint: https://hooks.example.com/api/posts
  5. HTTP Method: POST

Testing Your Connection

After entering your credentials, click Test & Continue. ScaleBlogger will:

  1. Send GET {endpoint}?slug=__scaleblogger_connection_test__
  2. Expect a 200 OK response with an empty array []

Make sure your API:

  • Is accessible from the internet (not behind a firewall or VPN)
  • Accepts the Authorization: Bearer {token} header
  • Handles the GET ?slug=... route and returns a JSON array
  • Returns 200 for unknown slugs with an empty []

Troubleshooting

Connection test fails (Step 1)

  • Verify your Posts Endpoint URL is correct and publicly accessible
  • Check that your API token is valid
  • Ensure your endpoint handles GET requests with a slug query parameter and returns a JSON array

Publishing defaults test fails (Step 2)

  • This step tests the full create flow. Ensure your endpoint handles POST requests
  • Verify your API returns JSON with at least an id field
  • Check that your API accepts Content-Type: application/json

Articles fail to publish

  • Verify your API can handle all the fields in the POST request payload (including SEO fields like schema_markup, tags, categories)
  • The content field may be very large (full HTML articles). Ensure your endpoint doesn't have a strict request size limit
  • Ensure your API returns JSON with an id field so ScaleBlogger can track the article for updates
  • If your API has strict schema validation, make sure it accepts the new SEO fields or ignores unknown fields

Published URL not showing in ScaleBlogger

  • Make sure your API response includes a url, link, or permalink field with the full public URL
  • The published URL comes from your API response, not from the Site URL field

Updates not working

  • Ensure your API supports PUT requests to {endpoint}/{id}
  • The post ID is appended directly to your endpoint URL. If using Supabase Edge Functions, extract the ID from the path segment after the function name
  • Verify the post ID returned by your API is stable and can be used in URLs

Rich content not rendering

  • The content field is raw HTML — render it directly (with appropriate sanitization)
  • Add the recommended CSS classes above to style embedded content
  • For code highlighting, integrate a library like Prism.js or Highlight.js
  • For quiz embeds, implement a JavaScript handler that reads data-quiz-id and loads the quiz

SEO fields not appearing

  • All SEO and metadata fields (author, seo_title, og_title, og_image, canonical_url, schema_markup, tags, categories, focus_keywords) are always present in the payload
  • Fields may be null or empty arrays — handle gracefully with fallbacks
  • If your database schema predates the SEO update, add the new columns (see Recommended Database Schema below)

Recommended Database Schema

If you are building a webhook to receive ScaleBlogger articles, here is a recommended database table structure:

CREATE TABLE blog_posts (
  id                 UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  title              TEXT NOT NULL,
  slug               TEXT NOT NULL UNIQUE,
  content            TEXT,              -- Full HTML body
  excerpt            TEXT,              -- Short summary
  meta_description   TEXT,              -- SEO meta description
  featured_image_url TEXT,              -- Hero image URL
  seo_title          TEXT,              -- SEO-optimized title for <title> tag
  og_title           TEXT,              -- Open Graph title for social sharing
  og_image           TEXT,              -- Open Graph image URL for social cards
  canonical_url      TEXT,              -- Canonical URL (for syndicated content)
  schema_markup      JSONB,             -- JSON-LD structured data (Schema.org)
  tags               TEXT[] DEFAULT '{}'::TEXT[],  -- Keywords and tags
  categories         TEXT[] DEFAULT '{}'::TEXT[],  -- Category names
  focus_keywords     TEXT[] DEFAULT '{}'::TEXT[],  -- Primary SEO keywords
  author             TEXT,
  published_at       TIMESTAMPTZ DEFAULT now(),
  created_at         TIMESTAMPTZ DEFAULT now(),
  updated_at         TIMESTAMPTZ DEFAULT now()
);

The content column stores the full HTML including all embedded elements (images, videos, quizzes, tables, etc.). Your blog template reads and renders this HTML directly. The SEO columns enable your template to render proper <title>, Open Graph, and JSON-LD tags for search engine and social media optimization.

Complete Webhook Example (Supabase Edge Function)

Here is a full working example of a Supabase Edge Function that implements all three routes:

import { serve } from "https://deno.land/std/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const API_KEY = Deno.env.get("SCALEBLOGGER_API_KEY") || "";
const SITE_URL = (Deno.env.get("SITE_URL") || "https://yoursite.com").replace(/\/$/, "");

const json = (data: unknown, status = 200) =>
  new Response(JSON.stringify(data), {
    status,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "authorization, content-type",
      "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS",
    },
  });

serve(async (req) => {
  // Handle CORS preflight
  if (req.method === "OPTIONS") return json({ ok: true });

  // Authenticate
  const authHeader = req.headers.get("authorization") || "";
  const token = authHeader.replace("Bearer ", "");
  if (!token || token !== API_KEY) {
    return json({ error: "unauthorized" }, 401);
  }

  const url = new URL(req.url);

  // Extract post ID from path:
  // URL structure: /functions/v1/your-function-name/{id}
  const pathParts = url.pathname.split("/").filter(Boolean);
  const funcIndex = pathParts.indexOf("your-function-name");
  const postId = funcIndex >= 0 && funcIndex + 1 < pathParts.length
    ? pathParts[funcIndex + 1]
    : null;

  try {
    // GET ?slug=... - Connection test & slug lookup
    if (req.method === "GET") {
      const slug = url.searchParams.get("slug");
      if (!slug) return json({ error: "slug query param required" }, 400);

      const { data } = await supabase
        .from("blog_posts")
        .select("id, slug")
        .eq("slug", slug);

      return json(data || []);
    }

    // POST - Create article
    if (req.method === "POST") {
      const body = await req.json();
      const { data, error } = await supabase
        .from("blog_posts")
        .insert({
          title:              body.title,
          slug:               body.slug,
          content:            body.content,
          excerpt:            body.excerpt || "",
          meta_description:   body.meta_description || "",
          featured_image_url: body.featured_image_url || null,
          seo_title:          body.seo_title || null,
          og_title:           body.og_title || null,
          og_image:           body.og_image || null,
          canonical_url:      body.canonical_url || null,
          schema_markup:      body.schema_markup || null,
          tags:               body.tags || [],
          categories:         body.categories || [],
          focus_keywords:     body.focus_keywords || [],
          author:             body.author || null,
          published_at:       new Date().toISOString(),
        })
        .select("id, slug")
        .single();

      if (error) throw error;
      return json({ id: data.id, url: `${SITE_URL}/blog/${data.slug}` }, 201);
    }

    // PUT /{id} - Update article
    if (req.method === "PUT") {
      if (!postId) return json({ error: "post ID required in path" }, 400);

      const body = await req.json();
      const updates: Record<string, unknown> = {
        updated_at: new Date().toISOString(),
      };

      // Accept all fields ScaleBlogger may send
      for (const key of [
        "title", "content", "slug", "excerpt",
        "featured_image_url", "meta_description",
        "seo_title", "og_title", "og_image",
        "canonical_url", "schema_markup",
        "tags", "categories", "focus_keywords",
        "author", "published_at"
      ]) {
        if (body[key] !== undefined) updates[key] = body[key];
      }

      const { data, error } = await supabase
        .from("blog_posts")
        .update(updates)
        .eq("id", postId)
        .select("id, slug")
        .single();

      if (error) throw error;
      return json({ id: data.id, url: `${SITE_URL}/blog/${data.slug}` });
    }

    return json({ error: "method not allowed" }, 405);
  } catch (err) {
    return json({ error: err.message }, 500);
  }
});

Remember: Replace your-function-name with your actual Edge Function name when extracting the post ID from the URL path. Set the SITE_URL environment variable in your Supabase project to your public website URL (e.g., https://myblog.com) so the webhook returns absolute URLs.

Content Types Summary

Here is a quick reference of every HTML structure ScaleBlogger may embed in the content field:

Content TypeHTML Element / ClassKey Attributes
Headings<h2><h6>Optional id for anchor links
Paragraphs<p>May contain <strong>, <em>, <a>, <code>
Lists<ul>, <ol>
Inline Images<figure> <img>src, alt, optional <figcaption>
Video Embeds<div class="video-embed">data-video-id, contains <iframe>
Quiz Embeds<div class="quiz-embed">data-quiz-id
Infographics<figure class="infographic">Contains <img> with src, alt
Templates<div class="template-download">Contains download <a> link
Tables<table class="content-table">Standard <thead>/<tbody> structure
Code Blocks<pre><code>Optional class="language-{lang}"
Blockquotes<blockquote>Optional <cite> for attribution
Callouts<div class="callout">callout-info, callout-warning, callout-tip
Quick Answers<div class="callout" data-section-type="quick-answer">Bold summary text