Developer tutorial · REST API
Build an SEO
content optimizer.
Turn a URL and keyword into a prioritized review queue. Help writers close relevant content gaps, add original evidence, and see what changed after an update.
The review queue uses the missing-term and competitor-coverage rule in step 3.
01 / Working example
Scan a page you want to improve
We’ll use our SEO API comparison, the keyword best seo api, and region US. The page already scores 85, but the report still identifies specific topics worth reviewing. That’s a useful starting point for an optimizer inside a CMS or writing app.
Choose a language, use your API key, and replace the URL, keyword, and region for your own page. Keep the key on your server.
Run these commands in Bash or zsh with cURL 7.76+ and OpenSSL. Each call uses the same HTTP contract you’d use from PHP, Go, Java, or C#.
export ONPAGE_API_KEY="your-api-key"
# Generate once for this scan; keep the same key when retrying.
export ONPAGE_IDEMPOTENCY_KEY="$(openssl rand -hex 16)"1. Submit the page and keyword
curl --include --fail-with-body --silent --show-error --max-time 30 \
-X POST "https://api.on-page.ai/v1/scan" \
-H "Authorization: Bearer $ONPAGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $ONPAGE_IDEMPOTENCY_KEY" \
--data '{
"url": "https://api.on-page.ai/best-seo-api",
"keyword": "best seo api",
"region": "US"
}'Copy job_id from the response into the next command. Keep the same idempotency key and body when retrying this submission.
2. Wait for completion
export ONPAGE_JOB_ID="job_id_from_the_scan_response"
curl --include --fail-with-body --silent --show-error --max-time 30 \
"https://api.on-page.ai/v1/jobs/$ONPAGE_JOB_ID" \
-H "Authorization: Bearer $ONPAGE_API_KEY"Repeat the GET request every few seconds until status is completed. Stop if the job is failed or cancelled. The status endpoint’s result stays null; fetch the report separately.
3. Save the baseline report
curl --fail-with-body --silent --show-error --max-time 30 \
"https://api.on-page.ai/v1/jobs/$ONPAGE_JOB_ID/result?response_format=customer_v1" \
-H "Authorization: Bearer $ONPAGE_API_KEY" \
--output before.jsonbefore.json contains the full report. The JavaScript and Python versions also turn it into the review queue shown below.
02 / Response contract
Separate coverage from repetition
The result endpoint returns fields at the top level. entity_coverage.natural_language_entities describes whether concepts are covered. competitor_term_coverage adds counts for your page and the comparison pages.
Here are two rows from the example. testing is missing, while integration is already present:
{
"entity_coverage": {
"natural_language_entities": [
{
"entity": "integration",
"importance": 8,
"coverage_status": "present_not_entity"
},
{
"entity": "testing",
"importance": 4,
"coverage_status": "missing"
}
]
},
"competitor_term_coverage": {
"domains": [
"dataforseo.com",
"seoprofy.com",
"analytify.io",
"nimbleway.com",
"apyhub.com",
"serpapi.com",
"coherentmarketinsights.com",
"botsify.com",
"position.digital",
"seranking.com"
],
"terms": [
{
"keyword": "integration",
"importance": 8,
"your_url_count": 3,
"competitor_counts": [
10,
10,
12,
8,
1,
0,
4,
11,
2,
4
]
},
{
"keyword": "testing",
"importance": 4,
"your_url_count": 0,
"competitor_counts": [
0,
2,
2,
2,
1,
0,
1,
0,
1,
1
]
}
]
}
}The download contains all entity and competitor rows used by this tutorial, plus selected score and originality fields.
good- The entity is covered. Keep it out of the missing-topic queue.
present_not_entity- The wording is present but wasn’t recognized as an entity. In the example, “integration” appears three times; it doesn’t need a suggestion to add the word.
missing- A candidate for review. Combine this status with the page’s term count and competitor coverage before prioritizing it.
competitor_counts- Counts align by index with
domains. Count how many entries are greater than zero to find how many compared pages cover a term.
For “testing”, 7 of 10 compared pages contain the term. That gives your editor a reason to investigate the topic; the eventual suggestion should explain what useful content to add.
03 / Prioritization
Build a queue writers can work through
A long list of terms needs an ordering rule. This example keeps rows where the entity is missing, your page’s count is zero, and at least half of the compared pages contain the term. It then sorts by competitor coverage, followed by importance.
The 50% threshold is an application choice you can tune. An ignored-term list lets editors dismiss generic or irrelevant suggestions and keep those decisions across scans.
Inspect the queue builder used by the script
function buildOptimizer(report, { minCoverage = 0.5, ignoredTerms = [] } = {}) {
if (minCoverage < 0 || minCoverage > 1 || !Number.isFinite(minCoverage)) {
throw new Error("minCoverage must be between 0 and 1.");
}
const entities = report.entity_coverage?.natural_language_entities;
const matrix = report.competitor_term_coverage;
if (!Array.isArray(entities) || !Array.isArray(matrix?.domains) || !Array.isArray(matrix?.terms)) {
throw new Error("Expected entity_coverage and competitor_term_coverage arrays.");
}
const key = (text) => String(text).trim().toLowerCase();
const statuses = new Map(entities.map((row) => [key(row.entity), row.coverage_status]));
const ignored = new Set(ignoredTerms.map(key));
const seen = new Set();
const gaps = [];
for (const row of matrix.terms) {
const counts = row.competitor_counts;
if (!Array.isArray(counts) || counts.length !== matrix.domains.length ||
counts.some((n) => typeof n !== "number" || !Number.isFinite(n) || n < 0)) {
throw new Error("Competitor counts must align with domains and contain numbers.");
}
const term = key(row.keyword);
if (!term || seen.has(term)) continue;
seen.add(term);
// Require both signals; present_not_entity is already present in the page.
if (statuses.get(term) !== "missing" || row.your_url_count !== 0 || ignored.has(term)) continue;
const competitorPages = counts.filter((n) => n > 0).length;
const comparedPages = counts.length;
if (!comparedPages || !competitorPages || competitorPages / comparedPages < minCoverage) continue;
gaps.push({
id: "coverage:" + term, term: row.keyword,
importance: Number.isFinite(row.importance) ? row.importance : 0,
competitorPages, comparedPages,
prevalence: competitorPages / comparedPages,
status: "needs_review",
});
}
gaps.sort((a, b) => b.prevalence - a.prevalence || b.importance - a.importance ||
(a.term < b.term ? -1 : a.term > b.term ? 1 : 0));
const gain = report.originality?.information_gain;
const ideas = gain?.potential_uncovered_topics_for_information_gain;
return {
jobId: report.jobId,
targetUrl: report.meta?.url ?? null,
keyword: report.meta?.target_keyword ?? null,
region: report.meta?.location ?? null,
score: report.on_page_optimization?.score ?? null,
scoreAlgorithm: report.on_page_optimization?.algorithm_version ?? null,
comparedPages: matrix.domains.length,
gaps,
originalIdeas: {
available: Array.isArray(ideas),
items: Array.isArray(ideas) ? [...new Set(ideas.filter((s) => typeof s === "string" && s.trim()))] : [],
uniqueDataPoints: gain?.unique_data_points ?? null,
},
};
}The example produces 15 candidates. Each suggestion keeps the evidence beside the term, so your UI can explain why it’s in the queue:
{
"id": "coverage:testing",
"term": "testing",
"importance": 4,
"competitorPages": 7,
"comparedPages": 10,
"prevalence": 0.7,
"status": "needs_review"
}Three candidates, three editorial decisions
| Term | Coverage | Suggested next step |
|---|---|---|
| testing | 7 / 10 | Expand the reliability section with a concrete API testing procedure. |
| ai mode | 7 / 10 | Review the AI-search comparison and verify which surfaces each provider supports before adding detail. |
| company | 6 / 10 | Dismiss this generic term. It doesn’t identify a useful section or explanation to add. |
Store each decision against the content record and suggestion ID. Keep an editor’s dismissed decision separate from the API’s coverage status: the next scan can still report a term as missing even when the editor has chosen to leave it out.
Add a second queue for original ideas
When available, originality.information_gain.potential_uncovered_topics_for_information_gain supplies questions your page and most competitors haven’t answered. The script returns them as originalIdeas.items, with an available flag so your UI can distinguish an empty list from an unavailable field.
{
"score": 42,
"grade": "Moderately original",
"information_gain": {
"potential_uncovered_topics_for_information_gain": [],
"unique_data_points": {
"your_count": 0,
"page1_average": 11.1,
"examples": []
}
}
}This scan returned no uncovered questions. Its numeric-data comparison does give us a separate editorial direction: the report detected no unique numeric data points on our page. We could add an original provider test covering completion time, success rate, and cost per finished workflow.
That becomes a research task in the editor: record the test inputs and observed results, then write the comparison from those measurements. The topic queue helps writers fill relevant gaps; original evidence gives readers something additional to learn.
04 / Editor integration
Turn “testing” into a useful edit
The comparison page has a section called “Reliability is a product feature.” It names the qualities to look for, which makes it a natural place to explain how a developer could evaluate them.
Original paragraph
Status pages, clear errors, retry behavior, request logs, and predictable job states matter as much as the data itself once an SEO API is inside a production workflow.
Suggested revision
Status pages, clear errors, retry behavior, request logs, and predictable job states matter as much as the data itself once an SEO API is inside a production workflow. Test each provider with the same set of URLs and keywords. Record successful responses, failed requests, completion time, and cost per finished workflow. Include retry and timeout cases in your API testing so the comparison reflects how the integration will behave in production.
The addition turns a broad recommendation into a small testing procedure. It covers the missing topic in context and gives the reader a practical next step.
Pass evidence to your writing layer
Your application creates the draft suggestion. If you use an LLM, pass the chosen term, competitor coverage, page purpose, and the actual paragraph text. Ask for a paragraph ID, original text, replacement text, and a reason for the change. Use a model call when the editor requests a draft.
Keep the content revision in your own application and attach it to the suggestion. Here’s the edit object for this example; the paragraph and revision IDs illustrate the values your CMS adapter would supply:
Example edit object
{
"paragraphId": "reliability",
"expectedRevision": "cms-revision-7",
"originalText": "Status pages, clear errors, retry behavior, request logs, and predictable job states matter as much as the data itself once an SEO API is inside a production workflow.",
"replacementText": "Status pages, clear errors, retry behavior, request logs, and predictable job states matter as much as the data itself once an SEO API is inside a production workflow. Test each provider with the same set of URLs and keywords. Record successful responses, failed requests, completion time, and cost per finished workflow. Include retry and timeout cases in your API testing so the comparison reflects how the integration will behave in production."
}function prepareEdit(document, suggestion) {
if (document.revision == null || document.revision !== suggestion.expectedRevision) {
throw new Error("The article changed. Review the suggestion again.");
}
const paragraph = document.paragraphs.find((p) => p.id === suggestion.paragraphId);
if (!paragraph || paragraph.text !== suggestion.originalText) {
throw new Error("The original paragraph no longer matches.");
}
if (typeof suggestion.replacementText !== "string" || !suggestion.replacementText.trim()) {
throw new Error("A replacement paragraph is required.");
}
return {
paragraphId: paragraph.id, text: suggestion.replacementText,
expectedRevision: document.revision,
};
}Send the validated patch through your CMS’s update API after the editor approves it. Have the CMS enforce expectedRevision atomically when saving, so another edit between validation and the write produces a conflict instead of an overwrite. Render the suggested text through your editor’s text model.
05 / Verification
Compare the page after an update
Once the updated content is available at the scanned URL, submit a new job with the same URL, keyword, region, and scan tier. Use a new idempotency key for this new measurement. The baseline score for our example is 85; the next score comes from the next scan.
Save the comparison helper as compare-reports.mjs. Keep before.json, then run a new scan of the updated URL and save it as after.json.
import { readFileSync } from "node:fs";
function compareReports(before, after) {
for (const field of ["url", "target_keyword", "location"]) {
if (!before.meta?.[field] || before.meta[field] !== after.meta?.[field]) {
throw new Error("Compare the same URL, keyword, and region.");
}
}
if (!before.jobId || !after.jobId || before.jobId === after.jobId) {
throw new Error("Use two distinct scan jobs.");
}
const previous = before.entity_coverage?.natural_language_entities;
const current = after.entity_coverage?.natural_language_entities;
if (!Array.isArray(previous) || !Array.isArray(current)) {
throw new Error("Both reports need natural_language_entities.");
}
const key = (text) => String(text).trim().toLowerCase();
const states = new Map(current.map((row) => [key(row.entity), row.coverage_status]));
const nowCovered = [], stillMissing = [], notReported = [];
const seen = new Set();
for (const row of previous) {
const term = key(row.entity);
if (row.coverage_status !== "missing" || seen.has(term)) continue;
seen.add(term);
const state = states.get(term);
if (state === "good" || state === "present_not_entity") nowCovered.push(row.entity);
else if (state === "missing") stillMissing.push(row.entity);
else notReported.push(row.entity);
}
const oldScore = before.on_page_optimization, newScore = after.on_page_optimization;
const sameAlgorithm = Boolean(oldScore?.algorithm_version) &&
oldScore.algorithm_version === newScore?.algorithm_version;
const oldDomains = before.competitor_term_coverage?.domains;
const newDomains = after.competitor_term_coverage?.domains;
const cohortChanged = Array.isArray(oldDomains) && Array.isArray(newDomains)
? JSON.stringify([...oldDomains].sort()) !== JSON.stringify([...newDomains].sort()) : null;
return {
beforeJobId: before.jobId, afterJobId: after.jobId,
scoreBefore: oldScore?.score ?? null, scoreAfter: newScore?.score ?? null,
scoreDelta: sameAlgorithm && Number.isFinite(oldScore.score) && Number.isFinite(newScore?.score)
? newScore.score - oldScore.score : null,
sameAlgorithm, cohortChanged, nowCovered, stillMissing, notReported,
};
}
const [beforePath, afterPath] = process.argv.slice(2);
if (!beforePath || !afterPath) throw new Error("Usage: node compare-reports.mjs before.json after.json");
console.log(JSON.stringify(compareReports(
JSON.parse(readFileSync(beforePath, "utf8")),
JSON.parse(readFileSync(afterPath, "utf8")),
), null, 2));# Run after the updated page is available at the same URL.
unset ONPAGE_JOB_ID ONPAGE_REPORT_FILE
export ONPAGE_IDEMPOTENCY_KEY="$(node -p 'crypto.randomUUID()')"
export ONPAGE_SAVE_REPORT="after.json"
node content-optimizer.mjs
node compare-reports.mjs before.json after.jsonThese commands use the JavaScript optimizer script from step 1. With cURL, repeat the scan using a new idempotency key, then save the second result to after.json instead.
nowCovered- Previously missing entities now marked
goodorpresent_not_entity. stillMissing- Previously missing entities still marked
missing. notReported- Baseline entities absent from the new report. Keep these separate from resolved items because the comparison set may have changed.
scoreDelta- The difference when both reports have numeric scores and the same scoring algorithm. Otherwise it’s
null. cohortChanged- Whether the compared domain lists changed. Show this alongside the score so readers can see when the benchmark moved.
Use coverage changes to review the edit’s effect, and track search performance separately. The on-page score measures the report’s optimization signals; it isn’t a predicted Google position.
Implementation questions
Before you connect it to your app
Can I send draft text or HTML instead of a URL?
POST /v1/scan analyzes a URL and keyword. It doesn’t accept a draft-text or HTML-body input. Use a crawler-accessible preview URL to analyze a draft, or the published URL for the final check. The editor can work with draft text locally; a new API score requires another URL scan. The comparison helper expects both reports to use the same URL.
What does the workflow cost?
A Standard scan costs 2 credits. A baseline plus one verification scan costs 4 credits. Polling, result retrieval, and running the mapper on saved JSON don’t create another scan. If your app uses an LLM to draft edits, that model’s usage is separate. See pricing for the current plans.
Should I run a scan on every keystroke?
Run a scan when a user clicks Analyze or after a meaningful saved revision. Store the raw report with the content revision, URL, keyword, region, and job ID, then reuse it while the editor reviews suggestions. For background processing, use job webhooks and associate each result with its revision so an older completion can’t replace a newer result.
What if the scan fails, times out, or gets rate-limited?
The JavaScript and Python examples stop after failed or cancelled jobs and bound their polling to five minutes. They retry 429 and 503 responses with a numeric Retry-After header. Other errors stop the script. Resume a known job by ID, or retry an interrupted submission with the same key and body. With cURL, inspect the response headers and handle retries explicitly. See rate limits and error codes.
What if there are no gaps or originality ideas?
An empty queue is valid: no rows passed your rule. Missing required entity or competitor arrays are a report-shape error, which the mapper surfaces instead of displaying an empty result. Originality is optional; show its queue only when the relevant field is available. An available but empty ideas list can say “No original-topic suggestions returned.”
Which scan tier should I use?
Use Standard for this workflow. Deep also returns the core fields, while Lite supports entity and competitor analysis but omits originality. When using Lite, request customer_v1_lite or omit the response-format parameter. Keep the same tier when comparing an update. See the API reference for tier differences.