Docs/Guides/Webhook Integration

Webhook Integration

FirstSearch.AI delivers articles directly to your website via HTTP webhooks. This guide covers everything you need to receive and verify article deliveries.

Quick Start

1
Create a POST endpoint on your server
e.g., https://yoursite.com/api/webhook
2
Add the webhook URL and copy the secret in Settings
The secret is auto-generated. Store it as an environment variable on your server.
3
Click "Test" to verify the connection
A test article will be sent to your endpoint with the same headers and signature as real deliveries.

HTTP Headers

Every webhook request from FirstSearch.AI includes these headers:

HeaderDescriptionUsed For
Content-TypeAlways application/jsonAlways present
User-AgentFirstSearch.AI/1.0Always present
X-Webhook-SecretYour shared secret in plain text. Compare this against your stored secret to authenticate requests.Authentication
X-Webhook-TimestampUnix timestamp (seconds) when the request was sentAlways present
X-Webhook-SignatureHMAC-SHA256 of the request body (optional, for advanced use)Optional

Authentication

Authenticate webhook requests by comparing the X-Webhook-Secret header against the secret stored on your server. If they match, the request is from FirstSearch.AI. This is the only validation you need.

Node.js / Express

javascript
const express = require('express');
const app = express();
app.use(express.json());

app.post('/api/webhook', (req, res) => {
  const secret = req.headers['x-webhook-secret'];

  if (secret !== process.env.FIRSTSEARCH_WEBHOOK_SECRET) {
    return res.status(401).json({ error: 'Invalid secret' });
  }

  // Authenticated — process the article
  const { title, content, slug, seo, featuredImage } = req.body;
  console.log('Received article:', title);

  // IMPORTANT: Use featuredImage as your hero/cover image
  // It is NOT embedded in the content — you must set it separately
  if (featuredImage?.url) {
    console.log('Hero image:', featuredImage.url);    // Permanent URL (1200x630)
    console.log('Alt text:', featuredImage.alt);       // SEO-optimized alt
  }

  // Create post in your CMS here...

  res.status(200).json({ success: true });
});

app.listen(3000);

Python / Flask

python
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/api/webhook', methods=['POST'])
def webhook():
    secret = request.headers.get('X-Webhook-Secret', '')

    if secret != os.environ['FIRSTSEARCH_WEBHOOK_SECRET']:
        return jsonify({'error': 'Invalid secret'}), 401

    # Authenticated — process the article
    article = request.get_json()
    print(f"Received article: {article['title']}")

    # IMPORTANT: Use featuredImage as your hero/cover image
    # It is NOT embedded in the content — you must set it separately
    featured = article.get('featuredImage', {})
    if featured.get('url'):
        print(f"Hero image: {featured['url']}")    # Permanent URL (1200x630)
        print(f"Alt text: {featured['alt']}")       # SEO-optimized alt

    # Create post in your CMS here...

    return jsonify({'success': True}), 200

PHP

php
<?php
$secret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';

if ($secret !== getenv('FIRSTSEARCH_WEBHOOK_SECRET')) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid secret']);
    exit;
}

// Authenticated — process the article
$article = json_decode(file_get_contents('php://input'), true);
error_log('Received article: ' . $article['title']);

// IMPORTANT: Use featuredImage as your hero/cover image
// It is NOT embedded in the content — you must set it separately
if (!empty($article['featuredImage']['url'])) {
    $heroUrl = $article['featuredImage']['url'];   // Permanent URL (1200x630)
    $heroAlt = $article['featuredImage']['alt'];    // SEO-optimized alt
}

// Create post in your CMS here...

http_response_code(200);
echo json_encode(['success' => true]);

WordPress (functions.php)

php
add_action('rest_api_init', function () {
  register_rest_route('firstsearch/v1', '/webhook', [
    'methods' => 'POST',
    'callback' => 'handle_firstsearch_webhook',
    'permission_callback' => '__return_true',
  ]);
});

function handle_firstsearch_webhook($request) {
  $secret = $request->get_header('X-Webhook-Secret');

  if ($secret !== get_option('firstsearch_webhook_secret')) {
    return new WP_Error('unauthorized', 'Invalid secret', ['status' => 401]);
  }

  // Authenticated — create the post
  $article = $request->get_json_params();

  $post_id = wp_insert_post([
    'post_title'   => sanitize_text_field($article['title']),
    'post_content' => wp_kses_post($article['content']),
    'post_excerpt' => sanitize_text_field($article['excerpt'] ?? ''),
    'post_status'  => 'draft',  // Save as draft for review
    'post_type'    => 'post',
    'post_name'    => sanitize_title($article['slug']),
  ]);

  return ['success' => true, 'post_id' => $post_id];
}

