Skip to content

Guide

Connect any website by webhook

Not on WordPress? No problem. YoDon POSTs every finished article as signed JSON to an endpoint you control — any stack, any CMS, about twenty lines of code on your side.

What the webhook connector is

When an article finishes generating — or its scheduled moment arrives — YoDon sends it to your endpoint as one JSON POST: title, full HTML, excerpt, SEO metadata, category and tag names, language, sources, and a public URL for the featured image. Your endpoint stores it however your site stores content, and everything else in YoDon works exactly as it does for WordPress sites: automations, scheduling, drafts, the dashboard, Google indexing.

Every request is signed with a secret only you and YoDon know, so your endpoint can prove the article really came from us and was not tampered with.

What your endpoint must do

  • Accept an HTTPS POST with a JSON body at a public URL.
  • Answer within 15 seconds with a 2xx status. Do slow work (image downloads, cache rebuilds) after answering, not before.
  • Answer the ping event with a 2xx — that's the test we run before saving the connection.
  • Verify the X-Yodon-Signature header (code below). Not enforced by us — but without it anyone who finds the URL could feed your site fake articles.
  • Treat article.id as the idempotency key: if a post with that id already exists, update it instead of creating a duplicate.

Connecting it

  1. Deploy your receiver endpoint (the example at the bottom is a complete one).
  2. In YoDon, go to Sites → Connect a site → Webhook.
  3. Enter a name and the endpoint URL, then hit Test & connect. We send a signed ping; a 2xx saves the connection.
  4. Copy the whsec_… signing secret from the final screen into your server's environment. It is shown exactly once.

Lost the secret? Reconnect the same URL — a new secret is generated and the old one stops mattering.

The payload, field by field

Three event types arrive, distinguished by the event field and the X-Yodon-Event header: ping (connection test, no article), article.published (first delivery of an article) and article.updated (the same article again — after a retry you triggered, or a draft being published). The full shape:

{
  "event": "article.published",
  "delivery_id": "whd_5f0c9c1e-…",
  "site_id": "d2a41c3e-…",
  "sent_at": "2026-08-28T15:04:05.000Z",
  "article": {
    "id": "a81f6a02-…",
    "title": "How to Choose a Standing Desk",
    "slug": "how-to-choose-a-standing-desk",
    "status": "publish",
    "html": "<h2>…</h2><p>…</p>",
    "excerpt": "A practical buyer's guide…",
    "seo": {
      "title": "Standing Desk Buyer's Guide (2026)",
      "description": "Everything to check before…",
      "keyword": "standing desk"
    },
    "category": "Office Setup",
    "categories": ["Office Setup", "Buying Guides"],
    "tags": ["desks", "ergonomics"],
    "language": "en",
    "featured_image": {
      "url": "https://www.yodon.com/api/images/…?sig=…",
      "mime": "image/webp"
    },
    "sources": [{ "title": "OSHA guidance", "url": "https://…" }],
    "published_at": "2026-08-28T15:04:05.000Z"
  }
}
  • article.status is the state we ask for: "publish" or "draft". Scheduled articles arrive at their scheduled time with "publish".
  • article.html is the complete article body. Image references inside it already point at public URLs.
  • category and categories are names, not ids — map them to your own taxonomy, or ignore them.
  • featured_image is null when the article has no image. The URL is signed and stable — fetch it any time.
  • delivery_id identifies this delivery attempt-group; article.id identifies the article. Dedupe on article.id.

Verifying the signature

Each request carries X-Yodon-Signature: t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256 of the string "<t>.<raw body>" keyed with your whsec_ secret. Verify against the raw request body — before any JSON parsing — reject timestamps older than 5 minutes, and compare with a timing-safe function.

Node.js

import crypto from "crypto";

export function verifyYodonSignature(secret, header, rawBody) {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
  if (!match) return false;
  const [, t, sig] = match;
  // Reject anything older than 5 minutes — replay protection.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

PHP

function verify_yodon_signature(string $secret, string $header, string $rawBody): bool {
    if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false;
    [, $t, $sig] = $m;
    if (abs(time() - (int) $t) > 300) return false;          // replay window
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $sig);                     // timing-safe
}

Python

import hashlib, hmac, re, time

def verify_yodon_signature(secret: str, header: str, raw_body: bytes) -> bool:
    m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
    if not m:
        return False
    t, sig = m.groups()
    if abs(time.time() - int(t)) > 300:      # replay window
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, sig)

What to answer

Any 2xx counts as delivered. But answer with JSON and YoDon gets smarter:

