Custom API integration

Connect any site to Seonix

Expose a single REST endpoint on your site and Seonix will publish generated articles straight to it — with updates, media uploads, and optional deletes out of the box. Works with any stack: Node, Python, PHP, Go, Rails, or a static site builder.

How it works

Seonix is the client. Your site exposes the endpoint. You generate, edit, and score articles inside Seonix, then click Publish — Seonix POSTs the article to your endpoint with a Bearer token. Re-publishes reuse the same external_id, so you never end up with duplicates.

1. Implement the endpoint

Accept a JSON POST, validate the Bearer token against your own secret, and upsert the article keyed on external_id. A few lines in any framework is enough — see the client samples below.

2. Connect the channel in Seonix

In your project's Channels, add a Custom API channel with the endpoint URL and the token. Seonix verifies the connection on save, then pushes each article and stores the id you return.

3. Updates and deletes stay in sync

Every re-publish is the same POST with the same external_id — upsert on your side. If you configure a delete URL template, deleting an article in Seonix issues a DELETE using the id your endpoint returned.

Set up the connection

Two fields are required to connect — the endpoint URL and the token. Add the media upload URL as well: it is what keeps your images on your own host instead of pointing published pages at Seonix (see Media). You can paste and save all of them in the Seonix Channels tab without leaving the app.

  1. 1

    Pick a shared secret

    Generate a long random string (32+ characters). It's the Bearer token Seonix sends in every request. Store it on your server; never hardcode it in client code.

  2. 2

    Implement POST /your-endpoint

    Validate the Authorization header against your secret, upsert the article keyed on external_id, and return { "id": "…", "url": "…" }. Reply 4xx/5xx with a JSON error body on failure.

  3. 3

    Implement a media upload endpoint

    Accept a multipart POST with a `file` field and return { "url": "…" }. Required for production — this is what puts images on your own host instead of leaving pages pointing at Seonix. Hash the bytes and store content-addressed, returning the URL you already have for bytes you have already seen: the same image is re-sent across edits and re-publishes, and content-addressing is what stops duplicates.

  4. 4

    (Optional) Implement DELETE /your-endpoint/:id

    If you want articles removed from your site when deleted in Seonix, handle DELETE with the same Bearer token. Return 200 on success; 404 is treated as already-gone.

  5. 5

    Add the channel in Seonix

    Open the project's Channels tab → Custom API → Connect. Paste the endpoint URL and token, plus (optionally) the delete URL template, media upload URL, default locale, and author. Seonix verifies the connection on save.

  6. 6

    Publish from the editor

    Open any article, click Publish, pick your Custom API channel in the picker, and hit Publish now. Seonix stores the returned id so every subsequent update and delete targets the same record.

Channel config

The Custom API channel stores your endpoint URL, the bearer token, and the optional extras below. Secrets stay inside your Seonix project and are only read to build outbound requests. The `headers` map is an advanced option set via the channel API — the dashboard form covers the rest.

json
{
  "api_url":             "https://your-site.example/api/articles",
  "api_token":           "YOUR_SHARED_SECRET",
  "delete_url_template": "https://your-site.example/api/articles/{id}",
  "media_upload_url":    "https://your-site.example/api/media",
  "lang":                "en",
  "author":              "Your team",
  "headers":             { "X-Custom": "advanced, via channel API" }
}

All config fields are stored inside your Seonix project, readable only by the Seonix backend, and never leave the engine. Edit the config any time through the same Channels → Custom API → Manage modal.

Authentication

Pick any bearer-style token — it's a shared secret between your site and Seonix. Seonix sends it in the Authorization header of every request: publishes, media uploads, deletes, and verification probes. Rotate it by updating the channel config; no restart required on either side.

  • Authorization:Bearer <your shared secret>
  • X-Seonix-Contract: 1

Compare the token in constant time (hash_equals / timingSafeEqual / hmac.compare_digest — see the samples below) and keep it server-side only. X-Seonix-Contract is the contract version: it only changes on breaking changes; new optional fields don't bump it.

Connection verification