Advanced: HMAC Signature Verification

This is optional. The X-Webhook-Secret header comparison above is sufficient for most integrations. HMAC verification provides an additional layer of security by cryptographically proving the request body was not tampered with in transit.

When a secret is configured, every request also includes an X-Webhook-Signature header containing an HMAC-SHA256 hash of the raw request body, using your webhook secret as the key.

How the signature is generated

  1. The full JSON request body is serialized with JSON.stringify()
  2. An HMAC-SHA256 hash is computed using your webhook secret as the key
  3. The resulting hex digest is sent in the X-Webhook-Signature header (no prefix, just the hex string)

Node.js / Express

javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

// Important: preserve raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.post('/api/webhook', (req, res) => {
  const secret = process.env.FIRSTSEARCH_WEBHOOK_SECRET;

  // Step 1: Verify secret header (required)
  if (req.headers['x-webhook-secret'] !== secret) {
    return res.status(401).json({ error: 'Invalid secret' });
  }

  // Step 2: Verify HMAC signature (optional, additional security)
  const signature = req.headers['x-webhook-signature'];
  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.rawBody)
    .digest('hex');

  if (signature !== expected) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Step 3: Check timestamp freshness (optional, prevents replay attacks)
  const timestamp = req.headers['x-webhook-timestamp'];
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return res.status(401).json({ error: 'Timestamp expired' });
  }

  // Process the article
  const article = req.body;
  console.log('Received article:', article.title);

  res.status(200).json({ success: true });
});

app.listen(3000);

Python / Flask

