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
https://yoursite.com/api/webhookHTTP Headers
Every webhook request from FirstSearch.AI includes these headers:
| Header | Description | Used For |
|---|---|---|
| Content-Type | Always application/json | Always present |
| User-Agent | FirstSearch.AI/1.0 | Always present |
| X-Webhook-Secret | Your shared secret in plain text. Compare this against your stored secret to authenticate requests. | Authentication |
| X-Webhook-Timestamp | Unix timestamp (seconds) when the request was sent | Always present |
| X-Webhook-Signature | HMAC-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
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
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}), 200PHP
<?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)
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
- The full JSON request body is serialized with
JSON.stringify() - An HMAC-SHA256 hash is computed using your webhook secret as the key
- The resulting hex digest is sent in the
X-Webhook-Signatureheader (no prefix, just the hex string)
Node.js / Express
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
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}), 200Payload Structure
Every webhook delivery sends a JSON object with the following fields:
Core Content
| Field | Type | Description |
|---|---|---|
| title | string | Article headline |
| content | string | Full article in Markdown format |
| excerpt | string | Plain text excerpt (first 160 chars) |
| slug | string | URL-friendly slug for the article |
| publishDate | string | ISO 8601 timestamp |
| status | string | Always "publish" |
| author | string | Author name |
| categories | string[] | Article categories |
| tags | string[] | Article tags (keyword + related terms) |
SEO Metadata
| Field | Type | Description |
|---|---|---|
| seo.metaTitle | string | SEO title tag |
| seo.metaDescription | string | Meta description for search engines |
| seo.focusKeyword | string | Primary target keyword |
| featuredImage.url | string | AI-generated hero image URL |
| featuredImage.alt | string | SEO-optimized alt text |
| openGraph | object | Open Graph social sharing data |
| object | Twitter card data | |
| schema | object | JSON-LD Article structured data |
| faqSchema | object? | FAQ structured data (if FAQ section exists) |
Article Metrics
| Field | Type | Description |
|---|---|---|
| metadata.keyword | string | Target keyword |
| metadata.wordCount | number | Total word count |
| metadata.readingTime | number | Estimated minutes to read |
| metadata.difficulty | number | Keyword difficulty (0-100) |
| metadata.searchVolume | number | Monthly search volume |
Example Payload
{
"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
| Setting | Value |
|---|---|
| Max attempts | 3 |
| First retry delay | 60 seconds |
| Max retry delay | 10 minutes |
| Request timeout | 30 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-Secretheader 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