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

  1. Go to your SEO45 AI dashboard
  2. Click "Add Site"
  3. Select "Universal" as your platform
  4. 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-signaturev1= + 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.

  1. Create a Google Sheet (example: a tab named SEO45)
  2. Open Extensions → Apps Script
  3. Paste the code below and set YOUR_SHEET_ID
  4. Click Deploy → New deployment → Web app
  5. Set Execute as: Me, and Who has access: Anyone
  6. Copy the Web App URL and paste it into your site's Webhook URL in SEO45
  7. 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 websiteId and domain to route content correctly
  • Return post_url when you publish so SEO45 can display it
Universal Setup | SEO45 AI Documentation – SEO45 AI