SEO Conquest
How it worksFeaturesPricing
Sign in
Start free trialFree trial
Sign in
Menu
How it worksFeaturesPricingSign inStart free trial
Language

On this page

  1. 1What you are building
  2. 2Create the signing secret
  3. 3The endpoint
  4. 4Verify the signature
  5. 5The test request
  6. 6The article
  7. 7Show the article on your site
  8. 8Reference code
  9. 9Lead tracking (optional)
  10. 10Connect the site
  11. 11When something fails
  12. 12Security notes

Connect a custom website to SEO Conquest

Developer guide, protocol version 1. For whoever maintains a website built without a CMS: what to add so SEO Conquest can publish articles on it.

1What 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.

2Create the signing secret

Create a random secret of at least 32 characters, for example with:

Shell
openssl rand -hex 32

Keep 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.

3The 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:

HeaderExampleMeaning
X-SEO-Conquest-Eventarticle.publishedping or article.published.
X-SEO-Conquest-Timestamp1793522400The time of sending, in Unix seconds. It is part of what is signed.
X-SEO-Conquest-Signaturev1=3b4f…The signature to check, see the signature.
X-SEO-Conquest-Delivery5f0c2a1e-…A unique id per request, for your logs. Not signed.
Content-Typeapplication/jsonThe body is JSON, UTF-8.
User-AgentSEO-Conquest-Webhook/1.0 (+https://seo-conquest.com/developers/custom-website)Useful for a firewall rule or a log filter.

4Verify the signature

Before you do anything with a request, check that it comes from SEO Conquest:

  1. Read the raw request body, before any JSON parsing. Parsing and encoding the JSON again changes the bytes and breaks the signature.
  2. Refuse the request when X-SEO-Conquest-Timestamp is more than 5 minutes away from your server’s clock. This stops an old request from being sent again.
  3. Compute HMAC-SHA256 of the text <timestamp>.<raw body> with your secret, as lowercase hex, and put v1= in front of it.
  4. Compare the result with X-SEO-Conquest-Signature in constant time: hash_equals in PHP, crypto.timingSafeEqual in Node.js.
  5. When anything does not match, answer 401 and stop.
Pseudocode
signed   = timestamp + "." + rawBody
expected = "v1=" + hex(hmac_sha256(secret, signed))
valid    = constantTimeEquals(expected, header["X-SEO-Conquest-Signature"])
           and abs(now - timestamp) <= 300 seconds

5The test request

The test request has the event ping and a random challenge:

JSON
{
  "event": "ping",
  "version": 1,
  "sentAt": "2026-11-02T08:30:00.000Z",
  "challenge": "9f86d081884c7d659a2feaa0c55ad015"
}

Answer with status 200 and the same challenge, as JSON:

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:

Shell
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"}

6The article

Each article arrives as article.published:

JSON
{
  "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"
  }
}
FieldTypeWhat it is
slugstringURL-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.
titlestringThe article title, for the page’s <h1> and <title>.
metaDescriptionstring or nullPlain text of up to about 160 characters, for <meta name="description">.
languagestringro, en, fr or de, for <html lang>.
htmlstringThe article body, already sanitized: no <h1>, no hero image, no script, style, inline style, event handler, iframe or form. Print it as it is.
jsonLdarrayschema.org objects: an Article and, when the article has questions, an FAQPage.
heroImageobject or nullThe hero image: url and alt.
publishedAtstringISO 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.

JSON
{
  "url": "https://www.example.com/blog/how-to-choose-a-dental-implant",
  "id": "184"
}
  • url must 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.

7Show 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 title in the page’s <h1> and <title>, metaDescription in <meta name="description">, and language in <html lang>. Add <link rel="canonical"> with the article’s own address.
  • Print html inside your article layout, as it is. It uses h2, h3, p, ul, ol, li, strong, em, a, blockquote, table, figure, img, section, and details with summary for the questions. Style these with your own CSS. Headings carry id attributes for links within the page.
  • Show heroImage above the article, with its alt. Downloading it while you handle the request and serving it from your own site is fastest; linking to its url directly also works, the address stays valid.
  • Add the article to the site’s sitemap.xml and 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
<?php foreach ($article['jsonLd'] as $item): ?>
<script type="application/ld+json"><?= json_encode($item, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?></script>
<?php endforeach; ?>
JavaScript
const scripts = article.jsonLd
  .map((item) => `<script type="application/ld+json">${JSON.stringify(item).replace(/</g, '\\u003c')}</script>`)
  .join('\n')

8Reference 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
<?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']);
Node.js
// 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.

9Lead 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:

HTML
<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 or mailto: 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.com in script-src and in connect-src.

10Connect the site

  1. Put the endpoint live on its public https address.
  2. 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.
  3. 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.
  4. From then on, every article SEO Conquest publishes for the site arrives as article.published.

11When something fails

What the site owner seesWhat happenedWhat to fix
Your website refused our signed test requestThe 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 describesThe 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 signatureA 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 oldAn 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 endpointNo 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 foundThe test got 404, 405 or 410.The address is wrong, or the route does not accept POST.
Your website did not accept the articleThe 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 addressA 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.

12Security 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 html is 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.
SEO Conquest

Operated by FLOWMATIC S.R.L.
CUI: 52891900 · Trade Registry: J2025087179005 · VAT registered
Str. Vasile Alecsandri, bl. 66A, sc. A, parter, ap. 1, Pitești, Argeș 110377, Romania

Support: office@flowmatic.ro

Links
  • Pricing
  • Terms of Service
  • Privacy Policy
  • Data Processing Agreement
  • Cookie policy
  • Legal notice
Consumer protection
  • Withdraw from a contract
  • Cancel a subscription
  • ANPC (Romania)
  • EU consumer protection

© 2026 FLOWMATIC S.R.L. All rights reserved.

Cookie settings

Necessary storage

Always active

These cookies support sign-in, security, language and the website you selected. They cannot be switched off here.

Advertising measurement

If you allow it, Meta Pixel measures page views and the signup, checkout and trial steps. Direct browser requests let Meta receive technical data such as your IP address and browser user agent.

Your choice does not subscribe you to marketing messages. You can change it here at any time.

We remember this choice for up to 180 days.

Read the cookie policy