OXIRANKER DEVELOPER DOCS

OxiRanker Documentation

A clean documentation center for connecting your website to OxiRanker services. Start with WordPress integration or API integration, then follow the guide step by step.

Official documentationFull-feature V1For site owners, developers and integration teams
Documentation menu
Choose a guide from the menu. This page is the main documentation center for OxiRanker.
API service

API service

Use Export API and Webhook for custom websites, custom CMS platforms, Next.js, Laravel, Node.js, PHP or Python backends.

Domain
Verified
Content
Ready
API
Webhook
Important storage notice

OxiRanker is not permanent hosting for the destination website

OxiRanker does not provide a permanent hosting guarantee for the destination website's content, images or exported files. OxiRanker is not a user-content hosting service, and generated content, cover images, Open Graph images or output files may not remain on OxiRanker infrastructure for more than 90 days.

Therefore, after receiving the output, the destination website must store the final article, images, SEO data, Open Graph data and Schema data in its own database, server, storage or CDN, and must not rely on OxiRanker URLs for public display.

Training video

Connection training video

Use this video as a visual walkthrough before connecting WordPress or implementing the API integration.

API Output

Implement the output flow step by step

Store the Export Token and Webhook Secret in your backend first, then complete the Webhook receiver, database, UPSERT logic, Image Mirroring and daily sync.

STEP 01

Register and create a domain in OxiRanker

First, register in OxiRanker, sign in to your account and click Add Domain from the Domains section.

Correct domain format

In the Domain name field, enter only the root domain. The address must not include http, https or www.

example.com

Wrong formats

Do not enter these formats in the domain field.

https://example.com
http://example.com
www.example.com
After creating the domain
If the domain format is valid, OxiRanker creates the domain and takes you to the domain ownership verification step.
STEP 02

Verify domain ownership

In this step, you must prove that the domain belongs to you or that you have access to manage it.

DNS TXT

Recommended

Add a TXT record to the domain DNS. For Cloudflare, this is usually the simplest method.

DNS Name:
_yourdomain-verification.example.com

TXT Value:
acb1****29803

HTTP File

Place a text file in the .well-known path of your website so OxiRanker can verify it.

File URL:
https://example.com/.well-known/yourdomain-verification.txt

File Content:
220daaa37****42730f27

Meta Tag

Place a meta tag inside the head section of your website homepage.

<meta name="yourdomain-verification" content="190208d****d275b65" />
Important DNS note
After adding the DNS record, especially in Cloudflare, you may need to wait 2 to 3 minutes. Then click Confirm on the same OxiRanker page.
Verification token lifetime
Domain verification values have a time limit. Currently, verification values are usually valid for 1440 minutes.
STEP 03

Receive free tokens after verifying the first domain

After verifying the first domain, OxiRanker adds 1000 free tokens to your account for initial testing.

Only for the first domain
This gift is only added for the first verified domain and is not added again for later domains.
STEP 04

Configure languages and Blog URL

In the Blog URLs section, define whether your website is single-language or multilingual and which languages should be used for generated articles.

Choose languages

OxiRanker supports 24 languages. You can choose one or multiple languages based on your website structure.

Enter Blog URL

For each language, enter the blog URL for that language. If your website is multilingual, enter each language path separately.

https://example.com/fa/blog
https://example.com/en/blog
URL structure
If your website has a different structure, enter the real blog URL of your website. The important point is that the final URL must be the path where articles should be published.
STEP 05

Add keywords for each language

In the Keywords tab, add at least 3 and up to 50 keywords for each language.

Main rule

Keywords for each language must be entered in the same language. If the article language is English, the keywords must also be English.

How to add keywords

After typing each keyword, press Enter to add it. After completing all keywords, click Save.

Common mistake
If you enter Turkish keywords for the English language, Turkish phrases may appear in English content. The keyword language must match the article language.
STEP 06

Complete the domain Article Profile

Article Profile tells OxiRanker what your brand is, who it writes for, what tone it should use and which topics it should avoid.

Industry

Defines the market or business category. Example: real estate consulting and property buying and selling in Dubai.

Business description

Explains what the business sells, who it serves, how it works and what makes it different.

Audience

Defines who the content should speak to. Each audience can be saved separately.

Tone

Controls the style and voice of generated content, such as formal, educational, simple, technical or sales-oriented.

Content goal

Defines what the articles should achieve, such as attracting customers, increasing traffic or educating users.

Forbidden topics

Anything the brand should not talk about or should not be associated with is entered here.

Optional CTA

CTA means the action you want the reader to take after reading the article. If you are not sure what text is suitable, it is better to leave this field empty.

