OROVA.VN — BIZ AI AGENT

/ Developers guide

Publishing via API on any website.

How to build your own endpoint so Orova can publish finished articles to a site that does not run WordPress.

Orova publishes finished articles to WordPress out of the box. If your website is not WordPress — a custom CMS, a headless setup, a static site, or your own backend — you can still let Orova publish automatically by accepting articles over a small API. You write one HTTP endpoint; Orova calls it every time it finishes an article. This guide is for a developer and describes the full request contract.

/ Overview

How it works

When Orova finishes writing an article for a keyword, it sends a single HTTP POST request to the endpoint URL you registered. The request body is JSON and contains the whole article. Your endpoint creates the post on your side and replies with the final, public URL of the article. Orova saves that URL as the published link, just as it would for a WordPress post. Use this when you want hands-off publishing on a site that is not WordPress, or when you prefer to keep full control of how posts are stored.

/ Setup

Setting it up — six steps

  • 1

    Turn on “Connect via API” in your project. Open your project (Projects → your project → Connections). Next to the WordPress card there is a Connect via API card. Enter the URL of your endpoint there. The secret key field is optional: leave it blank and Orova generates one, or paste a key your site already uses. Once saved, the key is shown on the card with a Copy button. A project publishes to one target only — connecting via API locks the WordPress button, and the other way round.

  • 2

    Build an endpoint that accepts POST. On your own site or server, create a route that listens for HTTP POST with a JSON body. This is the URL you paste back into the project Connections screen. Orova calls it once for every article it finishes.

  • 3

    Verify the Bearer token on every request. Every request from Orova carries an Authorization header in the form “Bearer <secret>”, using the exact secret key shown in your project. Compare it against your stored copy and reject anything that does not match — this is what stops anyone else from posting to your site.

  • 4

    Create the article from the payload. Read the JSON body and create a post in your CMS or database: use title, slug and content_html for the article itself, excerpt as the meta description, featured_image_url as the cover image, and keyword / lang / published_at as metadata.

  • 5

    Return the live URL of the article. Respond with HTTP 200 or 201 and a JSON body of { "url": "https://yoursite.com/the-new-article" }. Orova stores that URL as the published link for the article. If you return a non-2xx status, or omit the url field, Orova treats the publish as failed.

  • 6

    Handle update requests from the Optimize engine. When the body carries action = "update", find the existing post by target_url (or slug) and overwrite its title, content_html and excerpt in place — keep the URL unchanged. Respond 200 with { "url": ... } pointing at the same post. Endpoints that do not handle this yet will simply make Orova report the optimization as failed; new-article publishing keeps working as before.

/ Contract

The request Orova sends

Each finished article arrives as one request shaped like this:

POST <your endpoint URL>

Headers:
  Content-Type: application/json
  Authorization: Bearer <secret>     # the secret key shown in Project -> Connections
  User-Agent: Orova-SEO

Body (JSON):
  {
    "title": "...",                  # article headline
    "slug": "...",                   # URL-friendly identifier
    "content_html": "...",           # full article HTML
    "excerpt": "...",                # meta description
    "featured_image_url": "..." | null,
    "keyword": "...",                # target keyword
    "lang": "en",                    # ISO language code
    "published_at": "2026-05-17T09:00:00.482913",  # ISO 8601, UTC, no offset suffix
    "status": "draft"                # optional: "draft" keeps it unpublished
  }
  # Optional fields are left out of the body when empty — never sent as null.

/ Reference

Payload fields

Fields of a publish (new article) request. Delete and update requests reuse the same endpoint with the smaller bodies shown in their own sections below.

FieldTypeDescription
titlestringThe article headline.
slugstringURL-friendly identifier suggested for the post.
content_htmlstringThe full article body as ready-to-publish HTML.
excerptstringA short summary, intended for the meta description.
featured_image_urlstring | nullURL of the cover image, or null when there is none.
keywordstringThe target SEO keyword the article was written for.
langstringLanguage as an ISO code, e.g. "en" or "vi".
published_atstringThe moment Orova sent the article, ISO 8601 in UTC, with no timezone suffix.
statusstring | undefinedOptional. "draft" keeps the article unpublished; "publish", or no value at all, publishes it.
actionstring | undefinedAbsent for new articles. "delete" asks you to remove a post; "update" (sent by the Optimize engine) asks you to overwrite an existing post in place.
target_urlstringOnly with action = "update": the live URL of the post to overwrite. Keep the URL unchanged.
idnumber | undefinedOnly sent when Orova knows your post id (from `list` or `get`): match on it before `target_url`.

Optional fields are left out of the body entirely when there is nothing to send — Orova never sends null, so read them defensively. status is optional too: "draft" creates the article as a draft, while "publish" or no status at all publishes it right away.

/ Contract

The response you must return

Once you have created the post, reply with an HTTP 200 or 201 status and this JSON body:

