INTEGRATIONS · ANY WEBSITE

Publish to any website

Kleos publishes articles to WordPress, Webflow, Shopify, Ghost and EmDash on its own. For every other website (Squarespace, Wix, HubSpot, a Hugo, Jekyll or Next.js site, a CMS of your own) the article.approved webhook hands your site each approved article, text and all, and PATCH /v1/articles/{id} tells Kleos where the page went live. This guide is for a business owner and the developer who looks after their site.

When to use it

For the websites Kleos doesn't connect to

Your site isn't one Kleos connects to

If it runs on WordPress (Jetpack or self-hosted), Webflow, Shopify, Ghost or EmDash, connect it under Integrations instead and Kleos publishes approved articles for you. Squarespace has no posting API: Kleos makes an import file for it on the article's page and under Integrations.

You have a developer, or a Zapier or Make account

The webhook route takes an endpoint that can check a signature and call your CMS, or a Zapier or Make webhook trigger with no code at all. Recording the live link is one HTTP request.

You want your other tools told when an article goes live

A team chat message, a rebuild of a static site, a row in a spreadsheet, a LinkedIn post from another tool: article.published carries the whole article and its live address.

How it fits together

The webhook brings the article; one API call says where it went live

Three parts, in order. article.approved hands your site the whole article the moment it is approved. Your site creates the page. PATCH /v1/articles/{id} with the page's address tells Kleos it is published, and Kleos takes it from there: article.published to your other tools, Google index checks, visits and leads on the article's page.

1. Kleos writes, you approve

Articles aim at a real search phrase and wait for a person's approval under Articles (or are approved for you when auto-approve is on). The moment one is approved, every endpoint subscribed to article.approved gets a signed POST with the whole article: title, slug, Markdown and HTML, an excerpt, the hero image, the search title and description, the FAQ, the language and your site's address.

2. Your site creates the page

Your endpoint (or a Zap) checks the signature and creates the page in your CMS or writes a file into your site's repository from the fields in the event. An article edited and approved again arrives again, with the new text and the same id, so update the page you made rather than making a second one.

3. One call records the live link

Once the page is up, PATCH /v1/articles/{id} with {"external_url": "https://…"}. Kleos opens the link to check it exists, marks the article published with that address and keeps it with the article. The same thing "Published it? Paste the link → Record as published" does in the app, for the times a person does it by hand.

4. Kleos sends article.published and tracks the result

Every endpoint subscribed to article.published gets the article again, now with its live address, for the team, a rebuild, search engines or a post elsewhere. From then on Kleos checks whether Google has indexed the page at 3, 7 and 14 days, shows the result on the article's page, and counts the article's visits, leads and rankings like any other.

Set-up

Four steps in Kleos

You need a Kleos workspace on a plan with the API, and to be its owner or an admin.

  1. Make an API key with write access

    In Kleos, go to Settings → API. Under API keys, make a key with “Read and write” and copy it: it is shown once. Recording the live link is a write; listing articles only needs a read key. Only workspace owners and admins can make keys.

  2. Add a webhook endpoint

    Go to Settings → API → Webhooks. Enter your endpoint's address (https only, on the standard port), an optional description, and tick the events to send: article.approved, and article.published if other tools should hear when it is live. Press Add endpoint.

  3. Copy the signing secret

    The secret (whsec_…) is shown once, right after the endpoint is added. Store it where your receiver can read it, as an environment variable, and never in the page's code. Rotate secret makes a new one and keeps the old one signing for a day.

  4. Send a test event

    Press Send test event on the endpoint. Kleos sends one webhook.test event now and shows the response code and how long your endpoint took. The recent deliveries list under the endpoint shows every payload and attempt for the last month.

The event

What article.approved sends

An HTTPS POST with a JSON body. Every event has the same envelope; data is the whole article. This sample is built by the same code that builds the real one. article.published is the same shape, with status published and external_url set.