Multilingual profile
If your website is multilingual, you only need to complete the profile in one language. OxiRanker can prepare the required versions for other languages based on the same profile.
STEP 07

Internal and external linking

OxiRanker can intelligently apply internal and external links inside articles.

Internal links

This option is enabled by default and it is better to keep it enabled. Internal linking helps search engines better understand the content structure of your website.

In the first article, internal links are usually not created because there is no previous article yet. From the second article onward, the system can perform internal linking.

External links

External links are added to official and trusted sources and are marked as nofollow so SEO authority is not unintentionally transferred.

STEP 08

Check profile quality with AI

After completing the Article Profile, OxiRanker reviews the profile quality.

85+
Minimum score required to save
95
Excellent score commonly reached after improvements
AI
Professional suggestions for each field
If the score is below 85
The system does not allow final saving and shows explanations and improvement suggestions for each problematic field. You can apply the suggestions and save again.
STEP 09

Connect Telegram for article summaries

After successfully saving the profile, you can enable sending article summaries to a Telegram channel.

1

Add the bot

Add @Oxiranker_Bot to your Telegram channel and make it an Admin.

2

Enter the channel username

Enter the channel name with @. Example: @my_channel

3

Enable summary delivery

After registering a valid channel, enable article summary delivery.

Telegram benefit
If enabled, after content generation, the article summary is sent to the Telegram channel with the image and link. This can strengthen traffic paths to your website.
STEP 10

Configure the automatic article engine

In the Automatic article generation tab, you can manage the automatic article generation engine.

Smart topic selection

The system uses keywords and Article Profile to select suitable, unique and publishable topics.

Brand-aware generation

Tone, audience, content goal and brand restrictions are respected during article generation.

Scheduled publishing

Monthly capacity is distributed throughout the month so content publishing looks more natural and consistent.

Monthly capacity

7 articles / month
10 articles / month
15 articles / month
30 articles / month

30 articles means almost one article per day. 15 articles means almost every other day. 10 articles means about every two to three days. 7 articles means about every three to four days.

Article length and image

Article length can be Short, Standard or Long. Standard is a balanced choice for most websites.

Article images can be enabled or disabled, and the image theme can be Light or Dark.

Note for API websites
The automatic engine generates articles inside OxiRanker. To move each article to your custom website after generation, you must implement Export API, Webhook, database storage and Image Mirroring.
STEP 11

Create the first article from Generate

After preparing the domain, open the Generate menu, choose the domain and click New Content or New Article.

1

Language

Only one language is selected for each content request.

2

Topic

Write a clear topic. Source URL is optional.

3

Image

Enable or disable the image and choose Light or Dark theme.

4

Payment and generation

The cost is deducted from the wallet and article generation starts.

Generation time
Article and image generation usually takes about 2 to 3 minutes. After completion, the article is visible inside OxiRanker.
Source URL
If Source URL is entered, OxiRanker uses it to better understand the topic, but it does not generate copied content. The system is designed to create unique content.
STEP 12

Review Preview, SEO Report and JSON Output

After the article is generated, several important outputs are available for quality review and publishing preparation.

Preview

Shows the article text and generated image. This section is useful for reviewing final quality before publishing.

SEO Report

Shows an understandable SEO report including SEO Score, title, meta description, canonical, keywords, schema, headings, links, images and OpenGraph.

JSON Output

Shows the full article output for technical users, including slug, html_body, schema, image URLs, tags, category, reading time and created/updated timestamps.

Meaning of the outputs
These outputs show that the article is not just plain text. It is structured, SEO-ready content prepared for WordPress, CMS, API and publishing systems.
STEP 13

What exactly do Export API and Webhook do?

After an article is generated in OxiRanker, the destination website can read it with Export API or be notified through Webhook when the article is ready.

Method 1: Export API

The destination website can read articles from OxiRanker with the Export Token whenever needed. This method is used for manual sync, daily sync, article list pages and fetching the full article JSON.

GET https://oxiranker.com/api/v1/users/domains/1/export/articles?limit=20&offset=0
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/json
GET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/json

Method 2: Webhook

When a new article is created or an article is updated, OxiRanker notifies the destination website endpoint. Webhook does not send the full article. It only sends the article identifier so the destination website can fetch the full article from the Export JSON API.

https://example.com/api/oxiranker/webhook
{
  "event": "article.created",
  "domain_id": "1",
  "article_id": "5",
  "request_id": "5",
  "lang": "fa",
  "slug": "smart-internal-links",
  "occurred_at": "2026-05-20T19:35:33.000Z"
}
Very important note
Webhook is only a lightweight message. The destination website must not expect the full article inside the Webhook. After receiving domain_id and article_id, it must fetch the complete article from the Export JSON API and store it in its own database.
STEP 14