When you save the channel, Seonix sends a probe so misconfigured URLs and tokens surface immediately instead of on the first publish. The probe is a plain POST with an empty JSON object and an X-Seonix-Verify: 1 header — don't create a record for it; rejecting the empty payload with a 400 is the expected answer.

POST/your-endpoint

Connection probe: empty JSON + X-Seonix-Verify: 1.

http
POST https://your-site.example/api/articles
Authorization: Bearer YOUR_SHARED_SECRET
X-Seonix-Verify: 1
X-Seonix-Contract: 1
Content-Type: application/json

{}

# How Seonix reads your answer:
#   401 / 403            -> token rejected, channel save fails
#   404                  -> endpoint not found, channel save fails
#   non-JSON content     -> wrong URL (a marketing page?), save fails
#   2xx or 4xx with JSON -> connection verified

Publish an article

Seonix sends a JSON POST to your configured URL for both creates and updates. external_id is the stable article id inside Seonix (a UUID) and never changes across re-publishes — upsert on it, and re-submitting the same id updates the existing record instead of creating a duplicate.

POST/your-endpoint

Create or update (upsert).

http
POST https://your-site.example/api/articles
Authorization: Bearer YOUR_SHARED_SECRET
X-Seonix-Contract: 1
Content-Type: application/json

{
  "external_id":         "9b2f6e0a-4b1d-4c3a-9a56-170b63f6d2c1",
  "translation_key":     "c1d2e3f4-…",
  "slug":                "how-to-automate-seo-content",
  "lang":                "en",
  "title":               "How to automate SEO content in 2026",
  "excerpt":             "Short summary shown on list pages and meta tags.",
  "content_html":        "<h2 id=\"intro\">Intro</h2><p>Body copy…</p>",
  "category":            "Automation",
  "key_takeaways":       ["Point one", "Point two"],
  "key_takeaways_title": "Key takeaways",
  "author":              "Your team",
  "cover_url":           "https://your-site.example/media/abc.webp",
  "cover_alt":           "Dashboard with rising organic traffic",
  "og_image":            "https://your-site.example/media/abc.webp",
  "seo_description":     "Short meta description.",
  "published_at":        "2026-04-16T09:00:00Z",
  "schema_jsonld":       "{\"@context\":\"https://schema.org\",\"@graph\":[…]}"
}
http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id":  "42",
  "url": "https://your-site.example/blog/how-to-automate-seo-content"
}

Updates are the same POST with the same external_id. Optional fields that are empty in Seonix are omitted from the payload — keep your stored values for them.

http
# Updates reuse the same POST with the same external_id —
# your endpoint should upsert on it. Optional fields that are
# empty in Seonix are omitted from the payload.

POST https://your-site.example/api/articles
Authorization: Bearer YOUR_SHARED_SECRET

{
  "external_id":  "9b2f6e0a-4b1d-4c3a-9a56-170b63f6d2c1",
  "slug":         "how-to-automate-seo-content",
  "lang":         "en",
  "title":        "How to automate SEO content in 2026 (refined)",
  "excerpt":      "Updated summary.",
  "content_html": "<p>Updated body…</p>"
}

Media: host every image yourself

