Skip to main content
This guide shows you how to go from a single keyword to a complete brief of SEO and GEO recommendations. The flow uses four endpoints:
  1. Create a draft content in your editorial planning.
  2. Add the focus keyword to the draft’s page and set it as the focus keyword.
  3. Launch the keyword analysis asynchronously.
  4. Generate the analysis report to retrieve typed recommendations.
The same flow powers the New content button in the Semji app.

Prerequisites

  • An API key. See Authentication.
  • The workspace ID you want to plan content in. Get it from GET /v1/workspaces.
  • A focus keyword you want recommendations for (e.g. best crm for small business).

1. Create a draft content

Call POST /v1/workspaces/{workspaceId}/contents without a pageId — Semji will auto-create a blank page to host the draft. You only need a title to get started.
const API = "https://api.semji.com/v1";
const HEADERS = {
  Authorization: `Bearer ${process.env.SEMJI_API_KEY}`,
  "Content-Type": "application/json",
};
const WORKSPACE_ID = process.env.WORKSPACE_ID;

const content = await (
  await fetch(`${API}/workspaces/${WORKSPACE_ID}/contents`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ title: "Best CRM for small business" }),
  })
).json();

const contentId = content.id;
const pageId = content.page.id;
A successful response includes both the new content ID and the auto-created page ID:
201 Created (excerpt)
{
  "id": "7c4a1f08b29d",
  "title": "Best CRM for small business",
  "page": { "id": "5e8d203c7f1a", "url": null },
  "contentStatus": { "id": "b3d51e92c804", "label": "to do" },
  "version": 1
}
Keep both content.id and page.id — you’ll use them in the next steps.

2. Attach the focus keyword

Adding a focus keyword is a two-step operation:
  1. Add the keyword to the page with POST /v1/pages/{pageId}/keywords.
  2. Mark it as the page’s focus keyword with PUT /v1/pages/{id}.
// 1. Create the keyword on the page
const keyword = await (
  await fetch(`${API}/pages/${pageId}/keywords`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ keyword: "best crm for small business" }),
  })
).json();
const keywordId = keyword.id;

// 2. Set it as the page's focus keyword
const update = await fetch(`${API}/pages/${pageId}`, {
  method: "PUT",
  headers: HEADERS,
  body: JSON.stringify({ focusKeywordId: keywordId }),
});
if (!update.ok) throw new Error(`PUT failed: ${update.status}`);
If the keyword already exists in the workspace, POST /v1/pages/{pageId}/keywords reuses it instead of creating a duplicate.

3. Launch the keyword analysis

Recommendations are not computed on demand — you have to launch an asynchronous analysis with POST /v1/keywords/{id}/analyze. The analysis scrapes the Google SERP, runs the GEO/AI Overview probe, and stores the typed recommendations on the keyword.
const analyze = await fetch(`${API}/keywords/${keywordId}/analyze`, {
  method: "POST",
  headers: HEADERS,
});
if (!analyze.ok) throw new Error(`analyze failed: ${analyze.status}`);
The endpoint returns 202 Accepted immediately and the analysis runs in the background. Poll GET /v1/keywords/{id} until analysisStatus is "success":
while (true) {
  const keyword = await (
    await fetch(`${API}/keywords/${keywordId}`, { headers: HEADERS })
  ).json();
  if (keyword.analysisStatus === "success") break;
  if (keyword.analysisStatus === "failed") throw new Error("Keyword analysis failed");
  await new Promise((resolve) => setTimeout(resolve, 5_000));
}
analysisStatus transitions through queuedpendingsuccess (or failed). Most analyses complete within 30 seconds.
Each analysis consumes one analysis credit from your organization’s balance. Check available credits with GET /v1/me (look at organization.credits.analysis).

4. Retrieve the SEO & GEO recommendations