HTTP 200 (or 201)
Content-Type: application/json

{
  "url": "https://yoursite.com/published-article"
}

Orova stores that url as the published link for the article. If your endpoint returns any non-2xx status, or a body that is not a JSON object with a url field, Orova treats the publish as failed and marks the article accordingly so you can retry it. Reply within 60 seconds — Orova times the request out after that (this applies to publish, update and delete alike).

/ Example

Code example

A minimal receiving endpoint in Node.js with Express. The same steps — verify the token, branch on action (update / delete), create or change the post, return the URL — apply in any language or framework.

// Node.js / Express — a minimal receiving endpoint
import express from "express";

const app = express();
app.use(express.json({ limit: "5mb" }));

// The secret Orova generated for this project.
const OROVA_SECRET = process.env.OROVA_SECRET;

app.post("/orova/publish", async (req, res) => {
  // 1. Verify the Bearer token.
  const auth = req.get("authorization") || "";
  if (auth !== "Bearer " + OROVA_SECRET) {
    return res.status(401).json({ error: "unauthorized" });
  }

  // 2. Visual Editor: list your articles. action = "list".
  if (req.body.action === "list") {
    const posts = await listPosts(req.body.limit || 500); // newest first, 500 max
    return res.status(200).json({ ok: true, posts });
  }

  // 3. Visual Editor: read one article in full. action = "get".
  if (req.body.action === "get") {
    const post = await findPost(req.body.id, req.body.target_url, req.body.slug);
    if (!post) return res.status(404).json({ ok: false, err: "not_found" });
    return res.status(200).json({ ok: true, post });
  }

  // 4. Visual Editor: store an image, answer with its absolute URL.
  if (req.body.action === "upload_image") {
    const { filename, mime, data_base64 } = req.body;
    if (!String(mime || "").startsWith("image/")) {
      return res.status(422).json({ error: "image files only" });
    }
    const bytes = Buffer.from(data_base64, "base64");
    if (bytes.length > 15 * 1024 * 1024) {
      return res.status(413).json({ error: "image too large" });
    }
    return res.status(200).json({ ok: true, url: await saveImage(filename, bytes) });
  }

  // 5. Delete requests: body carries action = "delete".
  if (req.body.action === "delete") {
    const removed = await deletePostByUrl(req.body.url, req.body.slug);
    if (!removed) return res.status(404).json({ deleted: true }); // already gone
    return res.status(200).json({ deleted: true });
  }

  // 6. Update requests: action = "update". Optional fields may be absent.
  if (req.body.action === "update") {
    const updated = await updatePost(req.body.id, req.body.target_url, {
      title: req.body.title,
      html: req.body.content_html,
      metaDescription: req.body.excerpt,
      coverImage: req.body.featured_image_url,  // optional
      status: req.body.status,                  // optional: "publish" | "draft"
    });
    if (!updated) return res.status(404).json({ error: "post not found" });
    return res.status(200).json({ url: updated.url });
  }

  // 7. New article: read the payload Orova sent.
  const {
    title, slug, content_html, excerpt,
    featured_image_url, keyword, lang, published_at, status,
  } = req.body;

  // 8. Create the article in your own CMS or database.
  const post = await createPost({
    title,
    slug,
    html: content_html,
    metaDescription: excerpt,
    coverImage: featured_image_url,
    keyword,
    lang,
    publishedAt: published_at,
    draft: status === "draft",   // status is optional; absent means publish now
  });

  // 9. Return 200/201 with the live URL.
  return res.status(201).json({
    url: "https://yoursite.com/" + post.slug,
  });
});

app.listen(3000);

/ Contract — update

Updating articles (Optimize)

Orova's Optimize engine rewrites articles that are already live — refreshed facts, better titles, merged content — and then overwrites the existing post in place, keeping the URL unchanged. The request goes to the same endpoint URL, as a POST with the same Bearer token:

POST <your endpoint URL>

Headers:
  Content-Type: application/json
  Authorization: Bearer <secret>
  User-Agent: Orova-SEO

Body (JSON):
  {
    "action": "update",          # always the literal string "update"
    "id": 123,                   # optional: post id, match on this first
    "target_url": "...",         # public URL of the post to overwrite (match next)
    "title": "...",              # new headline
    "slug": "...",               # slug fallback for matching, may be empty
    "content_html": "...",       # full new article body as HTML
    "excerpt": "...",            # new meta description
    "featured_image_url": "...", # optional: new cover image
    "status": "publish"          # optional: "publish" or "draft"
  }
  # Optional fields are left out of the body when empty — never sent as null.

Find the post by target_url (fall back to slug), replace its title, body and meta description, keep the URL as it is, then respond 200 with { "url": "<same post URL>" }. Any non-2xx status, or a body without url, makes Orova mark that optimization as failed (it never creates a duplicate post). Endpoints that have not implemented this yet keep publishing new articles unchanged — implementing update is only needed once you use the Optimize screen.

