Developer tutorial · n8n + REST API
Build an SEO audit.
Run it in n8n.
Turn a page URL into a content review queue. Import the workflow, connect your API key, and put competitor evidence into the tools your team already uses.
03 / Use the evidence
One item, ready for your next node
The saved example produces 15 terms to review. Here is the testing candidate: missing on the target page and present on 7 of the 10 comparison pages.
Try the saved-data demo{
"term": "testing",
"importance": 4,
"competitorPages": 7,
"comparedPages": 10,
"prevalence": 0.7,
"status": "needs_review"
}01 / See the result first
Run a complete example with saved data
Start with the saved-data demo workflow. It contains a real report for our SEO API comparison page and the same transformation used by the live workflow. It makes no API requests and needs no credentials.
- Create a workflow in n8n. Open its menu and choose Import from File, then select
demo-workflow.json. - Select Execute Workflow. The three nodes load the saved report and build the review queue.
- Open Build review queue → Output → JSON. You should see one item with a score of 85, 10 comparison pages, and 15 entries in
gaps.
{
"targetUrl": "https://api.on-page.ai/best-seo-api",
"keyword": "best seo api",
"region": "US",
"score": 85,
"comparedPages": 10,
"nextStep": "editorial_review"
}Each gap includes its term, competitor prevalence, and a needs_review status. For example, “testing” appears on seven comparison pages and is missing from the target page. That gives the next step in your workflow specific evidence to use.
The workflows use built-in n8n nodes. Import and execution were verified with n8n 2.39.5, using the saved report and a local API fixture for the live workflow’s control flow.
02 / Connect once
Keep authentication in n8n’s credential store
Import live-workflow.json into a second workflow. You’ll see the scan request, a polling loop, a result request, and the review-queue builder.
- Open Submit scan. Authentication is set to Generic Credential Type → Header Auth.
- Create a Header Auth credential with the header name
Authorizationand the valueBearer YOUR_API_KEY. Get your key from the API key setup guide. - Select that same credential in Check status and Fetch report. All three HTTP nodes need it.
The download contains credential selectors, with no key or account-specific credential reference. Store the key in the credential form rather than the input node, code, or request URL. n8n’s HTTP credential guide explains this setup.
03 / Your first live run
Choose one page and its target keyword
Open Audit inputs and update the fields below. Use an interior page that serves the keyword you want to evaluate, such as a service page, article, or product comparison.
| Field | What to set |
|---|---|
url | The publicly crawlable page you want to analyze. The example uses https://api.on-page.ai/best-seo-api. |
keyword | The page’s target search query. The example uses best seo api. |
region | A supported Google region, such as US. See the scan API inputs. |
resumeJobId | Leave empty for a new scan. To continue an existing job, paste its ID here. |
requestKey | The default expression makes a scan key from the n8n execution ID. Preserve the original value when retrying an uncertain submission. |
Select Execute Workflow to start. A new Standard scan costs 2 credits. Track job shows the job ID and request key, which you can keep for recovery.
Starting another execution with an empty resume ID creates a new scan key. To retrieve an existing scan, fill in resumeJobId first. This makes the workflow skip Submit scan.
Your live results will differ from the saved example as the page and comparison results change. The demo’s 85 score and 15 candidates are reference outputs, not required values for your own audit.
04 / The asynchronous part
Follow the job all the way to its report
A scan starts asynchronously: the POST returns a job ID before the report is ready. The workflow keeps that ID, waits, and follows the job until it can retrieve the result.
- Submit scan calls
POST /v1/scanwith anIdempotency-Key. The accepted response containsjob_id. - Wait before check pauses for 15 seconds. Check status then requests
GET /v1/jobs/:id. - Read status counts the checks. Queued, waiting, running, and processing states return to the wait node. Failed or cancelled jobs stop with an error.
- Fetch report runs after completion and requests
GET /v1/jobs/:id/result?response_format=customer_v1.
The status response’s result field stays null. The completed report comes from the result endpoint and has fields such as entity_coverage at its top level.
The HTTP nodes include response headers and status so errors can retain their retry instructions. In n8n, the API JSON is therefore inside body. The following Code node checks the HTTP status and unwraps that body before using it.
Inspect the polling decision code
function successfulBody(response, step) {
const status = response.statusCode;
if (typeof status !== "number") throw new Error(step + ": expected the HTTP response status and body.");
if (status < 200 || status >= 300) {
const retryAfter = response.headers?.["retry-after"];
const delay = retryAfter == null ? "" : " Retry-After: " + String(retryAfter) + ".";
throw new Error(step + " returned HTTP " + status + "." + delay +
" Keep the original scan key when retrying a submission, or resume by job ID after submission succeeded.");
}
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) {
throw new Error(step + ": expected a JSON object in the response body.");
}
return response.body;
}
const state = $("Wait before check").item.json;
const response = successfulBody($input.first().json, "Check status");
const checks = state.checks + 1;
const status = response.status;
if (["failed", "cancelled"].includes(status)) {
throw new Error("Scan " + state.jobId + " ended with status " + status + ". Inspect the job error before starting another scan.");
}
if (!["queued", "waiting", "running", "processing", "completed"].includes(status)) {
throw new Error("Unexpected job status. Inspect the Check status node output.");
}
if (status !== "completed" && checks >= 20) {
throw new Error("Stopped after 20 checks. Set resumeJobId to " + state.jobId + " in Audit inputs to keep checking the same job.");
}
return [{ json: { ...state, checks, completed: status === "completed" } }];The reference to Wait before check carries the job ID and counter through the HTTP response. Keeping each execution to one page makes that association explicit.
The loop stops after 20 checks if the job is still pending. Each HTTP request has a 30-second timeout, and the workflow has a 15-minute execution limit. Reaching a limit does not cancel the API job; use its ID to resume checking.
05 / Make the output useful
Give the next node evidence it can act on
Build review queue turns the report into one n8n item. It retains the page, keyword, region, score, and job ID, then adds a gaps array for editorial review.
gaps- Terms marked missing, absent from the target’s term counts, and present on at least half of the compared pages. Sorted by prevalence, then importance.
originalIdeas- Optional information-gain questions and unique-data-point findings. An unavailable section is distinct from an available but empty list.
nextStep- The application value
editorial_reviewidentifies what should happen next. It is added by this workflow.
Inspect or customize the review-queue builder
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,
},
};
}
function successfulBody(response, step) {
const status = response.statusCode;
if (typeof status !== "number") throw new Error(step + ": expected the HTTP response status and body.");
if (status < 200 || status >= 300) {
const retryAfter = response.headers?.["retry-after"];
const delay = retryAfter == null ? "" : " Retry-After: " + String(retryAfter) + ".";
throw new Error(step + " returned HTTP " + status + "." + delay +
" Keep the original scan key when retrying a submission, or resume by job ID after submission succeeded.");
}
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) {
throw new Error(step + ": expected a JSON object in the response body.");
}
return response.body;
}
if ($input.all().length !== 1) throw new Error("Expected one completed report.");
const input = $input.first().json;
const report = input.statusCode === undefined ? input : successfulBody(input, "Fetch report");
if (!report.jobId || !report.meta?.url || !report.meta?.target_keyword) {
throw new Error("Expected the top-level customer_v1 report from the result endpoint.");
}
const review = buildOptimizer(report);
return [{ json: { ...review, nextStep: "editorial_review" } }];For example, pass { ignoredTerms: ["company"] } as the second argument to buildOptimizer to exclude a generic term your editors have dismissed.
A practical decision from the example
For “testing”, the editor could add a repeatable provider-testing method to the page’s reliability section. “Integration” is already present, so it is excluded from the missing-term queue. “Company” qualifies numerically but can still be dismissed as too generic to justify an edit.
This is why the output is a review queue. Prevalence helps choose what to inspect; it does not decide whether a particular topic improves the page.
Connect it to your product
- One task per page: send the final item to your review system, using
jobIdto avoid duplicate tasks when a run is resumed. - One task per suggestion: add a Split Out node for
gaps, retain the other fields, and usejobIdplus the gap’sidas the deduplication key. - A client report: save the
bodyJSON from Fetch report and follow the SEO audit PDF tutorial to add reviewed recommendations and generate the document.
The imported workflow ends at the review queue. Add your destination node and its credentials when you’re ready to integrate it. The content optimizer tutorial shows how to turn a selected finding into an editable suggestion.
06 / Developer questions
Keep the workflow predictable
Will importing the workflow start a scan or spend credits?
Both downloads use a manual trigger. Importing them does not execute a scan. The saved-data demo uses no API credits. Running the live workflow with a new scan key submits one Standard scan at 2 credits; n8n hosting or execution charges are separate.
What should I do after a timeout or an interrupted execution?
If you have a job ID, put it in resumeJobId in Audit inputs and execute the workflow again. Keep the credential for the account that owns the job. Resuming skips submission and starts a new polling window.
If the POST response was lost, copy requestKey from the original Prepare scan output. Set that value as fixed text in Audit inputs, leave the resume ID empty, and retry with the identical URL, keyword, and region. The API retains idempotency keys for 24 hours. After that window, check your job history before submitting again.
How do errors and rate limits behave?
The Code node after each HTTP request stops on non-success responses. Automatic retries are disabled. A 401 means the credential needs attention; 402 indicates insufficient credits; 409 can mean the same scan key was used with different inputs.
For a 429 or 503, inspect the Code node’s error or HTTP response headers and wait for the supplied Retry-After interval before retrying. Preserve the request key for an uncertain submission, or resume by job ID after submission succeeded. Do not route an error back to Submit scan with a fresh key.
Review the rate-limit guide before adding parallel executions.
Can I scan a list of pages or run this on a schedule?
This starter deliberately accepts one page per execution. For a list, run it as a sub-workflow once per URL, with a distinct scan key and stored job ID for each page. Limit concurrency to your account’s allowance.
After validating the live path, you can add a Schedule Trigger. Plan the credit budget first: ten new Standard scans use 20 credits. Keep job IDs and avoid starting a second scan for a page that is still processing.
Can I use a completion webhook instead of polling?
Yes. For a larger integration, a verified On-Page.ai completion webhook can trigger result retrieval. Verify its signature, associate the job with the correct account, and deduplicate deliveries before creating downstream tasks. Follow the webhook guide when building that separate path.
The download uses timed polling so you can run the example without configuring a public callback endpoint.
Why is the queue empty, or originality unavailable?
A valid empty gaps array means no rows met this workflow’s selection rule. It does not mean every aspect of the page passed an SEO audit. Missing core report arrays stop the transformation so a partial response cannot silently become a clean report.
Originality is optional. In the saved example, its question list is available but empty. Lite omits originality, so adapting this workflow to Lite requires the Lite endpoint and response format, and should keep the unavailable state visible.
Does this work on n8n Cloud? Can I keep using Node.js or Python?
The workflow uses built-in HTTP Request, Edit Fields, If, Wait, and JavaScript Code nodes, with no external packages in the Code nodes. It can be used in n8n Cloud or a self-hosted instance that enables those nodes. Check your instance’s execution limits and permissions.
If you prefer an application script, the content optimization and PDF report tutorials include complete JavaScript/Node.js and Python implementations. They use the same REST API.
What should I check before sharing my workflow?
Keep API keys in credentials, remove pinned execution data that belongs to a client, and inspect exported JSON for manually entered headers or private URLs. The supplied downloads contain no credentials, execution history, or saved n8n credential IDs.
The demo includes the public-page example report and its scan ID. Your own execution data can contain client URLs and reports, so set sharing and retention to match your application.
Make SEO analysis a repeatable workflow
You now have a working path from a page URL to a structured review queue: one scan, a bounded polling loop, a resumable job, and a result your next node can use. On-Page.ai supplies the page and competitor analysis; n8n connects it to your team’s process.
Run the saved example, connect your key, and audit a page you want to improve. Then add the review destination that makes those findings useful to your team.