Back to Documentation

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);

  // 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']}")

    # 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']);

// 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
statusstringAlways "publish"
authorstringAuthor name
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",
  "status": "publish",
  "author": "Jane Smith",
  "categories": ["Web Development"],
  "tags": ["image optimization", "page speed", "web performance"],
  "schema": { "@context": "https://schema.org", "@type": "Article", "..." : "..." }
}

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 Format

The content field contains Markdown, not HTML. This gives you flexibility to render it however your CMS needs.

  • Images are pre-resolved to direct URLs (no placeholders)
  • Internal links to other published articles may be included
  • Headings follow H2 → H3 → H4 hierarchy (no H1, that's the title)
  • Code blocks, tables, and lists use standard Markdown syntax