{
  "id": "3e7a9c1d-5f2b-4e8a-b6c4-9d0e1f2a3b4c",
  "type": "article.approved",
  "version": "2026-10-29",
  "created_at": "2026-11-02T16:40:11.000Z",
  "workspace_id": "5b7c3c1e-3f2a-4a69-9d3c-7e6b8f2a1c00",
  "business_id": "0c6f2d4e-8a1b-4c3d-9e5f-7a8b9c0d1e2f",
  "data": {
    "object": "article",
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "product_id": "0c6f2d4e-8a1b-4c3d-9e5f-7a8b9c0d1e2f",
    "title": "How to close the books in three days, not ten",
    "slug": "close-the-books-in-three-days",
    "status": "approved",
    "primary_keyword": "month-end close checklist",
    "meta_title": "Month-end close checklist: three days, not ten",
    "meta_description": "A month-end close checklist small finance teams use to cut the close from ten days to three, with the order to do it in.",
    "external_url": null,
    "published_at": null,
    "created_at": "2026-10-28T09:14:00.000Z",
    "updated_at": "2026-11-02T16:40:10.000Z",
    "body_md": "# How to close the books in three days, not ten\n\nMost small finance teams lose a week to the close because the work arrives in the wrong order. This checklist puts it in the right one.\n\n## Day one: lock what you can\n\n- Close the sub-ledgers that no longer change.\n- Chase the two or three invoices that always come late.\n\n## Day two: reconcile\n\nBank, credit cards and payroll, in that order.",
    "faq": [
      {
        "question": "Does this work with accrual accounting?",
        "answer": "Yes. The order of the steps is the same; the accruals are posted on day two."
      }
    ],
    "body_html": "<p>Most small finance teams lose a week to the close because the work arrives in the wrong order. This checklist puts it in the right one.</p>\n<h2>Day one: lock what you can</h2>\n<ul>\n<li>Close the sub-ledgers that no longer change.</li>\n<li>Chase the two or three invoices that always come late.</li>\n</ul>\n<h2>Day two: reconcile</h2>\n<p>Bank, credit cards and payroll, in that order.</p>\n<h2>Frequently asked questions</h2>\n<h3>Does this work with accrual accounting?</h3>\n<p>Yes. The order of the steps is the same; the accruals are posted on day two.</p>\n",
    "excerpt": "A month-end close checklist small finance teams use to cut the close from ten days to three, with the order to do it in.",
    "hero_image": {
      "url": "https://files.getkleos.ai/images/a1b2c3d4/hero.png?X-Amz-Signature=…",
      "alt": "A finance team's month-end close checklist on a desk"
    },
    "canonical_url": null,
    "language": "en-US",
    "site_url": "https://www.example.com"
  }
}
id
The event's id, also sent as the webhook-id header. The same on every retry, so you can skip one you have handled.
type
article.approved, article.published, post.published, newsletter.approved, newsletter.sent and so on.
version
The payload format, 2026-10-29 today. New fields and event types arrive without a new version.
created_at
When the event happened. Events can arrive out of order; use this to sort them.
workspace_id
Your Kleos workspace.
business_id
Which business the article belongs to (a workspace can hold several), or null for workspace-wide events.
data
The article with the fields above (ArticleContent in the OpenAPI document). Nothing else to fetch.

The events a website cares about

  • article.approved: An article was approved and is ready to publish, with its full text, so your website can create the page. Sent again when it is approved again after edits. (data is an ArticleContent)
  • article.published: An article was published to the website, with its full text and live address. (data is an ArticleContent)
  • post.published: A post went out on its channel. (data is a Post)
  • newsletter.approved: A newsletter issue was approved and is ready to send, with its sections. (data is a NewsletterDetail)
  • newsletter.sent: A newsletter issue was sent, or scheduled in your email platform. (data is a Newsletter)

Every event type, with its schema, is in the OpenAPI document under webhooks, and listed in Kleos under Settings → API → Webhooks.

The article

The fields in data

Everything a page needs, so your receiver has nothing else to fetch: the text twice (Markdown and HTML), the search title and description, an excerpt, the hero image, the FAQ, the language and your site's address.