Every image must end up on your own infrastructure. Seonix is not a CDN — a published page must never be left pointing at a Seonix URL. Configure a media upload URL and Seonix pushes each image to you the moment it is added in the editor: multipart/form-data, a `file` field, the same Bearer token. Return the URL you assigned (root-relative is fine — it is resolved against your endpoint's origin), and by publish time every img src and the cover already point at your host. Deduplicate by content: hash the bytes, store the file under that hash, and return the URL you already have when the hash is known — Seonix names files by content hash and re-sends the same image across edits, refinements and re-publishes, so content-addressing is what keeps one copy instead of a new record per publish. Without a media upload URL, content_html and cover_url carry temporary Seonix delivery URLs: download them and re-host them on your side before rendering. They are a handover mechanism, not a hosting guarantee.

POST/your-media-endpoint

multipart/form-data, `file` field. Same Bearer token.

http
POST https://your-site.example/api/media
Authorization: Bearer YOUR_SHARED_SECRET
Content-Type: multipart/form-data; boundary=…

--…
Content-Disposition: form-data; name="file"; filename="cover.webp"
Content-Type: image/webp

<binary image bytes>
--…--

# Expected response — absolute or root-relative URL:
HTTP/1.1 200 OK
Content-Type: application/json

{ "url": "https://your-site.example/media/ab12cd.webp" }

Accept images (jpeg/png/webp/gif) of at least up to 12 MB. Deduplicate by content: SHA-256 the bytes, store the file under that hash, and return the URL you already have when the hash is known. Seonix re-sends the same image across edits, refinements and re-publishes — content-addressing is what keeps one copy in your media library instead of a new record per publish.

Delete an article

Configure a delete_url_template in the channel — Seonix substitutes {id} with the id your endpoint returned at publish time (or the article's external_id if you didn't return one) and issues a DELETE with the same Bearer token. Skip the template to ignore deletes; 404 responses are treated as already-gone.

DELETE/your-endpoint/:id

Delete an article. Uses the same Bearer token.

http
# {id} in delete_url_template is substituted with the id your
# endpoint returned at publish time ("42" in the example above);
# if you returned none, the article's external_id is used instead.
DELETE https://your-site.example/api/articles/42
Authorization: Bearer YOUR_SHARED_SECRET

Payload fields

This is the exact shape Seonix sends — fields marked optional are omitted when empty. Map them to your own schema server-side and ignore anything you don't need. Treat content_html as untrusted input and sanitize it with your own allowlist before rendering.

FieldTypeDescription
external_idstringStable article UUID inside Seonix — the upsert key. Always present, never changes across re-publishes.
translation_keystring?Shared key across language versions of the same article. Sent when the article has translations — use it to cross-link locales.
slugstringLowercase, hyphen-separated (^[a-z0-9]+(?:-[a-z0-9]+)*$) — Seonix sanitizes it before sending. Same slug across locales keeps URLs aligned.
langstringISO-639-1 code of the article's language: "en", "ua", "de", "fr", … Seonix supports 50+ languages, so don't validate against a fixed pair. Ukrainian is normalized uk → ua.
titlestringArticle title. Default value for <h1> and <title>.
excerptstringShort summary for list pages and the meta description fallback. Always present but may be an empty string — keep a fallback of your own.
content_htmlstringHTML body (h2/h3 headings, paragraphs, lists, tables, images, code). Treat as untrusted and sanitize on your side — but keep <img>, <figure>, <figcaption> and the src, alt, width, height and loading attributes in your allowlist, or the images disappear and the page shifts on load (CLS).
categorystring?Freeform string — the article's category as named in the Seonix project. No fixed vocabulary; map it onto your own taxonomy.
key_takeawaysstring[]?Bullet list for a summary block at the top of the article page. 4-6 self-contained sentences ordered by usefulness (the first one answers the search query directly). See the note and markup below the table.
key_takeaways_titlestring?Heading of the key-takeaways block in the article's language — render it with the list so you don't need a translation of your own.
authorstringByline. Defaults to 'Seonix' or the channel-config value.
cover_urlstring?Absolute URL of the 16/9 cover image. With a media upload URL configured it already points at your host. Without one it is a temporary Seonix delivery URL — download the file and re-host it on your side; don't render it directly.
cover_altstring?Cover alt text — use it for <img alt> and og:image:alt.
og_imagestring?Open Graph image. Currently equals cover_url.
seo_descriptionstring?The article's meta description.
published_atISO 8601?First-publish date (RFC 3339). Stays the same on updates — use it as the canonical timestamp and don't rejuvenate the article.
schema_jsonldstring?Serialized schema.org @graph for the article (Article, WebPage, BreadcrumbList, FAQPage, HowTo, …), generated from the content. Render it into <script type="application/ld+json"> — see the note below the table.

About key_takeaways: render the block as a plain heading + list ABOVE the article body — that exact shape is what search engines lift into list snippets and what AI assistants quote verbatim. Use key_takeaways_title as the heading verbatim (it already arrives in the article's language); when it is empty, render the list without a heading. Items are plain text with no HTML: escape them and keep the order (it is ranked by usefulness, not by position in the text). When the array is missing or empty, hide the block entirely. No duplication to worry about: whenever key_takeaways is present, content_html carries no takeaways section of its own — Seonix strips it at publish time, the structured field is the single source.

html
<!-- Recommended markup — a plain heading + list ABOVE the body. -->
<!-- This exact shape is what search engines lift into list snippets -->
<!-- and what AI assistants (ChatGPT, Perplexity, AI Overviews) quote. -->

<section class="key-takeaways">
  <h2>{key_takeaways_title}</h2>   <!-- already in the article's language -->
  <ul>
    <li>{key_takeaways[0]}</li>    <!-- plain text: HTML-escape each item -->
    <li>{key_takeaways[1]}</li>
    …
  </ul>
</section>

{content_html}

About schema_jsonld: the @graph is generated before the final page URL exists, so URL-dependent nodes (Article, WebPage, BreadcrumbList) are computed from the project domain. If your articles live under a different path (e.g. /blog/…), render only the supplemental types — FAQPage / HowTo — from the graph next to your own Article and BreadcrumbList, rewriting their @id to the real page URL. That's exactly what the Seonix blog does. Don't duplicate Article across two graphs: search engines ignore competing graphs.

Expected response

Reply with JSON carrying the record id and the public URL of the published page. Seonix persists that id and reuses it for the delete path; updates keep flowing to the same external_id regardless. { "external_id": … } and { "data": { … } } wrappers are also accepted; anything else in the response is ignored.

json
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id":  "42",
  "url": "https://your-site.example/blog/how-to-automate-seo-content"
}

Errors and retries

Seonix waits up to 30 seconds per request and treats your response as follows. Failed publishes are retried up to 3 times (after 15 minutes, 45 minutes, then 2 hours) before the publication is marked failed and the operator is notified. A JSON error body with a short human-readable message is shown to the operator as-is, so make it helpful.

Your responseHow Seonix treats it
2xx + JSONSuccess. id and url are read; everything else is ignored.
404Record not found. On a re-publish Seonix starts a fresh publication; for DELETE it means already-gone.
other 4xx / 5xx / timeoutPublish failure: up to 3 retries after 15 min → 45 min → 2 h, then failed + operator notification. The JSON body's message is shown to the operator.
non-JSON responseConfiguration error — api_url likely points at a regular page, not an API. Channel verification catches this too.

Error body format: { "error": { "code": "VALIDATION", "message": "…" } }. An empty or freeform body is fine too — the operator just sees the bare HTTP status.

Limits & guarantees

What your endpoint can rely on, and what it should be ready for.

  • Request timeout is 30 seconds; respond fast and defer slow work to the background.
  • Article bodies are typically tens to hundreds of KB; accept JSON of at least 5 MB to be safe.
  • Make the upsert atomic and idempotent: retries and out-of-order deliveries are possible, so the same payload may arrive twice.
  • Treat (lang, slug) as unique on your side; resolve collisions with stale records in favour of the fresh external_id.
  • Transport security is HTTPS + a static Bearer token. There is no body signature, so keep the secret long and rotate it via the channel config.
  • The contract is versioned via the X-Seonix-Contract header (currently 1): new optional fields may appear without a version bump — ignore fields you don't recognize.

Client samples

Minimal receivers you can drop into any backend. They validate the bearer token in constant time, upsert the article, and return the response Seonix expects.

bash
# Minimal receiver test — simulate what Seonix sends you
curl -X POST "$YOUR_ENDPOINT" \
  -H "Authorization: Bearer $YOUR_SHARED_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id":  "9b2f6e0a-4b1d-4c3a-9a56-170b63f6d2c1",
    "slug":         "how-to-automate-seo-content",
    "lang":         "en",
    "title":        "How to automate SEO content in 2026",
    "excerpt":      "Short summary.",
    "content_html": "<p>Body…</p>",
    "category":     "Automation",
    "published_at": "2026-04-16T09:00:00Z"
  }'

Ready to ship?

Create a Seonix project, add your blog channel, and publish the first article in under ten minutes. The same token unlocks the write, media, and delete paths.

Start free