Content storage responsibility and Image Mirroring

The destination website must store the final article and images on its own infrastructure.

What the destination website must store

Full article text in the destination website database
Main article image on the destination server, storage or CDN
Open Graph image on the destination server, storage or CDN
SEO, OpenGraph and Article Schema data
raw_detail_payload and raw_list_item to preserve the original output

Correct image path example

Using OxiRanker image URLs directly and permanently is not correct. The image must be downloaded and article URLs must be replaced with the destination website's internal URLs.

https://oxiranker.com/uploads/articles/3_5/cover.webp
https://example.com/uploads/site-articles/oxiranker/1/5/cover.webp
OxiRanker is not permanent hosting for the destination website
OxiRanker is not responsible for permanently storing output content and images for the destination website. OxiRanker is not a user-content hosting service, and generated content, cover images, Open Graph images or output files may not remain on OxiRanker infrastructure for more than 90 days. Therefore, the destination website must store the final article, images, SEO data, OpenGraph and Schema in its own database, server, storage or CDN.
STEP 15

Get Export Token and ready endpoints

Export Token and ready-to-use endpoints are available from the article page, Outputs tab and API Output section.

Where to get the Export Token

Create the first article in OxiRanker.
Open that article page.
Open the Outputs tab.
Open the API Output section.
Create a token from Token management.
The full token is shown only once.
Store the token in the destination website backend or .env.
https://oxiranker.com/en/pannel/generate/{domainId}/articles/{articleId}

Ready-to-use endpoints

On the same article page, inside the API Output section, Ready-to-use endpoints displays prepared URLs.

https://oxiranker.com/api/v1/users/domains/1/export/articles?limit=20&offset=0
https://oxiranker.com/api/v1/users/domains/1/articles/1/export/json
https://oxiranker.com/api/v1/users/domains/1/articles/1/export/html?canonical_mode=absolute&include_css=0
Rotating the Token
If the Export Token is rotated in the OxiRanker panel, the previous token expires. The new value must immediately replace the old value in the destination website .env or backend environment, and the destination backend must be restarted or redeployed.
STEP 16

Create Webhook Secret and destination endpoint

Webhook Secret is also created from the article page, Outputs tab and API Output section.

Where is Webhook Secret created?

Open the article page.
Open the Outputs tab.
Open the API Output section.
Create a secret in the Webhook Secret section.
The full secret is shown only once.
Store the secret in the destination website .env.

Fixed destination endpoint

OxiRanker only accepts the following fixed path for the destination Webhook. The destination website must create this endpoint in its backend.

POST /api/oxiranker/webhook

https://example.com/api/oxiranker/webhook

This endpoint must only accept POST, preserve the raw body, verify timestamp and signature, and return a successful response for webhook.test.

Active events
In the current version, events are fixed: article.created and article.updated. For testing the connection, the event value webhook.test is sent.
STEP 17

Required .env values for the destination website

OxiRanker does not have access to the destination website files or .env. The destination website developer must store these values in their own backend.

OXIRANKER_EXPORT_API_BASE_URL=https://oxiranker.com
OXIRANKER_EXPORT_API_TOKEN=YOUR_EXPORT_TOKEN
OXIRANKER_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET
OXIRANKER_WEBHOOK_MAX_SKEW_MS=300000
OXIRANKER_SOURCE_PROVIDER=oxiranker
OXIRANKER_SOURCE_DOMAIN_IDS=1

SITE_ARTICLE_IMAGE_UPLOAD_DIR=/var/www/example.com/uploads/site-articles
SITE_ARTICLE_IMAGE_MAX_BYTES=10485760

PUBLIC_BASE_URL=https://example.com

OXIRANKER_SYNC_ENABLED=true
OXIRANKER_SYNC_HOUR=3
OXIRANKER_SYNC_MINUTE=10
OXIRANKER_SYNC_PAGE_LIMIT=20
OXIRANKER_SYNC_MAX_PAGES=50

Security values

OXIRANKER_EXPORT_API_TOKEN
The Export API token must only be stored in the backend.
OXIRANKER_WEBHOOK_SECRET
The Webhook Secret must only be stored in the backend.
OXIRANKER_WEBHOOK_MAX_SKEW_MS
The maximum allowed time difference for Webhook. 300000 means 5 minutes.

Storage values

SITE_ARTICLE_IMAGE_UPLOAD_DIR
The physical path for storing mirrored images on the destination server.
SITE_ARTICLE_IMAGE_MAX_BYTES
The maximum allowed image size for download and storage.
PUBLIC_BASE_URL
The public destination website URL used to create final image and article URLs.
Frontend security
Export Token and Webhook Secret must not be placed inside frontend code, user-visible JavaScript, localStorage or the window object.
STEP 18

