50% off your first month with the code LAUNCH50 Monthly plans only. Start now

Start free trial

Pull your articles.
Publish them your way.

Every article we write for you can be read over HTTP: the title, the metadata, the Markdown, the HTML, the hero image and the FAQ. Take them into your Next.js, Astro or custom site when your build runs, put them live, and tell us the address each one ended up at.

One bearer token. JSON in, JSON out. No SDK to install and nothing to configure beyond a key.

The webhook pushes. The API lets you pull.

They carry the same article in the same shape, so a parser written for one reads the other unchanged. The difference is who starts the conversation.

The webhook, when your site can accept a post at any hour

We call your endpoint the moment an article is ready, and again when it changes. Nothing to poll, nothing to schedule. WordPress, Shopify, Ghost, a Zapier trigger, or a route you wrote yourself.

The API, when your site is built and deployed as a unit

A static site cannot accept a post at half past six in the morning; something has to rebuild first. So your build asks what is waiting, takes it, and tells us the address afterwards. You choose when.

Both, if you want them

They are separate connections and neither excludes the other. A webhook that feeds a newsletter and an API that feeds the website is a normal arrangement.

One key, sent as a bearer token.

Make a key in the dashboard under Settings, then Connections. It is shown once, when it is made, because only its hash is stored: if you lose it, revoke it and make another. A key reads every site on the account and is revoked from the same screen.

Every request

Authorization: Bearer sns_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

Check that it works

curl https://seonis.ai/api/v1/me \
  -H "Authorization: Bearer $SEONIS_API_KEY"
{
  "organisation": { "id": 12, "name": "Peppervale", "country": "GB" },
  "key": {
    "name": "Production build",
    "prefix": "sns_live_ab",
    "created_at": "2026-09-06T09:12:44+00:00",
    "last_used_at": "2026-09-06T09:31:02+00:00"
  },
  "sites": 2
}

Rate limit

120 requests a minute, counted against the key rather than the address it comes from, because build runners share addresses. Over that you get 429 with the standard Retry-After and X-RateLimit headers. Pulling articles for a build is a handful of requests, so this is not a limit you should meet by accident.

Keep the key out of your repository

It reads everything we have written for your account, including articles that are not published yet. Put it in your build environment, not in your source. Revoke it here the moment it is anywhere it should not be, and anything using it gets a 401 immediately.

Five of them, and four only read.

Everything lives under /api/v1/. The version is in the path from the first day, so a v2 can exist one day without breaking what you write today.

GET /api/v1/me

The account the key belongs to, and the key’s own name and prefix. Useful for one thing above all: telling you that the key in this environment is the key you think it is.

Example

curl https://seonis.ai/api/v1/me \
  -H "Authorization: Bearer $SEONIS_API_KEY"
GET /api/v1/sites

Every site on the account. The id is what the article list filters on, and the domain is there so a build script can match on something it already knows rather than carry an id.

Example

curl https://seonis.ai/api/v1/sites \
  -H "Authorization: Bearer $SEONIS_API_KEY"
{
  "sites": [
    {
      "id": 5,
      "name": "Peppervale",
      "domain": "peppervale.co.uk",
      "url": "https://peppervale.co.uk",
      "language": "en",
      "country": "GB",
      "market": "en-GB",
      "status": "active",
      "articles_published": 34,
      "created_at": "2026-06-02T11:04:19+00:00"
    }
  ]
}
GET /api/v1/articles

A page of articles, newest change first, without the writing. Ordered by when each article last changed rather than when it was written, which is what makes updated_since worth having: an article that is already live can be edited later, when a link is inserted into it or taken out again.

Query parameters

Name What it does
site_id One site, from /sites. An id that is not on your account answers 404.
status One of draft, qa, needs_repair, review, ready, published, failed. Anything else is a 422 rather than an empty page. "ready" is what a build wants: written, checked, and not yet anywhere.
updated_since ISO 8601, such as 2026-09-06T00:00:00Z. Remember the timestamp of your last run and pass it back on the next one.
per_page Up to 100. The default is 25.
page From 1. meta.has_more says whether to ask for another.

Example