Since 29 August the update body can also carry id (match on it before target_url), featured_image_url for a new cover image, and status holding "publish" or "draft". All three are optional; when empty they are dropped from the body, never sent as null.

/ Contract — Visual Editor

Reading articles (Visual Editor)

The Visual Editor opens articles that already live on your site, so it has to read them first. Two more actions on the same endpoint URL, with the same Bearer token: list returns your articles, get returns one of them in full.

POST <your endpoint URL>     # same endpoint, same Bearer secret

Body (JSON):
  {
    "action": "list",            # always the literal string "list"
    "limit": 500                 # newest article first, 500 maximum
  }

Response — HTTP 200:
  {
    "ok": true,
    "posts": [
      {
        "id": 123,               # your own post id, reused by "get" and "update"
        "title": "...",
        "slug": "...",
        "link": "https://yoursite.com/the-article",
        "date": "2026-08-29",    # published date, YYYY-MM-DD
        "modified": "2026-08-29",
        "status": "publish",     # "publish" or "draft"
        "author": "..."
      }
    ]
  }
POST <your endpoint URL>

Body (JSON):
  {
    "action": "get",             # always the literal string "get"
    "id": 123,                   # match on this first
    "target_url": "...",         # then the public URL
    "slug": "..."                # then the slug
  }

Response — HTTP 200:
  {
    "ok": true,
    "post": {
      "id": 123,
      "title": "...",
      "slug": "...",
      "link": "https://yoursite.com/the-article",
      "content_html": "...",     # full article body as HTML
      "excerpt": "...",
      "featured_image_url": "...",
      "status": "publish"        # "publish" or "draft"
    }
  }

No such post — HTTP 404:
  { "ok": false, "err": "not_found" }

limit is capped at 500 and the newest article comes first. For get, Orova sends id, target_url and slug together — match on whichever you recognise, in that order. When nothing matches, answer 404 with { "ok": false, "err": "not_found" }. You only need these two actions if you want to edit site articles in the Visual Editor — automatic publishing and optimization work without them.

/ Contract — Visual Editor

Uploading images (Visual Editor)

When you drop an image into the Visual Editor, Orova sends it to the same endpoint URL as base64 inside the JSON body — no multipart, no second URL to secure.

POST <your endpoint URL>

Body (JSON):
  {
    "action": "upload_image",    # always the literal string "upload_image"
    "filename": "cover.png",     # original file name, extension included
    "mime": "image/png",         # image/* only
    "data_base64": "iVBORw0KGgo..."   # file bytes, base64, 15 MB maximum
  }

Response — HTTP 200 (or 201):
  {
    "ok": true,
    "url": "https://yoursite.com/uploads/cover.png"   # absolute URL
  }

Accept image/* only and reject anything above 15 MB. Store the file, then answer with the absolute URL of the image: a relative path breaks the article as soon as it renders somewhere else. This action is only needed for the Visual Editor.

/ Contract — delete

Deleting articles

When a user removes a published article inside Orova (from the Write or Reports screen), Orova asks your site to delete it too. The request goes to the same endpoint URL, as a POST with the same Bearer token — the only difference is the body:

POST <your endpoint URL>

Headers:
  Content-Type: application/json
  Authorization: Bearer <secret>
  User-Agent: Orova-SEO

Body (JSON):
  {
    "action": "delete",          # always the literal string "delete"
    "url": "...",                # public URL of the article to remove (match on this first)
    "slug": "...",               # slug fallback, may be empty
    "keyword": "..."             # the article's target keyword, for your logs
  }

Look the post up by url (fall back to slug), remove or unpublish it, then respond with 200 and { "deleted": true } — a bare 204 also works. If the post does not exist anymore, reply 404 and Orova treats it as already deleted. Returning { "deleted": false } tells Orova the post was not removed. Regular publish requests never contain an action field, so existing endpoints keep working unchanged — implementing delete is optional but recommended.

/ Security

Security notes

  • Always verify the Bearer token. Your endpoint is a public URL — anyone could call it. The Authorization header is the only thing that proves a request really came from Orova, so reject every request whose token does not exactly match your stored secret.
  • Serve the endpoint over HTTPS. The secret travels in a header. HTTPS keeps it from being read in transit.
  • Keep the secret out of your code. Store it in an environment variable or a secrets manager, never hard-coded in a file you commit. If it is ever exposed, generate a new one from Project → Connections.
  • Treat content_html as content, not as trusted markup. Store and render it the same careful way you would any article body in your CMS.

/ Wrapping up

That is the whole contract

With the endpoint live and registered in your project, Orova publishes to your site automatically — exactly as it does for WordPress users. Each article it finishes lands on your server, becomes a post, and its public URL flows back into Reports and Analysis so you can track how it performs.

← Back to the guide library

/ Need a hand?

Stuck wiring up your endpoint? Open the Support section inside your workspace, or send us a message.