id
The article's id in Kleos. Keep it with the page: PATCH /v1/articles/{id} uses it, and a re-approval arrives with the same id.
product_id
The business it belongs to (business_id in the webhook envelope).
title
The headline.
slug
A URL-safe name for the page, or null. Use it as the page's address when your CMS allows.
status
approved in article.approved; published in article.published. The API lists brief, draft, pending_approval, approved, published or failed.
primary_keyword
The search phrase the article aims at, or null.
meta_title
A search title for the page's <title> and og:title, or null.
meta_description
A search description, or null.
body_md
The article's text in Markdown, as GET /v1/articles/{id} returns it: headings, lists and links.
body_html
The same text as HTML for a page whose title your CMS sets: the body without its title heading, with the FAQ as a closing section. The same renderer as the app's HTML download.
excerpt
A short summary: the search description, else the opening paragraph.
hero_image
{url, alt} for the article's hero image, or null when it has none. The link is signed and good for 7 days: download the image into your site rather than hot-linking it.
faq
Question-and-answer pairs Kleos wrote for the article, as {question, answer} objects. Often empty. Already rendered into body_html.
canonical_url
The address Kleos holds as the article's home: null until the live link is recorded, then the same as external_url.
language
BCP 47, for example en-US or es, for the page's lang attribute.
site_url
The business's website as set in Kleos, for building the page's address.
external_url
The live address once recorded, or null.
published_at
When it was recorded as published, or null.
created_at
updated_at: when it was made and last changed.

GET /v1/articles/{id} returns the same article without body_html, excerpt, hero_image, canonical_url, language and site_url (those are built for the event); GET /v1/articles?status=approved lists what is ready without the text. Both take a read key.

Verifying

Check the signature before trusting a request

