Developers

Building with HitSonar?

Bring HitSonar’s YouTube research signals into your own application. The API returns the same analysis the product shows: one versioned HitSonar Score with its full breakdown, search demand as a band, the first page of results, and the queries people actually type. Nothing here is a preview; every response on this page is real.

Quickstart

  1. Create a key. Sign in at the console, name your organization if you have not, and choose Create API Key. It is shown once; store it as a secret, on a server.
  2. Make a call. Send it as a bearer token. This one costs nothing: autocomplete only.
    curl -H "Authorization: Bearer $HITSONAR_API_KEY" \
      "https://app.hitsonar.com/v2/suggest?q=how+to+start+a+podcast"
  3. Read the score. A full analysis (POST /v2/analyze) returns score.overall and score.grade: the one opportunity number, and its letter.

Where a secret key belongs

An hs_ key is a secret. Keep it on a server.

Do not put a HitSonar API key in browser JavaScript, a React or Vue frontend bundle, a public mobile app, a public GitHub repository, HTML, or a public config file. Anything shipped to a browser or an app store can be read by anyone who opens it, however it is minified or obfuscated. The right shape is your own backend between the two:

Browser / app→Your backend→HitSonar API

This is a different concern from CORS, even though both are about the browser. CORS (below) controls whichorigins a browser will let a request reach; it says nothing about who should hold the credential making that request. HitSonar allows cross-origin calls so the try-it panel on this page, and a server you run, can reach the API directly — that does not make it safe to send a secret key from code a stranger’s browser runs. The one exception on this page is the try-it panel itself, for testing your own key by hand; it is not a pattern to copy into a real application.

Authentication

Every call except /v2/health needs a key, as a bearer token:

Authorization: Bearer hs_...

Keys start with hs_ and belong to an organization, not a person, so a key keeps working when someone leaves. An owner or admin creates and revokes them in the console. A key is shown once, at creation, and is stored hashed after that: HitSonar itself cannot show it to you again.

