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.
API service
Use Export API and Webhook for custom websites, custom CMS platforms, Next.js, Laravel, Node.js, PHP or Python backends.
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.
Connection training video
Use this video as a visual walkthrough before connecting WordPress or implementing the API integration.
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.
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.comWrong formats
Do not enter these formats in the domain field.
https://example.com
http://example.com
www.example.comVerify domain ownership
In this step, you must prove that the domain belongs to you or that you have access to manage it.
DNS TXT
RecommendedAdd a TXT record to the domain DNS. For Cloudflare, this is usually the simplest method.
DNS Name:
_yourdomain-verification.example.com
TXT Value:
acb1****29803HTTP 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****42730f27Meta Tag
Place a meta tag inside the head section of your website homepage.
<meta name="yourdomain-verification" content="190208d****d275b65" />Receive free tokens after verifying the first domain
After verifying the first domain, OxiRanker adds 1000 free tokens to your account for initial testing.
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/blogAdd 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.
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.
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.
Check profile quality with AI
After completing the Article Profile, OxiRanker reviews the profile quality.
Connect Telegram for article summaries
After successfully saving the profile, you can enable sending article summaries to a Telegram channel.
Add the bot
Add @Oxiranker_Bot to your Telegram channel and make it an Admin.
Enter the channel username
Enter the channel name with @. Example: @my_channel
Enable summary delivery
After registering a valid channel, enable article summary delivery.
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 / month30 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.
Create the first article from Generate
After preparing the domain, open the Generate menu, choose the domain and click New Content or New Article.
Language
Only one language is selected for each content request.
Topic
Write a clear topic. Source URL is optional.
Image
Enable or disable the image and choose Light or Dark theme.
Payment and generation
The cost is deducted from the wallet and article generation starts.
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.
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/jsonGET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/jsonMethod 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"
}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
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.webphttps://example.com/uploads/site-articles/oxiranker/1/5/cover.webpGet 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
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=0https://oxiranker.com/api/v1/users/domains/1/articles/1/export/jsonhttps://oxiranker.com/api/v1/users/domains/1/articles/1/export/html?canonical_mode=absolute&include_css=0Create 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?
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/webhookThis endpoint must only accept POST, preserve the raw body, verify timestamp and signature, and return a successful response for webhook.test.
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=50Security values
Storage values
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/jsonlimit 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/jsonThis 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_TOKENHTML 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_TOKENThe ZIP output includes article.json, meta.json, assets.json, schema.jsonld, index.html, version.txt and README.txt.
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.
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,
});
});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);Prevent duplicates with UPSERT
The article must not be inserted again for every Webhook. If it already exists, it must be updated.
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();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,
});
}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
Recommended storage path
/uploads/site-articles/oxiranker/1/5/cover.webp
/uploads/site-articles/oxiranker/1/5/og.webphttps://example.com/uploads/site-articles/oxiranker/1/5/cover.webpDaily 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?
Daily sync algorithm
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;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
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>Common errors and their meanings
Destination website errors must be English, clear and understandable so troubleshooting can be done quickly.
Export API errors
Webhook errors
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/jsonGET https://oxiranker.com/api/v1/users/domains/1/articles/5/export/json
Authorization: Bearer YOUR_EXPORT_TOKEN
Accept: application/jsonWebhook 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.
Important rules for correct implementation
These rules must be followed in the API integration so the system remains secure, stable and maintainable.
Complete API flow summary
After completing these steps, your custom website can securely receive, store, mirror and publish articles generated in OxiRanker.