Kleos signs every request the Standard Webhooks way, so the standardwebhooks libraries for Node, Python, Go, Ruby, PHP and others verify it as they are. Each request carries three headers: webhook-id (the event's id), webhook-timestamp (Unix seconds; refuse anything more than 5 minutes off) and webhook-signature (v1, and the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_; two of them, space-separated, for a day after a rotation). Sign over the raw body exactly as it arrived: parsing and re-serializing the JSON changes the bytes.

These two samples are the ones Kleos's own test suite runs against real signed requests, so they can't drift from what Kleos sends.

Node 18+

import { createHmac, timingSafeEqual } from "node:crypto";

// secret: the endpoint's whsec_… secret. headers: the request's headers (lower-case names).
// body: the raw request body as a string, exactly as received (not parsed and re-serialized).
export function verifyKleos(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  // Refuse anything more than 5 minutes old (or ahead), so a captured request can't be replayed.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
  // During a secret rotation two signatures are sent; either one matching is enough.
  return signatures.split(" ").some((part) => {
    const [version, signature] = part.split(",");
    const got = Buffer.from(signature ?? "", "base64");
    return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
  });
}

Python 3.9+

import base64, hashlib, hmac, time

def verify_kleos(secret: str, headers: dict, body: str) -> bool:
    """secret: the endpoint's whsec_… secret. body: the raw request body, exactly as received."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False
    # Refuse anything more than 5 minutes old (or ahead), so a captured request can't be replayed.
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(hmac.new(key, f"{msg_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()).decode()
    # During a secret rotation two signatures are sent; either one matching is enough.
    return any(
        hmac.compare_digest(part.split(",", 1)[1], expected)
        for part in signatures.split(" ")
        if part.startswith("v1,")
    )

Closing the loop

Tell Kleos where the article lives

Kleos needs the page's address to count the article as published and to keep the live link with it (on the article's page, in the API's external_url and in the article.published event). PATCH /v1/articles/{id} with {"external_url": "https://…"} and a key with write access does it: approved articles only, https only. Kleos opens the link first: a page that doesn't exist (404 or 410) is refused with a 422, so make the call after the page is live, not before a deploy; a site that blocks automated visitors is accepted. The response is the article, now published. The same for a social post that went out through another tool: PATCH /v1/posts/{id} with external_url.

curl -X PATCH https://app.getkleos.ai/v1/articles/ARTICLE_ID \
  -H "Authorization: Bearer YOUR_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"external_url": "https://www.example.com/blog/close-the-books-in-three-days"}'

The same thing by hand: open the article under Articles, paste the full https link under “Published it? Paste the link” and press Record as published. Only someone who can publish can do it, and only for approved articles. Either way, Kleos sends article.published and starts the Google index checks.

Prefer polling to webhooks? GET /v1/articles?status=approved (a daily schedule is plenty) lists what is ready and GET /v1/articles/{id} returns the text as body_md; then record each live link with the same PATCH. The two Markdown-file scripts below work that way.

Delivery

Timeouts, retries and what turns an endpoint off

Answer 2xx within 10 seconds

Any 2xx counts as delivered. Do slow work (creating the page, the PATCH back) after answering. Kleos only sends to https addresses on port 443, doesn't follow redirects, and refuses private and internal addresses.

Retries for about 24 hours

Anything else, or no answer, is tried again after 1 minute, 5 minutes, 30 minutes, then 1, 2, 4, 8 and 8 hours: 9 attempts in all, each wait spread by up to 10% so a recovering site isn't hit by every retry at once.

Turned off after a day of failures

If an event uses up its retries and nothing has reached the endpoint since that event was made, Kleos turns the endpoint off, cancels what was still queued for it and emails the workspace's owners and admins. Turn it back on under Settings → API → Webhooks, then press Redeliver on anything it missed.

Redeliver and test events

Redeliver sends the same event (same webhook-id and body) once more, now. Send test event sends one webhook.test event. Both are single attempts and never count toward turning an endpoint off.

Secrets and rotation

Each endpoint has its own whsec_ secret, shown once. After Rotate secret, both the old and the new secret sign every request for 24 hours, so you can switch your receiver over without missing anything.

Log, limits and privacy

Every delivery and attempt, with its payload, response code and time taken, is kept for 30 days. A workspace can have up to 10 endpoints. Payloads never carry names, email addresses, phone numbers or credentials.

Worked examples

Four ways to wire it up

Next.js or any Node site with a database or CMS

The endpoint you add in Kleos. It checks the signature with the Node sample above, answers at once, then creates (or updates) the post from article.approved and records the page's address with the PATCH. On article.published it asks your host to rebuild, or does whatever should happen once an article is live.

// app/api/kleos/route.ts: the endpoint you add under Settings → API → Webhooks, as https://yoursite.com/api/kleos
import { verifyKleos } from "@/lib/verify-kleos"; // the Node verification sample above, saved as lib/verify-kleos.js
import { upsertPost } from "@/lib/posts"; // your database or CMS: returns the page's public address
import { after } from "next/server";

export async function POST(req: Request) {
  const body = await req.text(); // the raw body, exactly as sent: don't parse and re-serialize before checking
  const headers: Record<string, string> = {};
  req.headers.forEach((value, name) => (headers[name] = value)); // lower-case names
  if (!verifyKleos(process.env.KLEOS_WEBHOOK_SECRET!, headers, body)) return new Response(null, { status: 401 });
  const event = JSON.parse(body);
  if (event.type === "article.approved") after(() => publish(event.data)); // answer first; the work runs after the response
  if (event.type === "article.published") after(() => fetch(process.env.DEPLOY_HOOK_URL!, { method: "POST" })); // or tell the team
  return new Response(null, { status: 204 }); // within 10 seconds
}

async function publish(article: any) {
  // Keyed on article.id: an article approved again after edits arrives again, and updates the same page.
  const url = await upsertPost({
    kleosId: article.id, slug: article.slug ?? article.id, title: article.title, html: article.body_html, excerpt: article.excerpt,
    seoTitle: article.meta_title ?? article.title, seoDescription: article.meta_description, heroImageUrl: article.hero_image?.url ?? null, heroImageAlt: article.hero_image?.alt ?? null, lang: article.language,
  });
  // Tell Kleos where it lives: it marks the article published, sends article.published and checks Google's index at 3, 7 and 14 days.
  const res = await fetch(`https://app.getkleos.ai/v1/articles/${article.id}`, {
    method: "PATCH",
    headers: { authorization: `Bearer ${process.env.KLEOS_API_KEY}`, "content-type": "application/json" }, // a key with write access
    body: JSON.stringify({ external_url: url }),
  });
  if (!res.ok) console.error("Kleos didn't record the link", res.status, await res.text());
}

The same receiver as a plain Express app:

import express from "express";
import { verifyKleos } from "./verify-kleos.js";

const app = express();
app.post("/webhooks/kleos", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  if (!verifyKleos(process.env.KLEOS_WEBHOOK_SECRET, req.headers, body)) return res.sendStatus(401);
  const event = JSON.parse(body);
  // Answer fast, then do the work. Use event.id to skip an event you have already handled.
  res.sendStatus(204);
  if (event.type === "article.approved") publish(event.data).catch(console.error);
});

// The article is the whole thing: title, slug, body_html (or body_md), excerpt, meta_title, meta_description, hero_image,
// faq, language. Create the page in your CMS, then tell Kleos where it lives: Kleos marks the article published, sends
// article.published to your other endpoints, and checks whether Google indexes the page at 3, 7 and 14 days.
async function publish(article) {
  const url = await createPage(article); // your CMS or database; the page must be live before the next line
  const res = await fetch(`https://app.getkleos.ai/v1/articles/${article.id}`, {
    method: "PATCH",
    headers: { authorization: `Bearer ${process.env.KLEOS_API_KEY}`, "content-type": "application/json" }, // a key with write access
    body: JSON.stringify({ external_url: url }),
  });
  if (!res.ok) throw new Error(`Kleos didn't record the link: ${res.status} ${await res.text()}`);
}

Zapier or Make: WordPress.com, HubSpot, Wix and others

No code: a Zapier or Make webhook trigger receives article.approved, your CMS's own Zapier or Make action creates the post from the event's fields, and one HTTP step records the live link in Kleos. Neither Zapier nor Make checks the signature, so keep the webhook address private.

Zapier

  1. Receiving the article: make a Zap with Webhooks by Zapier → Catch Hook as the trigger, copy its URL, and under Settings → API → Webhooks add it as an endpoint subscribed to article.approved. Press Send test event, then Test trigger in Zapier; the fields appear as data__title, data__slug, data__body_html, data__excerpt, data__meta_title, data__meta_description, data__hero_image__url and so on. Add a Filter on type if the endpoint gets more than one event.
  2. Creating the page: add your CMS's action for a new post (WordPress.com and HubSpot have Zapier apps; check that yours offers a create-post or create-blog-post action). Map title, slug, meta_title, meta_description and body_html (or body_md with a Formatter by Zapier → Text → Convert Markdown to HTML step first). Keep the post's public address from the action's output.
  3. Recording the live link: add Webhooks by Zapier → Custom Request: method PATCH, URL https://app.getkleos.ai/v1/articles/{{data__id}}, headers Authorization: Bearer YOUR_WRITE_KEY and Content-Type: application/json, data {"external_url": "the post's address"}. Kleos marks the article published and sends article.published, which a second Catch Hook can receive for your other tools.

Make

  1. Receiving the article: Webhooks → Custom webhook, Add, copy the address, and under Settings → API → Webhooks add it subscribed to article.approved. Press Redetermine data structure in Make, then Send test event in Kleos, so Make learns the fields under data. A Router on type splits events if one endpoint gets several.
  2. Creating the page: your CMS's Make module for a new post, or HTTP → Make a request against its own API. Map title, slug, meta_title, meta_description and body_html (Make's Markdown module turns body_md into HTML if you prefer the Markdown). Keep the post's public address from the module's output.
  3. Recording the live link: HTTP → Make a request, method PATCH, URL https://app.getkleos.ai/v1/articles/{{data.id}}, header Authorization: Bearer YOUR_WRITE_KEY, body type JSON, content {"external_url": "the post's address"}. Kleos marks the article published and sends article.published.

Squarespace: it has no posting API, so neither route can create the page. Download the import file from the article's page (or all approved articles under Integrations → Squarespace) and import it under Settings → Import & export → Import → WordPress → Advanced. Then record the live link, in the app or with the PATCH, and article.published still fires for your other tools.

A static site (Next.js, Hugo, Jekyll): Markdown files in a Git repository, by polling

When the page only exists after a build, pull approved articles into content/blog as Markdown with front matter before the build, commit the files (or let your CI do it), and after the deploy record each page's address. The receiver above still works for a static site if your host can rebuild on demand; these scripts are for sites that would rather poll.

// pull-kleos-articles.mjs: approved articles → content/blog/<slug>.md. Node 18+, no packages.
// The polling alternative to article.approved: run it on a schedule (cron, GitHub Actions) or as the first step of your
// site's build. Needs a read API key. After the deploy, record each page's address (see record-kleos-links below).
import { access, mkdir, writeFile } from "node:fs/promises";

const API = "https://app.getkleos.ai/v1";
const KEY = process.env.KLEOS_API_KEY;         // a read key from Settings → API
const BUSINESS = process.env.KLEOS_BUSINESS_ID; // the business's id (GET /v1/products lists them)
const DIR = "content/blog";

async function get(path) {
  const res = await fetch(`${API}${path}`, { headers: { authorization: `Bearer ${KEY}` } });
  if (!res.ok) throw new Error(`${path}: ${res.status}`);
  return res.json();
}

await mkdir(DIR, { recursive: true });
let cursor = null;
do {
  const page = await get(`/articles?status=approved&product_id=${BUSINESS}&limit=50${cursor ? `&cursor=${cursor}` : ""}`);
  for (const a of page.data) {
    const file = `${DIR}/${a.slug ?? a.id}.md`;
    if (await access(file).then(() => true, () => false)) continue; // already pulled
    const full = await get(`/articles/${a.id}`); // the list has no text; this has body_md
    const front = [
      "---",
      `title: ${JSON.stringify(full.title)}`,
      `description: ${JSON.stringify(full.meta_description ?? "")}`,
      `seo_title: ${JSON.stringify(full.meta_title ?? full.title)}`,
      `kleos_id: ${full.id}`,
      `date: ${new Date().toISOString().slice(0, 10)}`,
      "---",
    ].join("\n");
    await writeFile(file, `${front}\n\n${full.body_md ?? ""}\n`);
    console.log(`wrote ${file}`);
  }
  cursor = page.next_cursor;
} while (cursor);
// record-kleos-links.mjs: run after the site is deployed. For each content/blog/<slug>.md with a kleos_id and no
// kleos_recorded line, PATCH the live address to Kleos (a key with write access), then mark the file so it isn't sent twice.
import { readdir, readFile, writeFile } from "node:fs/promises";

const KEY = process.env.KLEOS_API_KEY;     // a write key from Settings → API
const SITE = process.env.SITE_URL;         // https://www.example.com
const DIR = "content/blog";

for (const name of await readdir(DIR)) {
  const path = `${DIR}/${name}`;
  const text = await readFile(path, "utf8");
  const id = text.match(/^kleos_id: (\S+)/m)?.[1];
  if (!id || /^kleos_recorded: /m.test(text)) continue;
  const url = `${SITE}/blog/${name.replace(/\.md$/, "")}/`; // your site's address for the page
  const res = await fetch(`https://app.getkleos.ai/v1/articles/${id}`, {
    method: "PATCH", headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" }, body: JSON.stringify({ external_url: url }),
  });
  if (!res.ok) { console.error(`${url}: Kleos said ${res.status}`, await res.text()); continue; } // 422: the page isn't live yet, or the article isn't approved
  await writeFile(path, text.replace(/^---\n/, `---\nkleos_recorded: ${new Date().toISOString()}\n`));
  console.log(`recorded ${url}`);
}

Python: a Flask receiver, or a Hugo or Jekyll pull

The receiver, using the Python verification sample above: it creates the page from article.approved and records the live link.

import json, os, urllib.request
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhooks/kleos")
def kleos():
    body = request.get_data(as_text=True)
    headers = {k.lower(): v for k, v in request.headers.items()}
    if not verify_kleos(os.environ["KLEOS_WEBHOOK_SECRET"], headers, body):
        return "", 401
    event = json.loads(body)
    # Use event["id"] to skip an event you have already handled; do slow work after answering.
    if event["type"] == "article.approved":
        publish(event["data"])
    return "", 204

def publish(article):
    """Create the page from the article (title, body_html or body_md, excerpt, meta_title, meta_description, hero_image,
    faq), then tell Kleos where it lives: it becomes published, article.published goes out, Google's index is checked."""
    url = create_page(article)  # your CMS; the page must be live before the request below
    req = urllib.request.Request(
        f"https://app.getkleos.ai/v1/articles/{article['id']}",
        method="PATCH",
        data=json.dumps({"external_url": url}).encode(),
        headers={"Authorization": f"Bearer {os.environ['KLEOS_API_KEY']}", "Content-Type": "application/json"},  # a key with write access
    )
    with urllib.request.urlopen(req, timeout=30) as res:
        return json.load(res)

And the polling shape with the standard library only: approved articles become files under content/posts (Hugo) or _posts (Jekyll), the site builds them, and the curl line above (or the Node record script) records the links after the deploy.

# pull_kleos_articles.py: approved articles → content/posts/<slug>.md. Python 3.9+, standard library only.
# The polling alternative to article.approved: run it on a schedule or before the site builds. Needs a read API key.
# After the deploy, record each page's address with PATCH /v1/articles/{id} (the curl line above, or the Node script).
import json, os, urllib.request
from datetime import date

API = "https://app.getkleos.ai/v1"
KEY = os.environ["KLEOS_API_KEY"]           # a read key from Settings → API
BUSINESS = os.environ["KLEOS_BUSINESS_ID"]  # the business's id (GET /v1/products lists them)
DIR = "content/posts"                        # Hugo. Jekyll: "_posts", and put the date first in the file name.

def get(path):
    req = urllib.request.Request(f"{API}{path}", headers={"Authorization": f"Bearer {KEY}"})
    with urllib.request.urlopen(req, timeout=30) as res:
        return json.load(res)

os.makedirs(DIR, exist_ok=True)
cursor = None
while True:
    page = get(f"/articles?status=approved&product_id={BUSINESS}&limit=50" + (f"&cursor={cursor}" if cursor else ""))
    for a in page["data"]:
        path = os.path.join(DIR, f"{a['slug'] or a['id']}.md")
        if os.path.exists(path):
            continue  # already pulled
        full = get(f"/articles/{a['id']}")  # the list has no text; this has body_md
        front = "\n".join([
            "---",
            f"title: {json.dumps(full['title'])}",
            f"description: {json.dumps(full.get('meta_description') or '')}",
            f"kleos_id: {full['id']}",
            f"date: {date.today().isoformat()}",
            "draft: false",
            "---",
        ])
        with open(path, "w", encoding="utf-8") as f:
            f.write(front + "\n\n" + (full.get("body_md") or "") + "\n")
        print("wrote", path)
    cursor = page.get("next_cursor")
    if not cursor:
        break

Plans and permissions

Who gets webhooks and the API

Webhooks come with the API, on the Growth and Pro plans. Starter and Lifetime license workspaces see the documentation but can't add endpoints or make keys. Only workspace owners and admins can make API keys, add endpoints, rotate secrets, send test events or redeliver; anyone in the workspace can read the docs. Each key is limited to 60 requests a minute. See pricing.

Questions

Does the webhook carry the article's text?

Yes. article.approved and article.published both carry the whole article: the text as Markdown and as HTML, an excerpt, the hero image, the search title and description, the FAQ, the language and your site's address. There is nothing else to fetch; GET /v1/articles/{id} is there if you would rather poll.

Is there an event when an article is approved, before it's published?

Yes: article.approved, the moment a person (or auto-approve) approves it. An article edited and approved again sends it again with the same id, so update the page rather than creating another. article.published follows once the live link is recorded.

How does Kleos know where the page went live?

Your site tells it: PATCH /v1/articles/{id} with the page's address, right after the page is up. Kleos checks the link opens, marks the article published, sends article.published and starts checking Google's index. A person can do the same on the article's page in the app.

Can Kleos POST the article to my site and read the address from my response?

No. Webhooks are one-way: Kleos sends events and only looks at the response code, so the live address comes back through the PATCH. That keeps your endpoint simple (answer 2xx, then do the work) and lets the page be created minutes later, by a build, if that is how your site works.

What if the page only exists after a deploy?

Record the link after the deploy. Kleos opens the link when you PATCH and refuses a page that isn't there (404 or 410), so a static site records its links in a post-deploy step, with curl or a few lines of script, rather than from the webhook receiver.

Does article.published fire if my website isn't connected?

Yes. It fires when the live link is recorded, through the API or on the article's page, exactly as it does when Kleos publishes to a connected site. The same for newsletter.sent when a newsletter is marked sent.

Can I use one endpoint for several businesses?

Yes. Endpoints belong to the workspace; business_id in every event says which business the article belongs to, and product_id on the API filters the list.

What about social posts and newsletters?

post.published carries the whole post (hook, body, hashtags, call to action, the link it points to and the post's address on its channel); PATCH /v1/posts/{id} with external_url records a post you sent through another tool. newsletter.approved carries the issue with its sections, newsletter.sent the issue once it went out. The same signature, retries and log.

Do I have to verify the signature?

You should. Anyone who learns your address could send you a made-up article. The check is a few lines (above), and Standard Webhooks libraries do it for you in Node, Python, Go, Ruby, PHP and others. Zapier and Make can't verify, so keep their addresses private.

What if my receiver was down?

Kleos retries for about a day, then turns the endpoint off and emails owners and admins. Turn it back on and press Redeliver on the events it missed; the log keeps them for a month.

Support

Email [email protected] with any question about the API or webhooks. Kleos is made by New Odyssey. Zapier, Make, WordPress, HubSpot, Wix and Squarespace are their owners' trademarks; Kleos isn't affiliated with or endorsed by them.

Also: every Kleos integration · security · privacy