Connect a custom website to SEO Conquest
What you are building
SEO Conquest writes SEO articles for websites and publishes them automatically. A site built without a CMS has no publishing API, so it gets one small addition: an HTTPS endpoint that receives each article, stores it and shows it at an address on the site.
- One endpoint that accepts signed POST requests: the endpoint, the signature, the test request and the article.
- A page template that shows the stored articles: show the article on your site.
- Optionally, one script tag that counts the leads the site gets: lead tracking.
Ready-to-use reference code in PHP and Node.js covers the endpoint. Nothing else on your site changes, and SEO Conquest never logs in to it: every request comes to the address you choose, signed with a secret only you and SEO Conquest know.
Create the signing secret
Create a random secret of at least 32 characters, for example with:
openssl rand -hex 32Keep it on your server only: in the environment variable SEO_CONQUEST_SECRET (the reference code reads it from there) or in a configuration file outside the web root. Never put it in client-side JavaScript or commit it to your repository.
The same secret is entered in SEO Conquest when the site is connected. It never travels in our requests: we only use it to sign them.
The endpoint
Choose an address on the site, for example https://www.example.com/api/seo-conquest. It must:
- be public and use https with a valid certificate;
- accept POST requests with a JSON body;
- answer directly, without a redirect: no redirect from http to https, to www or to add a trailing slash. We never follow redirects;
- answer a test request within 15 seconds and an article within 120 seconds.
Every request carries these headers:
| Header | Example | Meaning |
|---|---|---|
| X-SEO-Conquest-Event | article.published | ping or article.published. |
| X-SEO-Conquest-Timestamp | 1793522400 | The time of sending, in Unix seconds. It is part of what is signed. |
| X-SEO-Conquest-Signature | v1=3b4f… | The signature to check, see the signature. |
| X-SEO-Conquest-Delivery | 5f0c2a1e-… | A unique id per request, for your logs. Not signed. |
| Content-Type | application/json | The body is JSON, UTF-8. |
| User-Agent | SEO-Conquest-Webhook/1.0 (+https://seo-conquest.com/developers/custom-website) | Useful for a firewall rule or a log filter. |
Verify the signature
Before you do anything with a request, check that it comes from SEO Conquest:
- Read the raw request body, before any JSON parsing. Parsing and encoding the JSON again changes the bytes and breaks the signature.
- Refuse the request when
X-SEO-Conquest-Timestampis more than 5 minutes away from your server’s clock. This stops an old request from being sent again. - Compute HMAC-SHA256 of the text
<timestamp>.<raw body>with your secret, as lowercase hex, and putv1=in front of it. - Compare the result with
X-SEO-Conquest-Signaturein constant time:hash_equalsin PHP,crypto.timingSafeEqualin Node.js. - When anything does not match, answer 401 and stop.
signed = timestamp + "." + rawBody
expected = "v1=" + hex(hmac_sha256(secret, signed))
valid = constantTimeEquals(expected, header["X-SEO-Conquest-Signature"])
and abs(now - timestamp) <= 300 secondsThe test request
The test request has the event ping and a random challenge:
{
"event": "ping",
"version": 1,
"sentAt": "2026-11-02T08:30:00.000Z",
"challenge": "9f86d081884c7d659a2feaa0c55ad015"
}Answer with status 200 and the same challenge, as JSON:
{ "challenge": "9f86d081884c7d659a2feaa0c55ad015" }SEO Conquest sends test requests only when the site is connected (see connect the site). They change nothing, so your endpoint should not change anything for them either. You can send one yourself from a terminal:
SECRET='the secret you created'
URL='https://www.example.com/api/seo-conquest'
BODY='{"event":"ping","version":1,"sentAt":"2026-11-02T08:30:00.000Z","challenge":"hello"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -sS -X POST "$URL" \
-H 'Content-Type: application/json' \
-H 'X-SEO-Conquest-Event: ping' \
-H "X-SEO-Conquest-Timestamp: $TS" \
-H "X-SEO-Conquest-Signature: v1=$SIG" \
--data-raw "$BODY"
# Expected answer: {"challenge":"hello"}The article
Each article arrives as article.published:
{
"event": "article.published",
"version": 1,
"sentAt": "2026-11-02T08:30:00.000Z",
"article": {
"slug": "how-to-choose-a-dental-implant",
"title": "How to choose a dental implant",
"metaDescription": "What to ask before an implant: materials, costs, healing time and the questions that set a good clinic apart.",
"language": "en",
"html": "<p>An implant replaces the root of a missing tooth…</p><h2 id=\"materials\">Materials</h2><p>…</p>",
"jsonLd": [
{ "@context": "https://schema.org", "@type": "Article", "headline": "How to choose a dental implant" },
{ "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [] }
],
"heroImage": {
"url": "https://seo-conquest.com/blog-assets/cm9x2k1lq0001",
"alt": "How to choose a dental implant"
},
"publishedAt": "2026-11-02T08:30:00.000Z"
}
}| Field | Type | What it is |
|---|---|---|
| slug | string | URL-safe and unique among the site’s SEO Conquest articles. If the same article is sent again, its slug is the same: use it as the key you insert or update by. |
| title | string | The article title, for the page’s <h1> and <title>. |
| metaDescription | string or null | Plain text of up to about 160 characters, for <meta name="description">. |
| language | string | ro, en, fr or de, for <html lang>. |
| html | string | The article body, already sanitized: no <h1>, no hero image, no script, style, inline style, event handler, iframe or form. Print it as it is. |
| jsonLd | array | schema.org objects: an Article and, when the article has questions, an FAQPage. |
| heroImage | object or null | The hero image: url and alt. |
| publishedAt | string | ISO 8601 date and time, for the date you show and for article:published_time. |
Store the article, publish it at its address, then answer 200 (or 201) with that address as JSON. id is optional: your own id for the article, which SEO Conquest keeps for reference.
{
"url": "https://www.example.com/blog/how-to-choose-a-dental-implant",
"id": "184"
}urlmust be the absolute public address where the article can be read now. SEO Conquest links to it, checks it and shows it to the site owner.- Insert or update by
slug: when an article you already have arrives again, update it and answer with the same address. - Answer only once the article is stored. Any answer other than 2xx counts as a failed publish.
- If the slug is already taken by a page that did not come from SEO Conquest, publish the article at another address and answer with that one.
Show the article on your site
- Give each article a stable address on the site’s own domain, for example
/blog/<slug>, and keep it working. If you ever move an article, redirect the old address with a 301. - Print
titlein the page’s<h1>and<title>,metaDescriptionin<meta name="description">, andlanguagein<html lang>. Add<link rel="canonical">with the article’s own address. - Print
htmlinside your article layout, as it is. It usesh2,h3,p,ul,ol,li,strong,em,a,blockquote,table,figure,img,section, anddetailswithsummaryfor the questions. Style these with your own CSS. Headings carryidattributes for links within the page. - Show
heroImageabove the article, with itsalt. Downloading it while you handle the request and serving it from your own site is fastest; linking to itsurldirectly also works, the address stays valid. - Add the article to the site’s
sitemap.xmland to your blog index, so search engines and visitors find it.
Print each jsonLd item in its own script tag in the page head, encoded so its text can never close the tag early:
<?php foreach ($article['jsonLd'] as $item): ?>
<script type="application/ld+json"><?= json_encode($item, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?></script>
<?php endforeach; ?>const scripts = article.jsonLd
.map((item) => `<script type="application/ld+json">${JSON.stringify(item).replace(/</g, '\\u003c')}</script>`)
.join('\n')Reference code
Two complete endpoints that do everything above except the storage: replace save_article / saveArticle with your own. SEO Conquest tests both against its own requests, so you can start from either one as it is.
<?php
// seo-conquest.php: PHP 7.4 or newer, no dependencies.
// The secret comes from the server environment, never from this file:
// SEO_CONQUEST_SECRET=<the secret you created>
const TOLERANCE_SECONDS = 300;
function reply(int $status, array $body): void
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body);
exit;
}
function is_signed_by_seo_conquest(string $rawBody): bool
{
$secret = (string) getenv('SEO_CONQUEST_SECRET');
$timestamp = $_SERVER['HTTP_X_SEO_CONQUEST_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SEO_CONQUEST_SIGNATURE'] ?? '';
if ($secret === '' || $signature === '' || !preg_match('/^\d+$/', $timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
// Store the article and return its public URL. Replace this with your own
// storage: insert the article, or update the one that has the same slug.
function save_article(array $article): string
{
// For example: INSERT ... ON DUPLICATE KEY UPDATE, keyed on $article['slug'].
return 'https://www.example.com/blog/' . rawurlencode($article['slug']);
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
reply(405, ['error' => 'method not allowed']);
}
$rawBody = (string) file_get_contents('php://input');
if (!is_signed_by_seo_conquest($rawBody)) {
reply(401, ['error' => 'invalid signature']);
}
$event = json_decode($rawBody, true);
$type = is_array($event) ? ($event['event'] ?? '') : '';
if ($type === 'ping') {
reply(200, ['challenge' => $event['challenge'] ?? '']);
}
if ($type === 'article.published') {
try {
reply(200, ['url' => save_article($event['article'])]);
} catch (Throwable $e) {
error_log('SEO Conquest article not saved: ' . $e->getMessage());
reply(500, ['error' => 'could not save the article']);
}
}
reply(400, ['error' => 'unknown event']);
// seo-conquest-endpoint.mjs: Node.js 18 or newer, no dependencies.
// Run: SEO_CONQUEST_SECRET=<the secret you created> PORT=3000 node seo-conquest-endpoint.mjs
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.SEO_CONQUEST_SECRET ?? ''
const TOLERANCE_SECONDS = 300
function isSignedBySeoConquest(headers, rawBody) {
const timestamp = headers['x-seo-conquest-timestamp']
const signature = headers['x-seo-conquest-signature']
if (!SECRET || typeof signature !== 'string' || !/^\d+$/.test(timestamp ?? '')) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = 'v1=' + createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex')
const given = Buffer.from(signature)
const wanted = Buffer.from(expected)
return given.length === wanted.length && timingSafeEqual(given, wanted)
}
// Store the article and return its public URL. Replace this with your own
// storage: insert the article, or update the one that has the same slug.
async function saveArticle(article) {
// For example: await db.articles.upsert({ where: { slug: article.slug }, ... })
return `https://www.example.com/blog/${encodeURIComponent(article.slug)}`
}
function reply(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
const server = createServer(async (req, res) => {
if (req.method !== 'POST') return reply(res, 405, { error: 'method not allowed' })
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const rawBody = Buffer.concat(chunks).toString('utf8')
if (!isSignedBySeoConquest(req.headers, rawBody)) return reply(res, 401, { error: 'invalid signature' })
const event = JSON.parse(rawBody)
if (event.event === 'ping') return reply(res, 200, { challenge: event.challenge })
if (event.event === 'article.published') {
try {
return reply(res, 200, { url: await saveArticle(event.article) })
} catch (error) {
console.error('SEO Conquest article not saved', error)
return reply(res, 500, { error: 'could not save the article' })
}
}
return reply(res, 400, { error: 'unknown event' })
})
server.listen(Number(process.env.PORT ?? 3000), () => {
console.log(`SEO Conquest endpoint listening on port ${server.address().port}`)
})
Using a framework? Keep the same steps in your route: read the raw body first (in Express, express.raw({ type: "application/json" }); in a Next.js route handler, await request.text()), verify, then parse.
Lead tracking (optional)
SEO Conquest can count the calls, WhatsApp messages, emails and form submits the site gets from real visitors. It needs one tag before </body> on every page:
<script src="https://seo-conquest.com/c.js" data-key="YOUR_SITE_KEY"></script>- The key is different for every site. The site owner finds the ready tag in SEO Conquest, under Site settings, Integrations, Custom website, once they pick the JS snippet as the lead channel.
- It records a click on a
tel:, WhatsApp ormailto:link, and a form submit. It sends only the kind of event, the page address without its query string, and the referrer: no form contents, no cookies, nothing that identifies the visitor. - If the site has a Content Security Policy, allow
https://seo-conquest.cominscript-srcand inconnect-src.
Connect the site
- Put the endpoint live on its public https address.
- Send the site owner the endpoint address and the secret, the secret through a channel you trust. If you have access to the SEO Conquest account, you can enter them yourself under Site settings, Integrations, Custom website.
- When they click Connect, SEO Conquest sends three test requests: one signed correctly, which must get the challenge back; one with a forged signature and one signed an hour ago, which must both be refused, for example with 401. Nothing is saved unless all three go as expected.
- From then on, every article SEO Conquest publishes for the site arrives as
article.published.
When something fails
| What the site owner sees | What happened | What to fix |
|---|---|---|
| Your website refused our signed test request | The correctly signed test got 401 or 403. | The secret entered in SEO Conquest is not the one on your server, or the signature is computed over parsed JSON instead of the raw body. |
| Your website answered our test request, but not as the developer guide describes | The answer was not 2xx JSON with the same challenge. | Answer ping with status 200, Content-Type: application/json and the challenge you received. |
| Your website accepted a test request with a forged signature | A request with a wrong signature got a 2xx answer. | Verify the signature before anything else and answer 401 when it does not match. |
| Your website accepted a correctly signed request that was an hour old | An old, correctly signed request got a 2xx answer. | Refuse requests whose timestamp is more than 5 minutes away from your clock. |
| We could not reach your website’s SEO Conquest endpoint | No answer: address, DNS, certificate, time limit or a redirect. | A public https address with a valid certificate, answering directly within the time limits in the endpoint. |
| That site or store could not be found | The test got 404, 405 or 410. | The address is wrong, or the route does not accept POST. |
| Your website did not accept the article | The article got an answer other than 2xx. | Check your endpoint’s logs; answer 2xx only once the article is stored. |
| Your website received the article but did not reply with its address | A 2xx answer without a valid absolute url. | Answer {"url": "https://…"} with the address the article lives at. |
SEO Conquest does not retry by itself. When a publish fails, the article is marked as failed in SEO Conquest; when the site owner approves articles before they go out, it stays waiting for approval and can be sent again. That is why your endpoint should store the article and answer within the time limit, and update rather than duplicate when the same slug arrives again.
Security notes
- The secret stays on your server. To change it, create a new one, put it on your server, then enter it in SEO Conquest with Update connection: the three test requests run again before it is saved.
- Compare signatures in constant time, check the timestamp, and only then parse the body.
- The
htmlis sanitized by SEO Conquest before it is sent. If your own policy asks for it, you can run it through your sanitizer too, allowing the elements listed in show the article on your site. - SEO Conquest only calls public https addresses, never follows a redirect, and sends one request per article plus three test requests whenever the connection is saved.