python
import hmac
import hashlib
import time
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/api/webhook', methods=['POST'])
def webhook():
    secret = os.environ['FIRSTSEARCH_WEBHOOK_SECRET']

    # Step 1: Verify secret header (required)
    if request.headers.get('X-Webhook-Secret', '') != secret:
        return jsonify({'error': 'Invalid secret'}), 401

    # Step 2: Verify HMAC signature (optional, additional security)
    signature = request.headers.get('X-Webhook-Signature', '')
    expected = hmac.new(
        secret.encode('utf-8'),
        request.get_data(),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        return jsonify({'error': 'Invalid signature'}), 401

    # Step 3: Check timestamp freshness (optional)
    timestamp = request.headers.get('X-Webhook-Timestamp', '0')
    if abs(time.time() - int(timestamp)) > 300:
        return jsonify({'error': 'Timestamp expired'}), 401

    # Process the article
    article = request.get_json()
    print(f"Received article: {article['title']}")

    return jsonify({'success': True}), 200

Payload Structure

Every webhook delivery sends a JSON object with the following fields:

Core Content

FieldTypeDescription
titlestringArticle headline
contentstringFull article in Markdown format
excerptstringPlain text excerpt (first 160 chars)
slugstringURL-friendly slug for the article
publishDatestringISO 8601 timestamp of original publish date
updatedAtstring?ISO 8601 timestamp when the article was last edited and resent. Only present on updates.
isUpdatebooleantrue if this is a resend of a previously published article. Use to update existing post by slug instead of creating a duplicate.
statusstringAlways "publish"
authorstringAuthor name (configurable per website or per article)
categoriesstring[]Article categories
tagsstring[]Article tags (keyword + related terms)

SEO Metadata

FieldTypeDescription
seo.metaTitlestringSEO title tag
seo.metaDescriptionstringMeta description for search engines
seo.focusKeywordstringPrimary target keyword
featuredImage.urlstringAI-generated hero image URL
featuredImage.altstringSEO-optimized alt text
openGraphobjectOpen Graph social sharing data
twitterobjectTwitter card data
schemaobjectJSON-LD Article structured data
faqSchemaobject?FAQ structured data (if FAQ section exists)

Article Metrics

FieldTypeDescription
metadata.keywordstringTarget keyword
metadata.wordCountnumberTotal word count
metadata.readingTimenumberEstimated minutes to read
metadata.difficultynumberKeyword difficulty (0-100)
metadata.searchVolumenumberMonthly search volume

Example Payload

json
{
  "title": "How to Optimize Images for Web Performance",
  "content": "## Quick Summary\n\n- Compress images...\n\n## Why Image Optimization Matters\n\nPage speed directly affects...",
  "excerpt": "Learn how to optimize images for faster page loads and better SEO rankings.",
  "slug": "optimize-images-web-performance",
  "seo": {
    "metaTitle": "How to Optimize Images for Web Performance",
    "metaDescription": "Learn how to optimize images for faster page loads and better SEO rankings.",
    "focusKeyword": "optimize images for web"
  },
  "featuredImage": {
    "url": "https://replicate.delivery/...",
    "alt": "Web performance optimization dashboard showing image compression results",
    "width": 1200,
    "height": 630
  },
  "openGraph": {
    "title": "How to Optimize Images for Web Performance",
    "description": "Learn how to optimize images for faster page loads.",
    "type": "article",
    "image": "https://replicate.delivery/..."
  },
  "metadata": {
    "keyword": "optimize images for web",
    "wordCount": 3200,
    "readingTime": 16,
    "difficulty": 35,
    "searchVolume": 1200
  },
  "publishDate": "2026-02-08T00:05:00.000Z",
  "updatedAt": null,
  "isUpdate": false,
  "status": "publish",
  "author": "Jane Smith",
  "categories": ["Web Development"],
  "tags": ["image optimization", "page speed", "web performance"],
  "schema": { "@context": "https://schema.org", "@type": "Article", "headline": "...", "..." : "..." },
  "faqSchema": {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    "mainEntity": [
      { "@type": "Question", "name": "What is image optimization?", "acceptedAnswer": { "@type": "Answer", "text": "Image optimization is..." } }
    ]
  }
}

Retries & Error Handling

Expected response

Return HTTP 2xx (200, 201, or 204) to confirm receipt. Any other status triggers an automatic retry.

Retry behavior

SettingValue
Max attempts3
First retry delay60 seconds
Max retry delay10 minutes
Request timeout30 seconds

After 3 failed attempts, the article is marked as failed. You can manually retry from the dashboard.

Security Best Practices

  • 1. Always verify the X-Webhook-Secret header before processing any request
  • 2. Use HTTPS for your webhook endpoint
  • 3. Store your webhook secret in environment variables, never in source code
  • 4. Use the article slug to prevent processing duplicate deliveries
  • 5. For additional security, verify the HMAC signature (see Advanced section above)
  • 6. If verifying HMAC, use the raw request body, not the parsed JSON object

Content Structure

The content field contains Markdown, not HTML. This gives you full control over rendering. Here's what to expect.

Article Layout

Every article follows this structure:

markdown
*Last Updated: March 15, 2026*

## Key Takeaways
- First key insight from the article
- Second key insight
- Third key insight
- Fourth key insight

## Introduction Section
Opening paragraph with direct answer to the search query...

![Alt text describing the image](https://storage.googleapis.com/.../image.webp)

More content with [external links](https://example.com) to sources...

## Body Sections (Multiple H2s)
Each major topic gets its own H2 heading. Sections may contain:

### Subsections (H3)
Deeper dives within a section use H3 headings.

<div class="video-container" style="...">
  <iframe src="https://www.youtube.com/embed/..." ...></iframe>
</div>

## FAQ
### What is the first common question?
Direct answer to the question in 2-3 sentences.

### What about the second question?
Another clear, concise answer.

## Conclusion
Final summary with call-to-action.

Content Elements

ElementFormatNotes
Headings## H2, ### H3, #### H4No H1 (that's the title). Strict hierarchy, never skips levels.
Images![alt](url)AI-generated, pre-resolved to permanent URLs. 2-3 per article.
Videos<div class="video-container">YouTube iframes with responsive wrapper. Max 1 per article.
Links[text](url)Mix of external source citations and internal links to your other articles.
Tables| col | col |Standard Markdown tables or HTML tables for comparisons.
Lists- item / 1. itemUnordered and ordered lists, standard Markdown.
Bold/Italic**bold** / *italic*Standard Markdown emphasis.
Freshness date*Last Updated: ...*Always the first line. Localized to article language.

Featured Image

The featuredImage object is sent separately from the content body. Use it as the hero/cover image for the article page and for social sharing cards. It is NOT embedded in the Markdown content.

json
{
  "featuredImage": {
    "url": "https://storage.googleapis.com/.../featured.webp",
    "alt": "AI-generated illustration of web performance optimization",
    "width": 1200,
    "height": 630
  }
}

Video Embeds

YouTube videos are embedded as responsive HTML within the Markdown. Since your Markdown renderer needs to support raw HTML, use a library like react-markdown with rehype-raw, or similar. The embed structure:

html
<div class="video-container" style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; max-width: 100%; margin: 2rem 0; border-radius: 12px;">
  <iframe
    src="https://www.youtube.com/embed/VIDEO_ID?rel=0"
    title="Video Title"
    style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0;"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
    allowfullscreen
    loading="lazy">
  </iframe>
</div>

Tip: If your CMS strips inline styles or iframes for security, you can parse the video container and re-render it using your own video component. The YouTube video ID is always in the src attribute after /embed/.

Structured Data (Schema.org)

Every article includes ready-to-use JSON-LD structured data for Google rich results. Inject these into your page's <head> as <script type="application/ld+json"> tags.

Article Schema

The schema field contains a complete Article schema. Always present.

json
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "How to Optimize Images for Web Performance",
  "description": "Learn how to optimize images for faster page loads and better SEO.",
  "author": {
    "@type": "Person",
    "name": "Jane Smith"
  },
  "datePublished": "2026-03-15T00:05:00.000Z",
  "wordCount": 3200,
  "keywords": "optimize images for web",
  "image": ["https://storage.googleapis.com/.../featured.webp"]
}

FAQ Schema

The faqSchema field is included when the article contains an FAQ section. This enables FAQ rich snippets in Google search results, showing expandable questions directly in the SERP.

json
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "What is image optimization?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Image optimization is the process of reducing file size while maintaining visual quality. This includes compression, format selection (WebP, AVIF), and responsive sizing."
      }
    },
    {
      "@type": "Question",
      "name": "Does image format affect SEO?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes. Modern formats like WebP load faster, improving Core Web Vitals scores. Google uses page speed as a ranking factor, so optimized images directly impact your search rankings."
      }
    }
  ]
}

