Payload Structure
Every time Epicurus One publishes an article to your webhook, it sends a POST request with a JSON body. Here's the full shape:
{
"event": "article.published",
"timestamp": "2026-04-07T14:00:00.000Z",
"article": {
"id": 142,
"title": "Best Running Shoes for Flat Feet (2026)",
"slug": "best-running-shoes-flat-feet-2026",
"status": "publish",
"excerpt": "Finding the right running shoes for flat feet...",
"content": {
"h1": "Best Running Shoes for Flat Feet (2026)",
"intro": "Finding the right running shoes...",
"sections": [
{
"h2": "Why Flat Feet Need Special Support",
"content": "Flat feet, or fallen arches...",
"h3s": [
{
"heading": "Overpronation Explained",
"content": "When your foot rolls inward..."
}
]
},
{
"h2": "Top 5 Running Shoes for Flat Feet",
"content": "Based on our research..."
}
],
"videos": [
{
"videoId": "abc123",
"title": "Best Running Shoes Review",
"embedUrl": "https://www.youtube-nocookie.com/embed/abc123"
}
],
"faq": [
{
"question": "Can you run with flat feet?",
"answer": "Yes, with the right shoes..."
}
],
"key_takeaways": [
"Stability shoes are best for flat feet",
"Replace running shoes every 300-500 miles"
]
},
"featured_image": "https://images.example.com/thumb.webp",
"images": [
{
"url": "https://images.example.com/inline-1.webp",
"alt": "Running shoe comparison chart",
"section_index": 1
}
],
"faq": [...],
"key_takeaways": [...],
"cta": {
"text": "Shop Running Shoes",
"url": "https://example.com/shop"
}
},
"seo": {
"meta_title": "Best Running Shoes for Flat Feet (2026)",
"meta_description": "Expert picks for the best running...",
"primary_keyword": "running shoes flat feet",
"word_count": 2847,
"seo_score": 82,
"aeo_score": 76
}
}
AI-Agent Note: Thecontent.sectionsarray contains all article sections in order. Each section hash2(the heading text) andcontent(plain text with potential markdown formatting). Sections may also containh3s[]— an array of sub-sections, each withheadingandcontent. Thesection_indexin images tells you which section (0-based) the inline image belongs to. Thecontent.videos[]array contains YouTube video data withvideoId,title, andembedUrl.
Request Headers
Every webhook request from Epicurus One includes these headers:
| Header | Value | Always Present |
|---|---|---|
Content-Type | application/json | Yes |
User-Agent | EpicurusOne-Webhook/1.0 | Yes |
X-Epicurus-Event | article.published or ping | Yes |
X-Epicurus-Signature | sha256=<hex> | Only if signing secret is set |
X-Epicurus-Timestamp | Unix timestamp (seconds) | Only if signing secret is set |
You can also add custom headers in your webhook configuration. They'll be merged into every request.
HMAC-SHA256 Signature Verification
If you set a signing secret in your webhook configuration, every request is signed. The signature is computed as:
signature = HMAC-SHA256(
key: your_signing_secret,
data: timestamp + "." + raw_request_body
)
// Sent as header:
// X-Epicurus-Signature: sha256=<hex_digest>
// X-Epicurus-Timestamp: <unix_seconds>
The timestamp is the value from the X-Epicurus-Timestamp header. The raw_request_body is the exact JSON string sent in the POST body. Always verify using the raw body (before JSON parsing) to ensure the signature matches.
Security tip: Check that the timestamp is within 5 minutes of your server's time to prevent replay attacks.
Node.js Example (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'your-secret';
// IMPORTANT: Need raw body for signature verification
app.use('/webhook', express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));
app.post('/webhook', (req, res) => {
// 1. Verify signature
const signature = req.headers['x-epicurus-signature'];
const timestamp = req.headers['x-epicurus-timestamp'];
const event = req.headers['x-epicurus-event'];
if (WEBHOOK_SECRET && signature) {
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${req.rawBody}`)
.digest('hex');
if (signature !== `sha256=${expected}`) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Optional: reject if timestamp is older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) {
return res.status(401).json({ error: 'Request too old' });
}
}
// 2. Handle ping events (connection test)
if (event === 'ping') {
return res.json({ ok: true, message: 'Webhook received' });
}
// 3. Process the article
const { article, seo } = req.body;
console.log(`New article: ${article.title}`);
console.log(`Keyword: ${seo.primary_keyword}`);
console.log(`Word count: ${seo.word_count}`);
// Your logic: save to database, render HTML, etc.
// article.content.sections[] has the structured content
// article.featured_image has the thumbnail URL
// article.images[] has inline images with section_index
res.json({ ok: true, message: 'Article received' });
});
app.listen(3000, () => console.log('Webhook server on :3000'));
Python Example (Flask)
import hmac, hashlib, json, time
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = 'your-secret'
@app.route('/webhook', methods=['POST'])
def webhook():
# 1. Verify signature
signature = request.headers.get('X-Epicurus-Signature', '')
timestamp = request.headers.get('X-Epicurus-Timestamp', '')
event = request.headers.get('X-Epicurus-Event', '')
raw_body = request.get_data(as_text=True)
if WEBHOOK_SECRET and signature:
message = f"{timestamp}.{raw_body}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
if signature != f"sha256={expected}":
return jsonify(error='Invalid signature'), 401
# Optional: check timestamp freshness
if abs(time.time() - int(timestamp)) > 300:
return jsonify(error='Request too old'), 401
# 2. Handle ping
if event == 'ping':
return jsonify(ok=True, message='Webhook active')
# 3. Process article
data = json.loads(raw_body)
article = data['article']
seo = data['seo']
print(f"New article: {article['title']}")
print(f"Sections: {len(article['content']['sections'])}")
# Your logic here — save to DB, generate HTML, etc.
return jsonify(ok=True, message='Article saved')
if __name__ == '__main__':
app.run(port=3000)
PHP Example
<?php
$secret = 'your-secret';
$rawBody = file_get_contents('php://input');
$headers = getallheaders();
$signature = $headers['X-Epicurus-Signature'] ?? '';
$timestamp = $headers['X-Epicurus-Timestamp'] ?? '';
$event = $headers['X-Epicurus-Event'] ?? '';
// 1. Verify signature
if ($secret && $signature) {
$message = $timestamp . '.' . $rawBody;
$expected = 'sha256=' . hash_hmac('sha256', $message, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Optional: check freshness
if (abs(time() - intval($timestamp)) > 300) {
http_response_code(401);
echo json_encode(['error' => 'Request too old']);
exit;
}
}
// 2. Handle ping
if ($event === 'ping') {
echo json_encode(['ok' => true, 'message' => 'Webhook active']);
exit;
}
// 3. Process article
$data = json_decode($rawBody, true);
$article = $data['article'];
$seo = $data['seo'];
error_log("New article: " . $article['title']);
error_log("Word count: " . $seo['word_count']);
// Your logic: save to database, render template, etc.
// $article['content']['sections'] — structured content array
// $article['featured_image'] — thumbnail URL
// $article['images'] — inline images with section_index
echo json_encode(['ok' => true, 'message' => 'Article saved']);
?>
cURL Test
You can test your webhook endpoint with a simulated Epicurus payload:
# Simple ping test (no signature)
curl -X POST https://your-server.com/webhook \
-H "Content-Type: application/json" \
-H "X-Epicurus-Event: ping" \
-H "User-Agent: EpicurusOne-Webhook/1.0" \
-d '{"event":"ping","timestamp":"2026-04-07T14:00:00.000Z","message":"test"}'
# Full article payload test
curl -X POST https://your-server.com/webhook \
-H "Content-Type: application/json" \
-H "X-Epicurus-Event: article.published" \
-H "User-Agent: EpicurusOne-Webhook/1.0" \
-d '{
"event": "article.published",
"timestamp": "2026-04-07T14:00:00.000Z",
"article": {
"id": 1,
"title": "Test Article",
"slug": "test-article",
"status": "publish",
"excerpt": "This is a test article.",
"content": {
"h1": "Test Article",
"intro": "This is a test.",
"sections": [
{"heading": "Section One", "content": "Hello world."}
]
}
},
"seo": {
"meta_title": "Test Article",
"meta_description": "A test article",
"primary_keyword": "test",
"word_count": 100,
"seo_score": 85,
"aeo_score": 72
}
}'
Handling Images
Epicurus One generates AI images for every article. The payload includes:
article.featured_image— The thumbnail/hero image URL (always present for paid tiers)article.images[]— Inline images, each with:url— Direct image URLalt— AI-generated alt text (SEO-optimized)section_index— Which section this image belongs to (0-based)
To render inline images in the right position, insert them before or after the matching sections[section_index].
// Node.js — Rendering sections with inline images
function renderArticle(article) {
let html = '';
const imageMap = {};
// Group images by section_index
(article.images || []).forEach(img => {
if (!imageMap[img.section_index]) imageMap[img.section_index] = [];
imageMap[img.section_index].push(img);
});
// Render each section
article.content.sections.forEach((section, i) => {
html += `<h2>${section.h2}</h2>`;
html += `<p>${section.content}</p>`;
// Insert inline images after the section
if (imageMap[i]) {
imageMap[i].forEach(img => {
html += `<img src="${img.url}" alt="${img.alt}" loading="lazy">`;
});
}
});
return html;
}
Handling Video Embeds
Articles may include YouTube video embeds. In JSON format, video placeholders appear as [VIDEO_EMBED_0], [VIDEO_EMBED_1], etc. in section content. The actual embed URLs are in content.videos[]. In HTML format, videos are already rendered as <iframe> elements.
Important: Epicurus usesyoutube-nocookie.comembed URLs withreferrerpolicy="strict-origin-when-cross-origin"to prevent YouTube Error 153 and respect user privacy.
GPT sometimes writes raw <iframe> HTML directly into section content instead of using [VIDEO_EMBED_N] placeholders. Your renderer should handle both patterns.
HTML Format Option
When you configure your webhook, you can choose between JSON (default) and HTML format.
- JSON format:
article.contentcontains the structured object (sections, headings, FAQ, etc.) - HTML format:
article.htmlcontains the fully rendered HTML ready to insert into a page
Use JSON when you want full control over rendering. Use HTML when you just need to drop the article into a container.
FAQ & Key Takeaways
If the article includes an FAQ section, it's available in two places:
article.content.faq[]— Array of{question, answer}pairs in the content structurearticle.faq[]— Same data at the top level for convenience
Key takeaways follow the same pattern — available in both article.content.key_takeaways[] and article.key_takeaways[].
For SEO, you should render FAQ data as FAQPage JSON-LD schema:
// Generate FAQPage schema from the payload
function buildFaqSchema(faq) {
if (!faq || !faq.length) return null;
return {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faq.map(item => ({
"@type": "Question",
"name": item.question,
"acceptedAnswer": {
"@type": "Answer",
"text": item.answer
}
}))
};
}
Testing Your Endpoint
In the Epicurus One dashboard, go to Settings → Connectors → Webhooks and click Connect. In the connect modal:
- Enter your endpoint URL
- Optionally set a signing secret
- Choose format (JSON or HTML)
- Choose HTTP method (POST or PUT)
- Click Test — Epicurus sends a
pingevent to verify your endpoint responds - Click Connect — Your webhook is saved and will receive all future articles
The test ping payload is:
{
"event": "ping",
"timestamp": "2026-04-07T14:00:00.000Z",
"message": "Epicurus One webhook test"
}
Your server should respond with a 2xx status code. The response body content doesn't matter — Epicurus only checks the HTTP status.
Expected Response
Your webhook endpoint should return:
- Status
200or201— Article received successfully - JSON body (optional) — You can return
{"url": "https://your-site.com/blog/slug"}and Epicurus will store the published URL for GSC indexing
If your endpoint returns a URL in the response, Epicurus will automatically submit it to Google Search Console for indexing (if GSC is connected for the site).
// Best practice: return the published URL
res.json({
ok: true,
url: `https://your-site.com/blog/${article.slug}`
});
Post-Publish SEO Checklist
If you're building a custom site to receive Epicurus articles (not WordPress, Shopify, or another CMS that handles this automatically), make sure your pages include these essential SEO fundamentals. Without them, Google may discover your URLs but never prioritize crawling them.
Required on Every Page
- Canonical tag —
<link rel="canonical" href="https://your-site.com/blog/slug">on every page. Self-referencing canonical tells Google this is the preferred URL. - Meta description —
<meta name="description" content="...">with a unique, compelling summary (120-160 characters). Useseo.meta_descriptionfrom the payload. - Open Graph tags —
og:title,og:description,og:image,og:urlfor rich previews on social media and messaging apps.
Structured Data (JSON-LD)
- Article schema — Every article page should have
@type: Articlewith headline, datePublished, dateModified, author, publisher, and image. - BreadcrumbList — Helps Google understand your site hierarchy:
Home → Blog → Article Title. - FAQPage schema — If the payload includes
article.faq[], render it as FAQPage JSON-LD (see the FAQ section above). - Organization/WebSite — Your homepage should have Organization and WebSite schema.
Internal Linking
- Related articles — Show 2-3 related posts at the bottom of every article. This is the single biggest factor for crawl priority. Without internal links, each article is a dead end that Google deprioritizes.
- Navigation links in HTML — Blog, category, and key page links should be in the initial HTML, not only rendered by JavaScript. Google's discovery crawl may not execute JS on the first pass.
- Footer cross-links — Link to your blog listing, legal pages, and homepage from every page footer.
Sitemap & HTTP Headers
- XML sitemap — Serve a dynamic
/sitemap.xmlwith all published article URLs. Include<lastmod>with the actual publish/update date — not today's date on every request (Google learns to ignore noisy lastmod values). - robots.txt — Serve a clean
/robots.txtwithAllow: /and aSitemap:directive. Check that your CDN or hosting provider isn't injecting extra directives into your robots.txt. Last-Modifiedheader — Set theLast-Modifiedresponse header on article pages to help Google determine content freshness.- Absolute URLs in response — When returning the published URL in your webhook response, always use an absolute URL (
https://your-site.com/blog/slug, not/blog/slug). Epicurus uses this URL for Google Search Console auto-indexing.
Why this matters: CMS platforms like WordPress handle all of the above automatically via plugins (Yoast, RankMath). If you're building a custom receiver, you are the CMS — these fundamentals are your responsibility. Missing them leads to "Discovered - currently not indexed" in Google Search Console, meaning Google found your URLs but never prioritized crawling them.