Universal Integration
Universal works with any website platform by delivering your daily generated content to a webhook you provide. You receive SEO-ready HTML, a featured image URL, and metadata so you can publish on any stack.
When to Use Universal
- Your platform isn't WordPress, Webflow, or Shopify
- You use a custom CMS or website builder
- You want to publish using your own backend logic
- You want to route content to multiple channels
Step 1: Add Your Site
- Go to your SEO45 AI dashboard
- Click "Add Site"
- Select "Universal" as your platform
- Enter your website domain
Step 2: Configure Your Webhook
Provide the following values in setup (or later in Site Settings → Configuration):
- Webhook URL — HTTPS endpoint that will receive POST requests
- Webhook Secret — used to verify requests with an HMAC signature
Use the Test Webhook button in the setup wizard to validate connectivity.
Step 3: Choose Your Plan
Select a subscription plan for your site.
You're set up! SEO45 AI will generate 1 SEO-optimized post per day and deliver it to your webhook.
Webhook Requests
SEO45 AI sends a POST request to your webhook. Requests include a signature so you can verify authenticity.
Headers
- x-seo45-timestamp — Unix milliseconds
- x-seo45-signature —
v1=+ hex HMAC-SHA256 - x-seo45-website-id — website UUID
- x-seo45-event — event name
Signature
Compute HMAC-SHA256 with your webhook secret over:
{timestamp}.{rawBody}Compare it to the signature in x-seo45-signature. Reject requests if the timestamp is too old (recommended: 5 minutes).
Payload
{
"event": "seo45.content.generated",
"websiteId": "uuid",
"domain": "example.com",
"createdAt": "2026-02-13T00:00:00.000Z",
"post": {
"title": "SEO-optimized title",
"html": "<article>...</article>",
"excerpt": "Optional short excerpt",
"seoScore": 88,
"featuredImage": {
"url": "https://...",
"alt": "Featured image alt text",
"caption": "Featured image caption"
}
},
"debug": {}
}Ping Event
During setup, we may send a seo45.webhook.ping event. Return 2xx to confirm you can receive traffic.
Response
Return a 2xx response. Optionally return JSON so we can store a post reference:
{ "post_url": "https://example.com/blog/my-post", "post_id": "123" }Implementation Blueprints
Next.js (App Router)
import crypto from 'crypto';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const secret = process.env.SEO45_WEBHOOK_SECRET || '';
const timestamp = req.headers.get('x-seo45-timestamp') || '';
const signature = req.headers.get('x-seo45-signature') || '';
const rawBody = await req.text();
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const data = JSON.parse(rawBody);
if (data.event === 'seo45.content.generated') {
const title = data.post?.title;
const html = data.post?.html;
const imageUrl = data.post?.featuredImage?.url || null;
void title;
void html;
void imageUrl;
}
return NextResponse.json({ ok: true });
}Node.js (Express)
import crypto from 'crypto';
import express from 'express';
const app = express();
app.post('/seo45/webhook', express.text({ type: '*/*' }), (req, res) => {
const secret = process.env.SEO45_WEBHOOK_SECRET || '';
const timestamp = req.header('x-seo45-timestamp') || '';
const signature = req.header('x-seo45-signature') || '';
const rawBody = req.body || '';
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).json({ error: 'Invalid signature' });
const data = JSON.parse(rawBody);
if (data.event === 'seo45.content.generated') {
const title = data.post?.title;
const html = data.post?.html;
const imageUrl = data.post?.featuredImage?.url || null;
void title;
void html;
void imageUrl;
}
res.json({ ok: true });
});
app.listen(3000);Google Apps Script + Google Sheets
Use this if you want Universal webhook deliveries saved into a Google Sheet. Apps Script web apps do not reliably expose custom request headers, so HMAC verification may not be possible directly. If you need strict verification, use a lightweight proxy (Cloud Run/Functions) to validate headers and then write to Sheets.
Simple approach: deploy an Apps Script Web App and append each delivery to a sheet.
- Create a Google Sheet (example: a tab named SEO45)
- Open Extensions → Apps Script
- Paste the code below and set
YOUR_SHEET_ID - Click Deploy → New deployment → Web app
- Set Execute as: Me, and Who has access: Anyone
- Copy the Web App URL and paste it into your site's Webhook URL in SEO45
- Set any Webhook Secret value and click Test Webhook (this triggers a ping event)
function doPost(e) {
var rawBody = (e && e.postData && e.postData.contents) ? e.postData.contents : '';
if (!rawBody) {
return ContentService.createTextOutput(JSON.stringify({ error: 'Missing body' }))
.setMimeType(ContentService.MimeType.JSON);
}
var data = JSON.parse(rawBody);
var eventName = data.event || '';
var ss = SpreadsheetApp.openById('YOUR_SHEET_ID');
var sheet = ss.getSheetByName('SEO45') || ss.insertSheet('SEO45');
if (sheet.getLastRow() === 0) {
sheet.appendRow([
'received_at',
'event',
'websiteId',
'domain',
'title',
'featured_image_url',
'seoScore',
'html'
]);
}
var receivedAt = new Date().toISOString();
var websiteId = data.websiteId || '';
var domain = data.domain || '';
var title = (data.post && data.post.title) ? data.post.title : '';
var imageUrl = (data.post && data.post.featuredImage && data.post.featuredImage.url) ? data.post.featuredImage.url : '';
var seoScore = (data.post && data.post.seoScore) ? data.post.seoScore : '';
var html = (data.post && data.post.html) ? data.post.html : '';
sheet.appendRow([receivedAt, eventName, websiteId, domain, title, imageUrl, seoScore, html]);
return ContentService.createTextOutput(JSON.stringify({ ok: true }))
.setMimeType(ContentService.MimeType.JSON);
}PHP
<?php
$secret = getenv('SEO45_WEBHOOK_SECRET') ?: '';
$timestamp = $_SERVER['HTTP_X_SEO45_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SEO45_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input') ?: '';
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$data = json_decode($rawBody, true);
if (($data['event'] ?? '') === 'seo45.content.generated') {
$title = $data['post']['title'] ?? '';
$html = $data['post']['html'] ?? '';
$imageUrl = $data['post']['featuredImage']['url'] ?? null;
}
header('Content-Type: application/json');
echo json_encode(['ok' => true]);Python (FastAPI)
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/seo45/webhook")
async def seo45_webhook(req: Request):
secret = os.environ.get("SEO45_WEBHOOK_SECRET", "")
timestamp = req.headers.get("x-seo45-timestamp", "")
signature = req.headers.get("x-seo45-signature", "")
raw_body = (await req.body()).decode("utf-8")
expected = "v1=" + hmac.new(secret.encode("utf-8"), f"{timestamp}.{raw_body}".encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
data = await req.json()
if data.get("event") == "seo45.content.generated":
title = data.get("post", {}).get("title")
html = data.get("post", {}).get("html")
image_url = (data.get("post", {}).get("featuredImage") or {}).get("url")
_ = (title, html, image_url)
return {"ok": True}Laravel (PHP)
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/seo45/webhook', function (Request $request) {
$secret = env('SEO45_WEBHOOK_SECRET', '');
$timestamp = $request->header('x-seo45-timestamp', '');
$signature = $request->header('x-seo45-signature', '');
$rawBody = $request->getContent();
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
$data = $request->json()->all();
if (($data['event'] ?? '') === 'seo45.content.generated') {
$title = $data['post']['title'] ?? '';
$html = $data['post']['html'] ?? '';
$imageUrl = $data['post']['featuredImage']['url'] ?? null;
}
return response()->json(['ok' => true]);
});Best Practices
- Verify signature + timestamp on every request
- Respond fast (2xx) and queue work in your system
- Store
websiteIdanddomainto route content correctly - Return
post_urlwhen you publish so SEO45 can display it