Every /v2/* response (and every /v1/* one) carries CORS headers, so a server-side call, a browser call from your own domain, and the try-it panel on this page all work the same way. See “Where a secret key belongs” above before deciding where that call should come from.

Try it live

A real request to the real API, sent from this page with a key you paste in. Nothing is saved.

Sent straight from your browser to https://app.hitsonar.com with your key above. It is held only in this page’s memory: never written to localStorage, a cookie, or the URL, never sent to analytics, and gone the moment you reload. This is a testing convenience, not a pattern for a real application — see “Where a secret key belongs” above.

POST

/v2/analyze

The full analysis: autocomplete, Trends, the first page of results, and the score built from them. A cold run takes about 20 seconds; a repeat of the same phrase within a day is served from cache in milliseconds and is free.

Parameters

FieldTypeMeaning
phrasestring, required2 to 80 characters. Lower-cased and single-spaced before use.
geostring, optional"US" is the only value today. Default "US".
freshboolean, optionaltrue runs it again instead of serving a cached result. Costs one analysis.

Request

curl -X POST https://app.hitsonar.com/v2/analyze \
  -H "Authorization: Bearer $HITSONAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phrase": "how to start a podcast", "geo": "US" }'

Response headers

FieldTypeMeaning
X-Request-IdstringAlso in every error body. Quote it to support: "request req_... failed".
X-RateLimit-Limit, -Remaining, -ResetintegerThis key's per-minute rate limit. Sent for organization keys where a rate-limit state exists -- not for operator-configured keys, and not on a 401, which has no identified key.
X-Usage-Limit, -Remaining, -ResetintegerThis organization's monthly analyses. Only on /v2/analyze, and only where an organization allowance was consulted.

The fields worth mapping

FieldTypeMeaning
score.overall, score.grade0-100, A-FThe one opportunity score. Show it; never compute your own.
score.versionstringThe scoring model (SCORING_VERSION). Store it beside any score you keep -- versions are not comparable.
ranking.serpOpening0-100 or nullDescribes this one results page. Not an opportunity score.
ranking.titleShapes, ranking.videos[].titlestring[]For a title prompt: the need-phrases and titles already working.
suggestions[].phrase, seedSuggestionsstring[], 0-14 or nullFor a tag builder: real autocomplete queries, and how many YouTube offers for the bare phrase.
score.searchBand.labelstring or nullA tenfold band such as "10K to 100K" -- lead with this over the raw range.
analysisStatuscomplete | degraded | provisionalMark a provisional score as provisional on screen: it read no first page.
trend, ranking, *.subscribersnullablenull means unknown, never 0. A hidden subscriber count is null.

Full field-by-field reference, with every value: the OpenAPI schema.

GET

/v2/suggest

Autocomplete only. No YouTube quota, no monthly allowance, a few seconds. Use it for tag ideas, or to check whether a phrase is worth a full analysis.

FieldTypeMeaning
qstring, requiredThe phrase to expand.
geostring, optional"US" is the only value today.
curl -H "Authorization: Bearer $HITSONAR_API_KEY" \
  "https://app.hitsonar.com/v2/suggest?q=how+to+start+a+podcast"

Same request/response shape in curl, JavaScript, TypeScript, Python, Ruby, PHP, C#, Java and Go: see POST /v2/analyze above for the full language matrix. Swap the method and path.

POST

/v2/titles

A video title in, a better one out — with the research behind it. You send a title and nothing else: no keyword, no search volume, no competition figure, no brief. HitSonar works out what the video is about, researches the topics people actually search, rejects the search angles whose results page belongs to a different kind of video, reads how the titles ranking there are built, scores the title you sent, and writes and scores alternatives.

Parameters

FieldTypeMeaning
titlestring, required2 to 120 characters. Trimmed and whitespace-collapsed; otherwise kept exactly as sent, because it is scored and echoed back.
descriptionstring, optionalWhat actually happens in the video, up to 800 characters. With it, nothing about the video's contents has to be inferred.
targetViewer, format, angle, tonestring, optionalFree text that steers the writing.
directionstring, optionalemotional | curiosity | search | shorter | direct. Steers the whole set.
countinteger, optionalHow many titles to return, 1 to 10 (default 10). Trims the set rather than shrinking the generation: the model is asked for the same 5-10 either way and the deterministic score still picks recommended from all of them, so a smaller count gives the same top pick with fewer alternatives, at the same cost.
researchIdstring, optionalStart from an analysis your organization already has instead of deriving topics. An id from another organization answers exactly as a non-existent one does.
challengeTopicboolean, optionalOnly meaningful with researchId. Measures other topics too and lets them compete with it. Can cost analyses.
geostring, optional"US" is the only value today.

Request

curl -X POST https://app.hitsonar.com/v2/titles \
  -H "Authorization: Bearer $HITSONAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "No Condemnation - Putting Down the Shame You Were Never Meant to Carry" }'

Allow 120 seconds: a request that has to measure three topics it has never seen does real work. It costs one title generation from a separate monthly allowance, plus up to three new analyses — research.analysesRun says how many it actually spent, and anything your organization analyzed in the last 30 days is reused for free.

Two scores, and they are not the same number

FieldTypeMeaning
research.selected.opportunityScore0-100 · HitSonar ScoreIs this TOPIC worth pursuing? The same score.overall POST /v2/analyze reports, from the same model, versioned by score.version.
titleScore.overall0-100 · Title OpportunityHow well is this TITLE built against that evidence? Versioned separately, by titleScoreVersion.

Never render both as score. Neither is a prediction — neither says a video will rank, or how many views or clicks it will get. Neither is produced by a language model either: both are computed, so the same input always gives the same number. The model proposes candidate topics and writes the titles; the deterministic title score decides which one is recommended.

The six dimensions, and the trade they expose

FieldTypeMeaning
coverage0-20Does the title contain the researched search phrase.
alignment0-15Is it built like the titles already ranking on that page.
differentiation0-15Does it stand apart from them.
clickAppeal0-30Textual signals associated with viewer pull: relevance, information gaps, stakes, specificity, contrast, comprehension. A deterministic title-text heuristic, NOT a CTR prediction.
clarity0-10Can a viewer tell what it means, without obvious empty bait.
length0-10Does it fit a title bar.

Read them as two halves. coverage + alignment (35) is whether the video can be found; clickAppeal + differentiation + clarity (55) is whether it is worth choosing. A rewrite that raises one half and lowers the other is making a trade, and the breakdown is the only place a caller can see which — do not show the total on its own.

Appeal outweighs findability deliberately, and that is what moved in titleScoreVersion 3.0.0; 2.0.0 had it even at 45 and 45. Inserting the researched phrase is the half a rewrite can always improve mechanically, and the reason somebody clicks is the half it deletes by accident. Read weight from the response rather than these numbers.

appealFloor is null on nearly every title. When it is not, a weakclickAppeal has held overall below what the dimensions add up to — capped at 69 below 10 of 30, at 79 below 15, uncapped from 15 up. It is the one rule here that is not a weighted sum, and it exists because 30 points still leaves 70: enough for a title that is easy to find and gives a viewer no reason to choose it to read as excellent. Before 3.0.0 overall was always the sum of dimensions; it is not any more, so show overall and explain it with appealFloor.note rather than re-adding the breakdown.

clickAppeal scores textual signals associated with viewer pull, including relevance, information gaps, stakes, specificity, contrast, and comprehension. It is a deterministic title-text heuristic, not a measurement of viewer psychology and not a CTR prediction, so do not convert it into a percentage or a rate. Capitalisation and punctuation cannot move it: a colon, an exclamation mark and ALL CAPS all score identically, by construction.

Since 4.0.0 a token never scores on its own. A word nominates a candidate structure, which earns nothing until it composes — does it have a subject and an object, does it relate to the researched context, does it form a meaningful proposition. Why I Stopped Putting My Emergency Fund in Index Funds scores a strong information gap; Why Morning Routine contains the same word, proposes nothing that could have a cause, and scores zero for curiosity. Direct utility is a complete route: a plain How to install X in Y opens no loop and still scores well. And it is relevance-gated — a well-built title about a different subject than the one researched is held near zero however well it reads.

The fields worth mapping

FieldTypeMeaning
research.statusselected | no_suitable_topicno_suitable_topic means every candidate was rejected on search intent or had no analysis. Titles still come back, from the brief alone -- do not present them as search-backed.
research.qualitycomplete | degraded | provisional | noneA recommendation resting on "none" must not look like one resting on "complete".
research.considered[]arrayEvery finalist and what happened to it. The rejections are the useful part: which angles HitSonar looked at and would not recommend, with the count behind it.
research.selected.intentFitstrong | acceptable | mismatchWhether the first page is the same kind of video. Three words, not a fourth score, and a mismatch always quotes its evidence in intentReason.
titleStyleobject or nullCounted from the selected page's real titles, never produced by a model. Prevalence only: punctuation does nothing to a ranking. Null when no first page was read.
contextobject or nullA reading of your title's words, used to decide what to measure. Hypotheses, not evidence. No number in it, and nothing about what the video contains.
brief.contentSuppliedbooleanfalse when you sent no description, so the brief was inferred from the title. Nothing about the video's contents was invented either way.
usage.generationsobject or nullThe GENERATION allowance, not the analysis one. The X-Usage-* headers describe analyses and are not sent by this endpoint.

What it will not do

It will not invent a subject: "Episode 11", "Sunday Message" and "My New Video" name an occasion rather than anything researchable, and get 422 insufficient_context instead of a guess. It will not invent what is in the video — given a title alone it writes about the subject the title states, and adds no lists, statistics, scripture references, names or results that were not supplied to it. It will not copy a competitor: it borrows structure and never wording. And it will not pick a topic on score alone — a phrase with more demand whose first page is worship music loses to a phrase with less demand whose first page is teaching.

Full field-by-field reference: the OpenAPI schema.

POST

/v2/titles/score

Score titles you already have against research you already paid for. Everything a title score needs was bought by the analysis behind researchId, so scoring another candidate against it is arithmetic — and this endpoint costs nothing: no credits, no analyses, no title generations, and no YouTube, autocomplete, Trends or model request. The per-key rate limit still applies.

Use this instead of calling /v2/titles once per title. Six candidates that way would be six requests and thirty credits — and, worse than the cost, six DIFFERENT pieces of research, so the six scores would not be comparable with one another. One request here scores them all against one piece of evidence.

curl -X POST https://app.hitsonar.com/v2/titles/score \
  -H "Authorization: Bearer $HITSONAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "researchId": "res_8Kd2mQ1pR9vNc4w",
    "titles": [
      "Overcoming Shame: How to Stop Carrying What You Were Forgiven For",
      "No Condemnation — Putting Down the Shame You Were Never Meant to Carry",
      "Why I Stopped Carrying Shame I Was Never Meant to Carry"
    ]
  }'

researchId comes from research.selected.researchId on a POST /v2/titles response, or from an analysis this organization has already run. It must belong to the key’s organization: an id from another organization answers exactly as a non-existent one does, so existence cannot be probed.

FieldTypeMeaning
scored[]arrayOne entry per title sent, IN THE ORDER SENT, each with `title` and `titleScore`.
opportunityScore0-100score.overall for the TOPIC, from that analysis. Unchanged by anything here.
titleStyleobject | nullHow the first page's own titles are built. Null when the analysis has no ranking.
titleScoreVersionstringStore it beside any score you keep.

Results are not ranked and not scored against each other. The same title with the same researchId always returns the same number, whatever else was in the request — which is what makes a score cacheable and explainable. Sort by titleScore.overall yourself for a ranking. POST /v2/titles does the opposite deliberately: there the titles compete to be recommended, so near-duplicates score each other down.

Optional subject affects clickAppeal only, which is relevance-gated — a title about a different subject than the research scores near zero there however well it reads. Send it when the video is narrower than the phrase that was analysed. A title scored here gets the same number the same title gets inside a recommendation.

GET

/v2/health

Open, no key needed. Reports what is switched on: whether ranking data is available, the API contract and scoring versions in force, and the geographies supported.

curl https://app.hitsonar.com/v2/health

Errors

One envelope, whatever the route or the status:

{
  "error": {
    "code": "organization_limit",
    "message": "This organization has used its analyses for the month.",
    "type": "usage_limit_error",
    "param": null,
    "requestId": "req_9Kx3mZq2pR8vNc1w"
  },
  "usage": { "used": 50, "limit": 50, "resetsAt": "2026-10-01T00:00:00.000Z" }
}

usage is present only when error.code is organization_limit or global_limit. Every error also sets X-Request-Id, matching error.requestId, on the response headers.

FieldTypeMeaning
unauthorized401 · authentication_errorMissing or unknown key.
invalid_request400 · invalid_request_errorPhrase, q or title too short, an unsupported geo, or a researchId not available to your key. error.param names the field.
insufficient_context422 · invalid_request_errorPOST /v2/titles only. The request was well formed, and the title does not say enough about the video to research it. Send a description, or a more descriptive title.
rate_limited429 · rate_limit_errorThis key is sending requests too fast. See Retry-After.
organization_limit429 · usage_limit_errorThis organization's analyses for the month are used.
global_limit429 · usage_limit_errorHitSonar's own monthly ceiling is reached. Not about your organization.
service_unavailable503 · api_errorYouTube or Trends did not answer, or the usage check failed. Not charged.

This is /v2, API contract 3.2.0. /v1 still returns its original, simpler shape -- { "error": "some string" } -- unchanged, for callers who integrated before this document existed; see Versioning. If code that already targets /v2 checks typeof body.error === "string", it needs updating; see the changelog for the date this shipped.

Rate limits & usage

Two different limits, on two different header families, so neither is mistaken for the other:

FieldTypeMeaning
X-RateLimit-*60 requests / minute, per keyHow fast you may call. Carried by organization-key responses that have a rate-limit state, success or 429, so you see a limit coming rather than discovering it by being refused. An operator-configured key has no organization counter, and a 401 has no key to report one for.
X-Usage-*credits / month, per organizationCredits left this calendar month, in CREDITS rather than calls — divide by 5. Only on POST /v2/analyze; visible any time in the console too.
usage.credits (response body)the same poolReturned by POST /v2/titles with used, limit, remaining, resetsAt and perCall. The same balance the headers report, in the body because that endpoint does not send usage headers.

Credits

One pool per organization, per calendar month. A call costs 5 credits whatever it is: Free grants 50 credits (10 calls), Pro grants 2,000 (400 calls).

Counted: one uncached POST /v2/analyze, and one POST /v2/titles — the latter is one call however many topics it measures internally. Free: GET /v2/suggest, a cached repeat, a run that turns out provisional, and a run that fails: a reservation is taken before the work and given back if the work does not happen, so an outage never costs anything. Credits do not roll over.

Versioning

Three things that sound similar and are kept deliberately separate:

FieldTypeMeaning
/v1, /v2URL generationWhich family of endpoints, and which response shape, this is. A breaking change gets a new URL prefix, never a silent change inside an existing one -- see below.
API_VERSIONcurrently "3.2.0"The shape of /v2 requests and responses: field names, types, required-ness, enum values. In every /v2/health response as apiVersion.
score.version (SCORING_VERSION)currently "1.2.0"The formula that turns signals into a HitSonar Score. Frozen once released; a new formula gets a new number. In every score, on both /v1 and /v2.

A scoring change never moves API_VERSION: the response shape is unchanged. A response-shape change never moves score.version: nothing about how a score is computed changed. The two can move together in one release without being the same number, and usually will not be. Every developer-facing change of either kind is dated in the changelog, and the rules for what counts as breaking are in the versioning and deprecation policy.

Already calling /v1? It still works. Its requests, its success research payload, its error contract and its documented endpoint behaviour are frozen: the same bare { "error": "..." } shape it has always returned, no request id, no rate-limit headers -- see /v1/openapi.json for its own frozen spec. Backward-compatible status metadata has been added without touching an existing field: /v1/health gained apiVersion, reporting the frozen 1.0.0. It is deprecated (every /v1/* response now carries a Deprecation header) and will not gain new fields or endpoints, so new integrations should start on /v2 directly. There is no sunset date yet; one will be announced, with notice, once existing callers have had a chance to move. Details: the deprecation policy, or go straight to the /v1 to /v2 migration guide.

OpenAPI & SDKs

The full machine-readable contract, generated from the same Zod schemas the server validates requests against and tests validate responses against (nothing here is retyped by hand):

Point a generator (openapi-typescript, openapi-python-client, Postman’s importer, an LLM) at either and get typed shapes without guessing a field name.

Official SDKs

@hitsonar/sdk (npm) and hitsonar (PyPI): full types generated from the contract above, authentication, .analyze(), .suggest(), .health(), typed error classes, timeout handling, rate-limit and usage-limit awareness, and the request id on every error. Their own version is independent of API_VERSION and score.version, following normal semver for the package. Full guides: TypeScript SDK · Python SDK.

Single-file clients

Until the packages above are published, or if you would rather not add a dependency: two single-file clients, plain code, nothing to install beyond what is noted, nothing to trust but what you can read.

/**
 * A single-file client. Copy this into your project -- it is not the published @hitsonar/sdk
 * package, so there is no version to pin or registry to trust. Requires Node 18+ or any modern
 * runtime with a global fetch. See "OpenAPI & SDKs" below for the real package.
 */