How to Mount Structured Data

Add each schema as a separate <script> tag in your page's <head>. Both schemas can coexist on the same page.

Node.js / Express (server-rendered)

javascript
app.post('/api/webhook', (req, res) => {
  const article = req.body;

  // Build the schema script tags for the page <head>
  const schemaScripts = [];

  // Article schema (always present)
  if (article.schema) {
    schemaScripts.push(
      `<script type="application/ld+json">${JSON.stringify(article.schema)}</script>`
    );
  }

  // FAQ schema (only if article has FAQ section)
  if (article.faqSchema) {
    schemaScripts.push(
      `<script type="application/ld+json">${JSON.stringify(article.faqSchema)}</script>`
    );
  }

  // Store schemaScripts with the article for rendering
  saveArticle({
    ...article,
    schemaHtml: schemaScripts.join('\n'),
  });

  res.status(200).json({ success: true });
});

React / Next.js

jsx
// In your article page component
import Head from 'next/head'; // Next.js, or use react-helmet

function ArticlePage({ article }) {
  return (
    <>
      <Head>
        <title>{article.seo.metaTitle}</title>
        <meta name="description" content={article.seo.metaDescription} />

        {/* Article structured data */}
        {article.schema && (
          <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{ __html: JSON.stringify(article.schema) }}
          />
        )}

        {/* FAQ structured data (rich snippets) */}
        {article.faqSchema && (
          <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{ __html: JSON.stringify(article.faqSchema) }}
          />
        )}
      </Head>

      <article>
        <h1>{article.title}</h1>
        {/* Render article.content with a Markdown renderer */}
      </article>
    </>
  );
}

WordPress (functions.php)

php
function handle_firstsearch_webhook($request) {
  $article = $request->get_json_params();

  $post_id = wp_insert_post([
    'post_title'   => sanitize_text_field($article['title']),
    'post_content' => wp_kses_post($article['content']),
    'post_status'  => 'draft',
    'post_type'    => 'post',
    'post_name'    => sanitize_title($article['slug']),
  ]);

  // Save structured data as post meta for your theme to render
  if (!empty($article['schema'])) {
    update_post_meta($post_id, '_article_schema', wp_json_encode($article['schema']));
  }
  if (!empty($article['faqSchema'])) {
    update_post_meta($post_id, '_faq_schema', wp_json_encode($article['faqSchema']));
  }

  return ['success' => true, 'post_id' => $post_id];
}

// Then in your theme's header.php or via wp_head hook:
add_action('wp_head', function() {
  if (is_single()) {
    $schema = get_post_meta(get_the_ID(), '_article_schema', true);
    if ($schema) {
      echo '<script type="application/ld+json">' . $schema . '</script>';
    }
    $faq = get_post_meta(get_the_ID(), '_faq_schema', true);
    if ($faq) {
      echo '<script type="application/ld+json">' . $faq . '</script>';
    }
  }
});

Validate your structured data: After publishing, paste your article URL into Google's Rich Results Test to verify both Article and FAQ schemas are detected correctly.