Once the analysis is success, call POST /v1/keywords/{id}/report to score a content draft against the analysis and pull the typed recommendations. You have two ways to score:
  • By reference — pass contentId and Semji uses the draft’s current title + html.
  • By value — pass title and html inline (useful for previewing recommendations against arbitrary text).
const report = await (
  await fetch(`${API}/keywords/${keywordId}/report`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ contentId }),
  })
).json();
The response contains two top-level surfaces:
  • googleSearch — classic SEO recommendations from the SERP (topics, questions, search intents, internal links to add, SERP competitors).
  • googleAiOverview — GEO recommendations from the AI Overview answer (topics to cover for citation, cited sources, mentioned brands, markdown preview of the LLM answer).
200 OK (excerpt)
{
  "googleSearch": {
    "score": 0.42,
    "recommendations": {
      "topicsSuggestion": { "score": 0.55, "items": [{ "topic": "pricing", "...": "..." }] },
      "questionsSuggestion": { "score": 0.30, "items": [{ "question": "Which CRM is free?" }] }
    },
    "competitors": [{ "domain": "hubspot.com", "position": 1 }]
  },
  "googleAiOverview": {
    "score": 0.31,
    "recommendations": {
      "geoTopicsSuggestion": { "score": 0.31, "items": [{ "topic": "integration with email" }] }
    },
    "sources": [{ "domain": "salesforce.com", "position": 1 }],
    "brands": [{ "name": "HubSpot", "count": 4, "category": "software" }],
    "preview": "## Best CRM for small business…"
  }
}
Each *Suggestion block carries its own score (0.0 – 1.0) and a list of items you can render in your brief. The top-level score on each surface is the overall match between the content and the recommendations.
Re-call the report endpoint as the draft evolves — the recommendations are fixed (until you re-run the analysis), but the scores change as the html improves.

Putting it all together

const API = "https://api.semji.com/v1";
const HEADERS = {
  Authorization: `Bearer ${process.env.SEMJI_API_KEY}`,
  "Content-Type": "application/json",
};
const WORKSPACE_ID = process.env.WORKSPACE_ID;

async function api(path: string, init: RequestInit = {}) {
  const response = await fetch(`${API}${path}`, { ...init, headers: HEADERS });
  if (!response.ok) {
    throw new Error(`${init.method ?? "GET"} ${path}${response.status}`);
  }
  return response.json();
}

async function createDraft(title: string) {
  return api(`/workspaces/${WORKSPACE_ID}/contents`, {
    method: "POST",
    body: JSON.stringify({ title }),
  });
}

async function setFocusKeyword(pageId: string, keyword: string) {
  const created = await api(`/pages/${pageId}/keywords`, {
    method: "POST",
    body: JSON.stringify({ keyword }),
  });
  await api(`/pages/${pageId}`, {
    method: "PUT",
    body: JSON.stringify({ focusKeywordId: created.id }),
  });
  return created.id;
}

async function analyzeAndWait(keywordId: string) {
  await api(`/keywords/${keywordId}/analyze`, { method: "POST" });
  while (true) {
    const keyword = await api(`/keywords/${keywordId}`);
    if (keyword.analysisStatus === "success") return;
    if (keyword.analysisStatus === "failed") throw new Error("Keyword analysis failed");
    await new Promise((resolve) => setTimeout(resolve, 5_000));
  }
}

async function getReport(keywordId: string, contentId: string) {
  return api(`/keywords/${keywordId}/report`, {
    method: "POST",
    body: JSON.stringify({ contentId }),
  });
}

async function recommendationsFor(keyword: string) {
  const draft = await createDraft(keyword.charAt(0).toUpperCase() + keyword.slice(1));
  const keywordId = await setFocusKeyword(draft.page.id, keyword);
  await analyzeAndWait(keywordId);
  return getReport(keywordId, draft.id);
}

const report = await recommendationsFor("best crm for small business");
console.log("SEO score:", report.googleSearch.score);
console.log("GEO score:", report.googleAiOverview.score);

Reference