export type HitSonarScore = {
  version: string;
  overall: number;
  grade: "A" | "B" | "C" | "D" | "F";
  demand: number;
  competition: number;
  searchBand: { label: string; low: number; high: number | null } | null;
  confidence: "low" | "medium" | "high";
};

export class HitSonarError extends Error {
  constructor(message: string, public code: string, public status: number, public requestId: string | null, public body: unknown) {
    super(message);
  }
}

export function hitSonar(apiKey: string, baseUrl = "https://app.hitsonar.com") {
  async function call(path: string, init?: RequestInit) {
    const res = await fetch(baseUrl + path, {
      ...init,
      headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...init?.headers },
      signal: AbortSignal.timeout(60_000),
    });
    const body = await res.json().catch(() => null);
    if (!res.ok) throw new HitSonarError(body?.error?.message ?? `HitSonar ${res.status}`, body?.error?.code ?? "unknown", res.status, res.headers.get("X-Request-Id"), body);
    return body;
  }
  return {
    analyze: (phrase: string, opts: { geo?: "US"; fresh?: boolean } = {}) =>
      call("/v2/analyze", { method: "POST", body: JSON.stringify({ phrase, ...opts }) }) as Promise<{ score: HitSonarScore; [k: string]: unknown }>,
    suggest: (q: string, geo: "US" = "US") =>
      call(`/v2/suggest?q=${encodeURIComponent(q)}&geo=${geo}`) as Promise<{ suggestions: { phrase: string; hits: number }[]; seedSuggestions: number | null }>,
  };
}

Changelog, methodology, policy

Status

A live read of /v2/health, refreshed when this page loads. There is no historical uptime record or incident log behind it: this shows whether the service is answering right now, not whether it was an hour ago.

Checking…raw