Export API: List, JSON, HTML and ZIP

The destination website can use Export API to receive the article list or the full output of each article.

Get the list of articles for a domain

GET https://oxiranker.com/api/v1/users/domains/:domainId/export/articles?limit=20&offset=0
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/json

limit defines the number of articles and is currently limited between 1 and 50. offset is used for pagination. lang is optional and is used to fetch articles for a specific language such as fa, en or tr.

Get the full article as JSON

GET https://oxiranker.com/api/v1/users/domains/:domainId/articles/:articleId/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/json

This is the most important output for the destination website. The destination website must store the full article JSON in its own database and read from its own database for public display.

Get ready HTML

GET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/html?canonical_mode=absolute&include_css=0
Authorization: Bearer YOUR_EXPORT_TOKEN

HTML is suitable for websites that want ready output, but the more professional approach is to store JSON and render it with the destination website's own template.

Get ZIP output

GET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/zip
Authorization: Bearer YOUR_EXPORT_TOKEN

The ZIP output includes article.json, meta.json, assets.json, schema.jsonld, index.html, version.txt and README.txt.

STEP 19

Webhook Signature and secure Webhook receiving

The destination website must verify the Webhook signature using the original raw body.

Webhook headers

content-type: application/json
user-agent: Oxiranker-Webhooks/1.0
x-oxiranker-event: article.created
x-oxiranker-domain-id: 1
x-oxiranker-timestamp: 2026-05-20T19:35:33.000Z
x-oxiranker-signature: sha256=...

Signature formula

payload_to_sign = timestamp + "." + rawBody
signature = "sha256=" + HMAC_SHA256(webhook_secret, payload_to_sign)

If the destination website parses JSON first and then stringifies it again, order or spacing may change and the signature may become invalid. The original raw body must be used for signature verification.

Timing-safe comparison
Do not compare signatures with expected === received. In Node.js, it is better to use crypto.timingSafeEqual.
STEP 20

Simple Node.js code sample for receiving Webhook

This Express sample handles raw body, timestamp, signature, webhook.test and article events.

import express from "express";
import crypto from "crypto";

const app = express();

app.use(
  express.json({
    verify: (req, _res, buf) => {
      (req as any).rawBody = Buffer.from(buf);
    },
  })
);

function getHeader(req: express.Request, name: string): string {
  const value = req.headers[name.toLowerCase()];
  if (Array.isArray(value)) return String(value[0] || "").trim();
  return String(value || "").trim();
}

function getRawBody(req: express.Request): string {
  const raw = (req as any).rawBody;
  if (Buffer.isBuffer(raw)) return raw.toString("utf8");
  return JSON.stringify(req.body || {});
}

function createSignature(secret: string, timestamp: string, rawBody: string): string {
  const payload = `${timestamp}.${rawBody}`;
  const digest = crypto
    .createHmac("sha256", secret)
    .update(payload, "utf8")
    .digest("hex");

  return `sha256=${digest}`;
}

function timingSafeEqualText(a: string, b: string): boolean {
  const ab = Buffer.from(a, "utf8");
  const bb = Buffer.from(b, "utf8");

  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}

function isTimestampValid(timestamp: string): boolean {
  const maxSkewMs = Number(process.env.OXIRANKER_WEBHOOK_MAX_SKEW_MS || 300000);
  const date = new Date(timestamp);

  if (Number.isNaN(date.getTime())) return false;

  const diff = Math.abs(Date.now() - date.getTime());
  return diff <= maxSkewMs;
}

