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
- Deploy your receiver endpoint (the example at the bottom is a complete one).
- In YoDon, go to Sites → Connect a site → Webhook.
- Enter a name and the endpoint URL, then hit Test & connect. We send a signed ping; a 2xx saves the connection.
- 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?
I lost the signing secret. Where do I find it again?
Do scheduled articles work?
What about drafts?
How do images arrive?
Is the payload ever retried? Will I see duplicates?
Does SEO metadata come through?
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.