curl -G https://seonis.ai/api/v1/articles \
  -H "Authorization: Bearer $SEONIS_API_KEY" \
  -d site_id=5 \
  -d status=ready \
  -d updated_since=2026-09-01T00:00:00Z \
  -d per_page=50
{
  "articles": [
    {
      "id": 918,
      "site_id": 5,
      "language": "en",
      "status": "ready",
      "title": "How to choose a pepper grinder",
      "slug": "how-to-choose-a-pepper-grinder",
      "meta_title": "How to choose a pepper grinder | Peppervale",
      "meta_description": "Burr or blade, ceramic or steel: what matters in a grinder.",
      "meta_keywords": ["pepper grinder", "burr grinder"],
      "tags": ["Kitchen"],
      "excerpt": "A burr grinder gives an even grind; a blade one does not.",
      "key_takeaways": ["A burr grinder gives an even grind; a blade one does not."],
      "faq": [{ "question": "Ceramic or steel?", "answer": "Steel for pepper, ceramic for salt." }],
      "hero_image": { "url": "https://seonis.ai/storage/images/5/918.jpg", "alt": "A pepper grinder" },
      "infographic_url": null,
      "word_count": 1812,
      "published_url": null,
      "published_at": null,
      "updated_at": "2026-09-06T06:31:12+00:00",
      "created_at": "2026-09-05T02:14:55+00:00",
      "quality": { "seo_score": 88, "language_score": 96, "language_findings": [] }
    }
  ],
  "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1, "has_more": false }
}
GET /api/v1/articles/{id}

One article, with everything the list leaves out: content_markdown, content_html, the FAQ as schema.org JSON-LD ready to drop into the page, and any exchange links the article carries.

The field names are the ones our webhook sends, on purpose. If you already have a webhook receiver, the same parser reads this.

Example

curl https://seonis.ai/api/v1/articles/918 \
  -H "Authorization: Bearer $SEONIS_API_KEY"
{
  "article": {
    "id": 918,
    "site_id": 5,
    "language": "en",
    "status": "ready",
    "title": "How to choose a pepper grinder",
    "slug": "how-to-choose-a-pepper-grinder",
    "meta_description": "Burr or blade, ceramic or steel: what matters in a grinder.",
    "content_markdown": "Grinders differ.\n\n## Burr or blade\n\nA **burr** grinder ...",
    "content_html": "<p>Grinders differ.</p>\n<h2>Burr or blade</h2> ...",
    "faq_schema": { "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [] },
    "hero_image": { "url": "https://seonis.ai/storage/images/5/918.jpg", "alt": "A pepper grinder" },
    "word_count": 1812,
    "published_url": null,
    "updated_at": "2026-09-06T06:31:12+00:00",
    "quality": { "seo_score": 88, "language_score": 96, "language_findings": [] },
    "exchange_links": []
  }
}
POST /api/v1/articles/{id}/published

You put the article live; this is where you tell us the address. It is the one endpoint that changes anything.

It does exactly what a delivery we made ourselves does: the article is marked published, your monthly allowance counts it, the plan item closes, any exchange links in it get the address the verifier has been waiting for, and your Facebook Page is told if you have connected one. Without this call the article stays ready for ever and none of that happens.

Example

curl -X POST https://seonis.ai/api/v1/articles/918/published \
  -H "Authorization: Bearer $SEONIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://peppervale.co.uk/blog/how-to-choose-a-pepper-grinder"}'

Answers

200 Recorded. The article comes back with its new status, published_at and published_url.
422 The url is missing or is not a full http:// or https:// address.
409 The article is not ready to be published, or is already recorded as published. The article comes back with the refusal, so you can see which.

Connect "REST API" as your publishing engine.

Under Settings, then Publishing, there is an engine called "REST API: you fetch and publish". It asks for nothing, because there is nothing for us to send. What it changes is what happens each morning: a finished article stays ready with no address, and the publishing screen says it is waiting for you to fetch it rather than showing a delivery that failed.

You can use the API without connecting it: the endpoints work for any account with a key. Connecting it is how the rest of the product knows to stop expecting an address of its own, and it is what stops an article you publish yourself being counted as a failure.

Every failure is JSON with a message.

One shape throughout, whether the refusal came from the key check or from the validator, so nothing has to parse two.

{ "message": "That API key is not valid, or it has been revoked." }
Status What it means
401 No key, a key that is not ours, or a key that has been revoked.
404 No such article or site on this account. Another account’s article answers 404 rather than 403: an API that says "that is not yours" has confirmed the thing exists.
409 The request was fine but the article is not in a state for it.
422 A parameter is missing or wrong. The reply carries an errors object naming the field, as well as the message.
429 Over the rate limit. Retry-After says how long to wait.

What a build step actually does.

#!/usr/bin/env bash
set -euo pipefail

# 1. What is finished and not yet on the site?
ready=$(curl -sG https://seonis.ai/api/v1/articles \
  -H "Authorization: Bearer $SEONIS_API_KEY" \
  -d site_id=5 -d status=ready -d per_page=100)