app.post("/api/oxiranker/webhook", async (req, res) => {
  const secret = String(process.env.OXIRANKER_WEBHOOK_SECRET || "").trim();

  if (!secret) {
    return res.status(500).json({
      error: "webhook_secret_missing",
      message: "Webhook secret is not configured.",
    });
  }

  const eventHeader = getHeader(req, "x-oxiranker-event");
  const domainIdHeader = getHeader(req, "x-oxiranker-domain-id");
  const timestamp = getHeader(req, "x-oxiranker-timestamp");
  const receivedSignature = getHeader(req, "x-oxiranker-signature");
  const rawBody = getRawBody(req);

  if (!eventHeader) {
    return res.status(400).json({
      error: "missing_webhook_event",
      message: "Webhook event header is required.",
    });
  }

  if (!domainIdHeader) {
    return res.status(400).json({
      error: "missing_webhook_domain_id",
      message: "Webhook domain id header is required.",
    });
  }

  if (!timestamp) {
    return res.status(400).json({
      error: "missing_webhook_timestamp",
      message: "Webhook timestamp header is required.",
    });
  }

  if (!receivedSignature) {
    return res.status(400).json({
      error: "missing_webhook_signature",
      message: "Webhook signature header is required.",
    });
  }

  if (!isTimestampValid(timestamp)) {
    return res.status(401).json({
      error: "invalid_webhook_timestamp",
      message: "Webhook timestamp is expired or invalid.",
    });
  }

  const expectedSignature = createSignature(secret, timestamp, rawBody);

  if (!timingSafeEqualText(expectedSignature, receivedSignature)) {
    return res.status(401).json({
      error: "invalid_webhook_signature",
      message: "Invalid webhook signature.",
    });
  }

  const payload = req.body || {};
  const event = String(payload.event || eventHeader || "").trim();

  if (event === "webhook.test") {
    const sourceDomainId = Number(payload.domain_id || domainIdHeader);

    return res.status(200).json({
      ok: true,
      message: "Webhook test received successfully.",
      event,
      source_domain_id:
        Number.isFinite(sourceDomainId) && sourceDomainId > 0 ? sourceDomainId : null,
      test: true,
    });
  }

  if (event !== "article.created" && event !== "article.updated") {
    return res.status(400).json({
      error: "unsupported_webhook_event",
      message: "Webhook event is not supported.",
    });
  }

  const sourceDomainId = Number(payload.domain_id || domainIdHeader);
  const sourceArticleId = Number(payload.article_id);

  if (!Number.isFinite(sourceDomainId) || sourceDomainId <= 0) {
    return res.status(400).json({
      error: "invalid_domain_id",
      message: "Webhook domain id is invalid.",
    });
  }

  if (!Number.isFinite(sourceArticleId) || sourceArticleId <= 0) {
    return res.status(400).json({
      error: "invalid_article_id",
      message: "Webhook article id is invalid.",
    });
  }

  // Fetch the full article from Export API and store it in the database here.
  // await syncArticleFromOxiranker(sourceDomainId, sourceArticleId);

  return res.status(200).json({
    ok: true,
    message: "Webhook processed successfully.",
    event,
    source_domain_id: sourceDomainId,
    source_article_id: sourceArticleId,
  });
});
STEP 21

Recommended destination website database

The destination website must store articles in its own database and prevent duplicates with a unique constraint.

CREATE TABLE public.site_articles (
  id BIGSERIAL PRIMARY KEY,

  source_provider VARCHAR NOT NULL DEFAULT 'oxiranker',
  source_domain_id BIGINT NOT NULL,
  source_article_id BIGINT NOT NULL,
  source_request_id BIGINT NULL,

  lang VARCHAR NOT NULL,
  lang_label VARCHAR NULL,
  dir VARCHAR NOT NULL,
  is_rtl BOOLEAN NULL,

  length VARCHAR NULL,
  topic_text TEXT NULL,

  slug VARCHAR NOT NULL,
  canonical_path VARCHAR NULL,
  effective_canonical_url TEXT NULL,

  title TEXT NOT NULL,
  meta_description TEXT NULL,
  html_body TEXT NULL,

  focus_keywords JSONB NOT NULL DEFAULT '[]'::jsonb,
  category VARCHAR NULL,
  tags JSONB NOT NULL DEFAULT '[]'::jsonb,

  reading_time_minutes INTEGER NULL,

  og_title TEXT NULL,
  og_description TEXT NULL,

  cover_image_url TEXT NULL,
  og_image_url TEXT NULL,

  article_schema JSONB NOT NULL DEFAULT '{}'::jsonb,

  raw_list_item JSONB NOT NULL DEFAULT '{}'::jsonb,
  raw_detail_payload JSONB NOT NULL DEFAULT '{}'::jsonb,

  source_created_at TIMESTAMPTZ NULL,
  source_updated_at TIMESTAMPTZ NULL,

  sync_status VARCHAR NOT NULL DEFAULT 'synced',
  last_synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_sync_error TEXT NULL,

  publish_status VARCHAR NOT NULL DEFAULT 'draft',
  published_at TIMESTAMPTZ NULL,
  deleted_at TIMESTAMPTZ NULL,

  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

  source_cover_image_url TEXT NULL,
  source_og_image_url TEXT NULL,

  image_mirror_status VARCHAR NOT NULL DEFAULT 'pending',
  image_mirror_error TEXT NULL,
  image_mirrored_at TIMESTAMPTZ NULL,

  CONSTRAINT site_articles_dir_check
    CHECK (dir IN ('ltr', 'rtl')),

  CONSTRAINT site_articles_reading_time_check
    CHECK (reading_time_minutes IS NULL OR reading_time_minutes >= 0),

  CONSTRAINT site_articles_sync_status_check
    CHECK (sync_status IN ('pending', 'syncing', 'synced', 'failed')),

  CONSTRAINT site_articles_publish_status_check
    CHECK (publish_status IN ('draft', 'published', 'archived')),

  CONSTRAINT site_articles_image_mirror_status_check
    CHECK (image_mirror_status IN ('pending', 'not_needed', 'mirrored', 'failed')),

  CONSTRAINT site_articles_source_unique
    UNIQUE (source_provider, source_domain_id, source_article_id)
);
CREATE INDEX site_articles_source_lookup_idx
ON public.site_articles (source_provider, source_domain_id, source_article_id);