{ "ok": true, "id": "123", "url": "https://example.com/blog/my-post" }
  • url — stored as the article's live link: the dashboard's View button opens it, and Google indexing submits it.
  • id — your post's identifier, stored so future article.updated events can be matched on your side too.

Ping answers can carry your taxonomy: reply with { ok: true, categories: ["Guides", "News"] } and those names appear as real category choices in YoDon's creation forms. Refreshed on every Re-verify and on the category picker's refresh button — omit the field and the AI simply proposes category names instead.

Retries and duplicates

  • A network failure or 5xx answer is retried once immediately, then on later runs — an outage on your side doesn't lose articles.
  • A 4xx answer is not retried automatically: it means you understood the request and refused it. Fix the cause, then use the Retry button in the site's Recent deliveries panel.
  • Because of retries your endpoint may see the same article twice — this is why upserting by article.id matters.
  • Every delivery, its response code and your response body (first kilobyte) are visible under the site's Recent deliveries.

A complete example receiver

A Next.js App Router route that verifies, dedupes and stores. Swap savePost for your own persistence and it's production-ready:

// app/api/articles/webhook/route.ts — a complete Next.js receiver
import { NextResponse } from "next/server";
import crypto from "crypto";

const SECRET = process.env.YODON_WEBHOOK_SECRET; // the whsec_… you copied

function verify(header: string, body: string): boolean {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const expected = crypto.createHmac("sha256", SECRET!)
    .update(`${m[1]}.${body}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(m[2]), Buffer.from(expected));
}

export async function POST(req: Request) {
  const body = await req.text(); // raw body — verify BEFORE parsing
  const payload = JSON.parse(body);

  // The connect-time ping arrives before you have the secret (it is shown
  // after the ping succeeds), so accept pings while unconfigured.
  if (!SECRET) {
    if (payload.event === "ping") return NextResponse.json({ ok: true });
    return NextResponse.json({ error: "not configured" }, { status: 503 });
  }
  if (!verify(req.headers.get("x-yodon-signature") ?? "", body)) {
    return NextResponse.json({ error: "bad signature" }, { status: 401 });
  }
  if (payload.event === "ping") {
    // Optional: advertise your categories so YoDon's forms can offer them.
    return NextResponse.json({ ok: true, categories: await listMyCategories() });
  }

  const a = payload.article;
  // Upsert by a.id — retries and updates must not create duplicates.
  const post = await savePost({
    externalId: a.id,
    slug: a.slug,
    title: a.title,
    html: a.html,
    excerpt: a.excerpt,
    metaTitle: a.seo.title,
    metaDescription: a.seo.description,
    tags: a.tags,
    category: a.category,
    imageUrl: a.featured_image?.url ?? null,
    published: a.status === "publish",
  });

  // Answer with the live URL so the YoDon dashboard links to the post.
  return NextResponse.json({ ok: true, id: post.id, url: post.url });
}

The same logic ports to any framework in a few minutes — the FAQ below covers the common platform questions.

Questions

Which platforms does this work with?
Any stack that can receive an HTTPS POST: Next.js, Laravel, Django, Rails, Express, a Cloudflare Worker, a no-code tool like Make or n8n, a custom CMS — if it has a URL, it can receive articles.
I lost the signing secret. Where do I find it again?
Nowhere — we store it encrypted and never display it after the connect dialog. Reconnect the same endpoint URL from the Sites page and a fresh secret is generated; update your server with the new one.
Do scheduled articles work?
Yes. Scheduling stays on YoDon's side: at the scheduled moment we send the article with status "publish". Your endpoint never has to implement scheduling.
What about drafts?
An article sent as a draft arrives with status "draft". What that means on your site is your call — most receivers store the post unpublished. When you later hit Publish in YoDon, the same article arrives again as an article.updated event with status "publish".
How do images arrive?
The featured image comes as a signed public URL on our servers (featured_image.url), and the article HTML references the same URL. Download it and host it yourself, or serve it straight from ours — both work.
Is the payload ever retried? Will I see duplicates?
A failed delivery is retried, so your endpoint can see the same article more than once. Treat article.id as the idempotency key: update the existing post instead of creating a second one, and duplicates become impossible.
Does SEO metadata come through?
Yes — seo.title, seo.description and seo.keyword ride along with every article, plus the excerpt, tags, category names and language. Use what your platform supports and ignore the rest.

Publishing to WordPress instead? Read the WordPress guide

Your stack, our articles

Connect an endpoint once and every article — manual, bulk or automated — flows straight into your site.