for id in $(echo "$ready" | jq -r '.articles[].id'); do
  # 2. Take the article, body and all.
  curl -s https://seonis.ai/api/v1/articles/$id \
    -H "Authorization: Bearer $SEONIS_API_KEY" \
    | jq -r '.article.content_markdown' > "content/posts/$id.md"

  # 3. Your build puts it live, and you know where it landed.
  slug=$(echo "$ready" | jq -r ".articles[] | select(.id==$id) | .slug")

  curl -s -X POST https://seonis.ai/api/v1/articles/$id/published \
    -H "Authorization: Bearer $SEONIS_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"url\": \"https://peppervale.co.uk/blog/$slug\"}"
done

Check that a delivery came from us.

Every webhook delivery in our own format carries three headers from the Standard Webhooks specification. Check them with the signing key from the Publishing settings, and refuse anything that does not match or is more than five minutes old.

webhook-id: 7c1e2f0a-3b5d-4e8f-9a61-2d4c8b0e5f13
webhook-timestamp: 1790490600
webhook-signature: v1,s0RzLJ1d7Cxd9nwe8pqER9DWy/stEIB+uT9MlGVLlM0=
Header What it is
webhook-id The delivery, the same value as delivery_id in the body. Remember the ones you have handled, and answer a repeat with 200 without doing the work again.
webhook-timestamp When we signed it, in whole seconds since 1970. Refuse anything more than five minutes from your own clock, so an old delivery cannot be sent to you again.
webhook-signature v1, then the base64 HMAC-SHA256 of the id, the timestamp and the raw body joined with full stops, made with your signing key. There can be more than one, separated by spaces. Accept the delivery if any of them matches.

The signing key

It starts with whsec_. Take that off and base64-decode the rest: those bytes are the HMAC key, not the text you copied. It is shown once, when you connect the webhook or press "New signing key" on the Publishing page, and we never send it.

Check the raw body

Work out the signature from the bytes exactly as they arrived, before anything parses them. JSON that has been decoded and encoded again is not the same bytes, and its signature will not match.

PHP

<?php
$raw = getenv('SEONIS_SIGNING_KEY'); // whsec_...

// No key, no deliveries. A missing or empty variable would otherwise become
// an empty HMAC key, and anybody can sign with that.
$secret = is_string($raw) && str_starts_with($raw, 'whsec_')
    ? base64_decode(substr($raw, strlen('whsec_')), true)
    : false;

if ($secret === false || strlen($secret) < 24) {
    http_response_code(500);
    exit('Signing key not configured.');
}

$id        = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$header    = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$body      = file_get_contents('php://input');

if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit('Too old.');
}

$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $secret, true));

$valid = false;
foreach (explode(' ', $header) as $signature) {
    [$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
    $valid = $valid || ($version === 'v1' && hash_equals($expected, $value));
}

if (! $valid) {
    http_response_code(401);
    exit('Bad signature.');
}

$delivery = json_decode($body, true);

Node.js

import crypto from 'node:crypto';
import express from 'express';

const app = express();

// No key, no server. An empty variable would otherwise become an empty HMAC
// key, and anybody can sign with that.
const raw = process.env.SEONIS_SIGNING_KEY ?? '';
if (!raw.startsWith('whsec_')) throw new Error('SEONIS_SIGNING_KEY is not set.');
const key = Buffer.from(raw.slice('whsec_'.length), 'base64');
if (key.length < 24) throw new Error('SEONIS_SIGNING_KEY is not a valid key.');

// express.raw keeps the body as the bytes we signed.
app.post('/hooks/seonis', express.raw({ type: 'application/json' }), (req, res) => {
  const id = req.get('webhook-id') ?? '';
  const timestamp = req.get('webhook-timestamp') ?? '';
  const header = req.get('webhook-signature') ?? '';

  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(401).send('Too old.');
  }

  const expected = crypto.createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(req.body)
    .digest();

  const valid = header.split(' ').some((signature) => {
    const [version, value = ''] = signature.split(',');
    const given = Buffer.from(value, 'base64');
    return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });

  if (!valid) return res.status(401).send('Bad signature.');

  const delivery = JSON.parse(req.body);
  res.json({ ok: true });
});

Or use a library

The official Standard Webhooks libraries, for PHP, JavaScript, Python, Go, Ruby and more, do all of this in one call. Give them the signing key as it was shown to you, whsec_ included. github.com/standard-webhooks

The older headers

Deliveries also carry the signing secret as a bearer token, and X-AutoSEO-LT-Signature, an HMAC of the body alone made with that secret. They are still sent so that endpoints built on them keep working. A new endpoint should check webhook-signature instead: the old signature covers no time and no id, and its key travels in every request.

The AutoSEO, Outrank and BabyLoveGrowth formats send exactly what those services sent, so they do not carry these headers.

Make a key and try it.

The trial is enough to read the API: the articles written during it are real articles, and they come back through these endpoints like any others.

Start free trial