CREATE INDEX site_articles_source_updated_at_idx
ON public.site_articles (source_updated_at DESC);

CREATE INDEX site_articles_slug_idx
ON public.site_articles (slug)
WHERE deleted_at IS NULL;

CREATE UNIQUE INDEX site_articles_lang_slug_active_unique
ON public.site_articles (lang, slug)
WHERE deleted_at IS NULL;

CREATE INDEX site_articles_lang_publish_idx
ON public.site_articles (lang, publish_status)
WHERE deleted_at IS NULL;

CREATE INDEX site_articles_published_at_idx
ON public.site_articles (published_at DESC)
WHERE deleted_at IS NULL;

CREATE INDEX site_articles_focus_keywords_gin_idx
ON public.site_articles USING gin (focus_keywords);

CREATE INDEX site_articles_tags_gin_idx
ON public.site_articles USING gin (tags);

CREATE INDEX site_articles_article_schema_gin_idx
ON public.site_articles USING gin (article_schema);
PostgreSQL note
In PostgreSQL, simple quotes are used for Persian or Unicode text. Something like N'...' is not needed and must not be used.
STEP 22

Prevent duplicates with UPSERT

The article must not be inserted again for every Webhook. If it already exists, it must be updated.

Main key for preventing duplicates
The most important constraint for preventing duplicates is: UNIQUE (source_provider, source_domain_id, source_article_id)
INSERT INTO public.site_articles (
  source_provider,
  source_domain_id,
  source_article_id,
  source_request_id,
  lang,
  dir,
  slug,
  title,
  meta_description,
  html_body,
  focus_keywords,
  category,
  tags,
  reading_time_minutes,
  cover_image_url,
  og_image_url,
  article_schema,
  source_created_at,
  source_updated_at,
  sync_status,
  last_synced_at,
  publish_status,
  published_at,
  created_at,
  updated_at
)
VALUES (
  'oxiranker',
  1,
  5,
  5,
  'fa',
  'rtl',
  'smart-internal-links',
  'Article title',
  'Meta description',
  '<p>HTML body</p>',
  '[]'::jsonb,
  'SEO',
  '[]'::jsonb,
  7,
  'https://example.com/uploads/site-articles/oxiranker/1/5/cover.webp',
  'https://example.com/uploads/site-articles/oxiranker/1/5/cover.webp',
  '{}'::jsonb,
  '2026-05-20T19:33:23.618Z',
  '2026-05-20T19:34:09.629Z',
  'synced',
  now(),
  'published',
  now(),
  now(),
  now()
)
ON CONFLICT (source_provider, source_domain_id, source_article_id)
DO UPDATE SET
  source_request_id = EXCLUDED.source_request_id,
  lang = EXCLUDED.lang,
  dir = EXCLUDED.dir,
  slug = EXCLUDED.slug,
  title = EXCLUDED.title,
  meta_description = EXCLUDED.meta_description,
  html_body = EXCLUDED.html_body,
  focus_keywords = EXCLUDED.focus_keywords,
  category = EXCLUDED.category,
  tags = EXCLUDED.tags,
  reading_time_minutes = EXCLUDED.reading_time_minutes,
  cover_image_url = EXCLUDED.cover_image_url,
  og_image_url = EXCLUDED.og_image_url,
  article_schema = EXCLUDED.article_schema,
  source_created_at = EXCLUDED.source_created_at,
  source_updated_at = EXCLUDED.source_updated_at,
  sync_status = 'synced',
  last_synced_at = now(),
  last_sync_error = NULL,
  publish_status = CASE
    WHEN public.site_articles.publish_status = 'archived'
    THEN public.site_articles.publish_status
    ELSE 'published'
  END,
  published_at = COALESCE(public.site_articles.published_at, EXCLUDED.published_at, now()),
  deleted_at = NULL,
  updated_at = now();
