Developer Guide

Webhook Integration — Sample Code

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: The content.sections array contains all article sections in order. Each section has h2 (the heading text) and content (plain text with potential markdown formatting). Sections may also contain h3s[] — an array of sub-sections, each with heading and content. The section_index in images tells you which section (0-based) the inline image belongs to. The content.videos[] array contains YouTube video data with videoId, title, and embedUrl.

Request Headers

Every webhook request from Epicurus One includes these headers:

Header Value Always Present
Content-Typeapplication/jsonYes
User-AgentEpicurusOne-Webhook/1.0Yes
X-Epicurus-Eventarticle.published or pingYes
X-Epicurus-Signaturesha256=<hex>Only if signing secret is set
X-Epicurus-TimestampUnix 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 URL
    • alt — 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 uses youtube-nocookie.com embed URLs with referrerpolicy="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.content contains the structured object (sections, headings, FAQ, etc.)
  • HTML format: article.html contains 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 structure
  • article.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:

  1. Enter your endpoint URL
  2. Optionally set a signing secret
  3. Choose format (JSON or HTML)
  4. Choose HTTP method (POST or PUT)
  5. Click Test — Epicurus sends a ping event to verify your endpoint responds
  6. 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 200 or 201 — 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). Use seo.meta_description from the payload.
  • Open Graph tagsog:title, og:description, og:image, og:url for rich previews on social media and messaging apps.

Structured Data (JSON-LD)

  • Article schema — Every article page should have @type: Article with 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.xml with 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.txt with Allow: / and a Sitemap: directive. Check that your CDN or hosting provider isn't injecting extra directives into your robots.txt.
  • Last-Modified header — Set the Last-Modified response 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.