STEP 23

Fetch the full article and sync after Webhook

When a Webhook arrives, the destination website must fetch the full article from Export API, mirror the images and UPSERT the article.

async function fetchOxirankerArticleDetail(args: {
  baseUrl: string;
  token: string;
  domainId: number;
  articleId: number;
}) {
  const url = `${args.baseUrl.replace(/\/+$/, "")}/api/v1/users/domains/${args.domainId}/articles/${args.articleId}/export/json`;

  const response = await fetch(url, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${args.token}`,
      Accept: "application/json",
      "User-Agent": "My-Site-Oxiranker-Sync/1.0",
    },
  });

  const bodyText = await response.text();

  if (response.status < 200 || response.status > 299) {
    throw new Error(
      `Export API request failed with HTTP status ${response.status}. Response: ${bodyText.slice(0, 1000)}`
    );
  }

  try {
    return JSON.parse(bodyText);
  } catch {
    throw new Error("Export API returned invalid JSON.");
  }
}
async function syncArticleFromWebhook(payload: any) {
  const sourceDomainId = Number(payload.domain_id);
  const sourceArticleId = Number(payload.article_id);

  if (!Number.isFinite(sourceDomainId) || sourceDomainId <= 0) {
    throw new Error("Webhook domain id is invalid.");
  }

  if (!Number.isFinite(sourceArticleId) || sourceArticleId <= 0) {
    throw new Error("Webhook article id is invalid.");
  }

  const article = await fetchOxirankerArticleDetail({
    baseUrl: process.env.OXIRANKER_EXPORT_API_BASE_URL!,
    token: process.env.OXIRANKER_EXPORT_API_TOKEN!,
    domainId: sourceDomainId,
    articleId: sourceArticleId,
  });

  const mirroredImages = await mirrorArticleImages(article);

  const finalHtmlBody = replaceImageUrls(article.html_body, mirroredImages);
  const finalArticleSchema = replaceImageUrlsInJson(article.article_schema, mirroredImages);

  await upsertSiteArticle({
    source_provider: process.env.OXIRANKER_SOURCE_PROVIDER || "oxiranker",
    source_domain_id: sourceDomainId,
    source_article_id: sourceArticleId,
    source_request_id: article.request_id,
    lang: article.lang,
    dir: article.dir,
    slug: article.slug,
    title: article.title,
    meta_description: article.meta_description,
    html_body: finalHtmlBody,
    focus_keywords: article.focus_keywords,
    category: article.category,
    tags: article.tags,
    reading_time_minutes: article.reading_time_minutes,
    cover_image_url: mirroredImages.cover_image_url,
    og_image_url: mirroredImages.og_image_url,
    article_schema: finalArticleSchema,
    raw_detail_payload: article,
    source_created_at: article.created_at,
    source_updated_at: article.updated_at,
  });
}
STEP 24

Image Mirroring and image storage rules

The destination website must download images, store them on its own infrastructure and replace article URLs.

Image Mirroring rules

Only HTTPS URLs must be accepted.
The file must actually be an image.
content-type must start with image/.
Empty files must not be stored.
The file size must not exceed the limit.
If cover and og are the same, download only once.
After mirroring, replace URLs inside html_body and article_schema.

Recommended storage path

/uploads/site-articles/oxiranker/1/5/cover.webp
/uploads/site-articles/oxiranker/1/5/og.webp
https://example.com/uploads/site-articles/oxiranker/1/5/cover.webp
STEP 25

Daily sync for reliability

Webhook alone is not enough. The destination website should also have a daily sync to recover missed articles.

Why is daily sync needed?

The destination website may be temporarily down.
Webhook may timeout.
Deployment or network issues may happen.
An article may be generated but its Webhook may be missed.

Daily sync algorithm

Start offset from 0.
Use limit 20 or 50.
Fetch the article list from Export List API.
For each item, fetch the Export JSON of that article.
Mirror the images.
Store the article with UPSERT.
Increase offset and continue until total is completed.
STEP 26

Display articles on the destination website

After storing articles in site_articles, the destination website must not connect to OxiRanker for every public display. It must read from its own database.

Blog list page

SELECT
  id,
  lang,
  lang_label,
  dir,
  is_rtl,
  slug,
  title,
  meta_description,
  category,
  tags,
  reading_time_minutes,
  cover_image_url,
  og_image_url,
  source_created_at,
  published_at,
  created_at
FROM public.site_articles
WHERE source_provider = 'oxiranker'
  AND deleted_at IS NULL
  AND sync_status = 'synced'
  AND publish_status = 'published'
ORDER BY COALESCE(source_created_at, published_at, created_at) DESC, id DESC
LIMIT 20 OFFSET 0;

Article detail page

SELECT *
FROM public.site_articles
WHERE source_provider = 'oxiranker'
  AND lang = 'fa'
  AND slug = 'smart-internal-links'
  AND deleted_at IS NULL
  AND sync_status = 'synced'
  AND publish_status = 'published'
LIMIT 1;
Recommended article URL
For multilingual websites, you can use the /{lang}/blog/{slug} pattern. Example: /fa/blog/smart-internal-links
STEP 27

SEO, Schema, RTL and HTML rendering

The destination website must read SEO data from its own database and render it on the article page.

SEO fields

title for the page title
meta_description for meta description
effective_canonical_url for canonical
og_title and og_description for Open Graph
og_image_url or cover_image_url for social preview image
article_schema for JSON-LD

HTML rendering

<div
  className="article-content"
  dangerouslySetInnerHTML={{ __html: article.html_body }}
/>

If the destination website has a strict security policy, it can sanitize HTML before saving or before displaying it.

RTL and LTR

Each article has lang, dir and is_rtl fields. If dir is rtl, the article page must be rendered right-to-left.

<article dir="rtl">
  ...
</article>
STEP 28

Common errors and their meanings

Destination website errors must be English, clear and understandable so troubleshooting can be done quickly.

Export API errors

Missing domain export token.
The Export API token was not sent. Authorization: Bearer YOUR_EXPORT_TOKEN must be sent.
Invalid domain export token.
The token is wrong, rotated, not related to this domain or the domainId is wrong.
Token does not have required scope.
The token does not have the required scope. New tokens must have articles:read and exports:read.
Invalid lang.
The lang value in the query parameter was sent incorrectly.

Webhook errors

Webhook URL must use HTTPS.
Webhook URL must start with https://.
Invalid webhook signature.
Webhook Secret is wrong, secret was rotated, raw body changed or the signature was calculated incorrectly.
Webhook timestamp is expired or invalid.
The timestamp is old, in the future or invalid. Check the server clock and NTP.
Webhook test delivery failed.
The destination website did not return a successful 200 to 299 status, timed out or did not handle webhook.test correctly.
STEP 29

Final API and Webhook testing checklist

After implementation, these tests verify the connection from beginning to end.

Export Token and JSON test

GET https://oxiranker.com/api/v1/users/domains/1/export/articles?limit=2&offset=0
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/json
GET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/json

Webhook test

If you call the destination Webhook with Postman without a signature, you must receive a security error. This means the endpoint is active and checks security.

{
  "error": "missing_webhook_signature",
  "message": "Webhook signature header is required."
}

Then click Test Webhook inside OxiRanker. If the receiver supports webhook.test, response_status must be 200.

After rotation
If Webhook Secret or Export Token is rotated, copy the new value immediately, replace it in the destination website .env and restart or redeploy the destination backend.
STEP 30

Important rules for correct implementation

These rules must be followed in the API integration so the system remains secure, stable and maintainable.

Export Token must only be stored in the backend.
Webhook Secret must only be stored in the backend.
The destination Webhook endpoint must be exactly /api/oxiranker/webhook.
Webhook URL must use HTTPS.
Webhook signature and timestamp must always be verified.
Articles must be stored with UPSERT, not a simple insert.
A unique constraint is required to prevent duplicates.
Images must be downloaded from OxiRanker and stored on the destination website.
The destination website must not permanently depend on OxiRanker image URLs.
Image URLs must be replaced inside html_body and article_schema.
If Webhook fails, daily sync must recover the missed article.
Errors shown to users or logs must be English and understandable.
If an article is archived, automatic sync must not publish it again.
raw_detail_payload and raw_list_item must be preserved so no original output data is lost.
If Export Token or Webhook Secret is rotated, the new value must be replaced in .env and the backend must be restarted or redeployed.
STEP 31

Complete API flow summary

After completing these steps, your custom website can securely receive, store, mirror and publish articles generated in OxiRanker.

Register in OxiRanker
Add a domain
Verify domain ownership
Choose languages and add Blog URL
Add keywords for each language
Complete Article Profile
Configure Telegram if needed
Configure the automatic article engine
Create the first article from Generate
Review Preview and SEO Report
Get Export Token from API Output
Get ready Export API endpoints
Get Webhook Secret
Create the fixed /api/oxiranker/webhook endpoint in the destination backend
Store Token and Secret in the destination website .env
Create the site_articles table and unique constraint
Implement full article fetching from Export JSON
Implement Image Mirroring
Implement UPSERT
Implement daily sync
Render article, SEO, OG and Schema from the destination database
Run final Export API and Webhook tests