# HitSonar API

For an application that wants HitSonar's YouTube keyword research inside its own workflow. Everything below is the live contract
as of 2026-09-28 (API contract 3.2.0); the sample responses are real output, shortened only where marked "… more". The rendered,
browsable version of this reference, with a live try-it panel and an OpenAPI 3.1 spec, is at https://hitsonar.com/developers.

## Where a secret key belongs

An `hs_` API key is a secret. Never put it in browser JavaScript, a frontend bundle, a public repository, or a public mobile app —
anything shipped to a browser or an app store can be read by anyone who opens it. The right shape is your own backend between the
two: **browser or app &rarr; your backend &rarr; HitSonar**. CORS is enabled on every `/v1/*` and `/v2/*` route so a server you
run, or the try-it panel at https://hitsonar.com/developers, can call it directly; that does not make it safe to send a secret key
from code a stranger's browser runs.

## Base URL

    https://app.hitsonar.com

## Versioning

Three independent numbers. `/v1` or `/v2` in the path is URL generation: which family of endpoints and response shape this is,
bumped only for a wholesale redesign, never changed silently inside an existing prefix. `apiVersion` (in every `/v2/health`
response, currently `3.2.0`) is the shape of `/v2` requests and responses — field names, types, required-ness, error format.
`score.version` (`scoringVersion` in health, currently `1.2.0`) is the formula behind a score, reported on both `/v1` and `/v2`.
`titleScoreVersion` (returned by `POST /v2/titles`) is a third, independent number: the formula behind a **title's** score, which
is not the formula behind a topic's. A scoring change never moves `apiVersion`; a response-shape change never moves
`score.version`; neither moves `titleScoreVersion`. Full policy, including what counts
as a breaking change and how a deprecation is announced: https://hitsonar.com/developers/deprecation-policy. Dated history of
every change: https://hitsonar.com/developers/changelog.

**Already integrated against `/v1`?** It still works. Its requests, its success research payload, its error contract and its
documented endpoint behaviour are frozen: the same `{ "error": "some string" }` shape it has always returned, no request id, no
rate-limit headers, documented at its own frozen spec, https://app.hitsonar.com/v1/openapi.json. Backward-compatible status
metadata has been added without touching any existing field -- `/v1/health` gained `apiVersion`, which reports the frozen `1.0.0`
and lets a caller confirm it is still on the old contract. It is
deprecated (every `/v1/*` response now carries a `Deprecation` header) and will not gain new fields or endpoints. There is no
sunset date yet, and none will be set without advance notice on this page and in the changelog. New integrations should start
directly on `/v2`, described below.

## Authentication

Every call except `/v2/health` needs an API key as a bearer token:

    Authorization: Bearer hs_...

Keys start with `hs_` and belong to an organization. An owner or admin of that organization creates them at
https://app.hitsonar.com/console; a key is shown once, at creation. Store it as a secret. A missing or unknown key gets `401`.

## The keyword endpoint

    POST /v2/analyze
    Content-Type: application/json

    { "phrase": "how to start a podcast", "geo": "US", "fresh": false }

| Field | | |
| --- | --- | --- |
| `phrase` | required | Lower-cased, single-spaced and cut at 80 characters before use; the response echoes what was scored. |
| `geo` | optional | `US` is the only value today. Default `US`. |
| `fresh` | optional | `true` runs it again instead of serving a cached result. Costs an analysis. |

A cold run takes about 20 seconds; set a 60-second timeout. A repeat of the same phrase within a day is served from cache in
milliseconds (`"cached": true`) and is free.

### Sample response

```json
{
  "phrase": "how to start a podcast",
  "cached": false,
  "geo": "US",
  "analysisStatus": "complete",
  "volumeStatus": "modeled",
  "signals": {
    "autocomplete": "ok",
    "trends": "ok",
    "ranking": "ok",
    "volume": "modeled"
  },
  "geoCoverage": {
    "autocomplete": "US",
    "trends": "US",
    "ranking": "US results page; view and subscriber counts are worldwide",
    "searches": "worldwide: the model was fitted to worldwide reference volumes"
  },
  "suggestions": [
    {
      "phrase": "how to start a podcast for beginners 2025",
      "hits": 9,
      "sources": [
        "seed",
        "how to",
        "why",
        "which",
        "is",
        "for",
        "for beginners",
        "b",
        "f"
      ]
    },
    {
      "phrase": "how to start a podcast for beginners",
      "hits": 8,
      "sources": [
        "seed",
        "how to",
        "why",
        "which",
        "is",
        "for",
        "for beginners",
        "f"
      ]
    },
    {
      "phrase": "how to start a podcast with your friends",
      "hits": 7,
      "sources": [
        "seed",
        "how to",
        "why",
        "which",
        "is",
        "w",
        "y"
      ]
    }
  ],
  "directSuggestions": [
    {
      "phrase": "how to start a podcast for beginners 2025",
      "hits": 9,
      "sources": [
        "seed",
        "how to",
        "why",
        "which",
        "is",
        "for",
        "for beginners",
        "b",
        "f"
      ]
    },
    "… the subset of suggestions whose sources contain \"seed\""
  ],
  "seedSuggestions": 14,
  "seedEvidence": {
    "completions": 14,
    "status": "strong"
  },
  "trend": {
    "points": [
      {
        "week": "Sep 21, 2025",
        "value": 47
      },
      {
        "week": "Sep 28, 2025",
        "value": 100
      },
      "… 50 more, one a week"
    ],
    "recent": 46.25,
    "prior": 52.583333333333336,
    "direction": "steady",
    "peak": "Sep 28, 2025",
    "rising": [
      "how to start a podcast at home"
    ]
  },
  "ranking": {
    "totalResults": 1000000,
    "medianViews": 231271,
    "medianViewsPerMonth": 8652,
    "medianSubscribers": 485000,
    "subscriberDataCoverage": 1,
    "smallChannelShare": 0,
    "bigChannelShare": 0.85,
    "recentShare": 0.25,
    "phraseInTitleShare": 0.45,
    "serpOpening": 39,
    "verdict": "Real demand, but the first page belongs to large channels. Win it with a sharper need phrase, not the bare keyword.",
    "videos": [
      {
        "title": "Best CHEAP Podcast Setup for Beginners (Everything You Need to Start!)",
        "channel": "Think Media",
        "subscribers": 3520000,
        "views": 1102919,
        "monthsOld": 65.59387507043434,
        "url": "https://www.youtube.com/watch?v=80XcN1mNZ-c"
      },
      {
        "title": "How to Create a Podcast for Beginners",
        "channel": "Think Media",
        "subscribers": 3520000,
        "views": 1002304,
        "monthsOld": 56.842494938246226,
        "url": "https://www.youtube.com/watch?v=c4JdpdIsJvY"
      },
      "… 18 more"
    ],
    "titleShapes": [
      "to create a podcast for beginners",
      "to start a podcast for beginners",
      "to go from zero subscribers to millions",
      "to starting a podcast"
    ]
  },
  "score": {
    "version": "1.2.0",
    "demand": 47,
    "competition": 46,
    "overall": 41,
    "grade": "C",
    "searchesLow": 5500,
    "searchesHigh": 87000,
    "searchBand": {
      "label": "10K to 100K",
      "low": 10000,
      "high": 100000
    },
    "searchSource": "modeled",
    "confidence": "medium",
    "competitionConfidence": "medium",
    "lowSuggestionWarning": "none",
    "notes": [
      "Searches are modeled from public signals: trust the band, or the one next to it, and read the range as rough."
    ],
    "explanation": {
      "inputs": {
        "medianViewsPerMonth": 8652,
        "phraseInTitleShare": 0.45,
        "medianSubscribers": 485000,
        "subscriberDataCoverage": 1,
        "seedSuggestions": 14,
        "trendDirection": "steady",
        "completions": 52,
        "measuredSearches": null
      },
      "searches": {
        "fitted": 22262,
        "suggestionFactor": 0.981,
        "modeled": 21838,
        "used": 21838,
        "source": "modeled",
        "formula": "10^(0.87 + 0.702*log10(max(1000, medianViewsPerMonth)) + 1.586*phraseInTitleShare) * 10^(-1.248 / (1 + e^((seedSuggestions - 4) / 2))); a provider's figure replaces it"
      },
      "demand": {
        "base": 46.8,
        "trendNudge": 0,
        "value": 47,
        "formula": "round(clamp((log10(searches used) - 2) * 20 + trendNudge))"
      },
      "competition": {
        "value": 46,
        "basis": "fitted",
        "formula": "round(clamp(-43.51 + 25.93*phraseInTitleShare + 13.63*log10(max(100, medianSubscribers))))"
      },
      "overall": {
        "value": 41,
        "formula": "round(clamp(demand * (1 - competition/100) * 1.35 + demand * 0.15))",
        "grades": "A >= 70, B >= 55, C >= 40, D >= 25, else F"
      }
    }
  },
  "ranAt": "2026-09-22T12:17:34.507Z"
}
```

### The fields worth mapping

**The score.** `score.overall` (0 to 100) and `score.grade` (A to F) are the one opportunity score. Show them; never compute your
own. `score.version` names the scoring model that produced them; store it beside any score you keep, because scores from different
versions are not comparable. `ranking.serpOpening` describes the results page and is not an opportunity score.

**For a title builder:** `ranking.titleShapes` are the need-phrases that recur in first-page titles ("for beginners", "step by
step"); `ranking.videos[].title` are the first-page titles themselves; `trend.rising` are related queries gaining interest.

**For a tag builder:** `suggestions[].phrase` are phrases YouTube's autocomplete returned in response to HitSonar's direct and
expanded queries — not proof that each one is searched. They are ordered by recurrence across those expansion queries (`hits`, up to
about 40), which is not search volume, demand or relevance. **The number of suggestions is discovery breadth, never demand:** the
expansion fans the seed through roughly forty query shapes, so a meaningless string still comes back with plenty.

The direct evidence is `seedSuggestions` — completions YouTube offers for the bare phrase, 0 to 14, where ten or more is strong,
five to nine is thin, and four or fewer means almost nobody types it. Within `suggestions`, a phrase whose `sources` contains
`"seed"` was returned for the bare phrase itself; anything else was found only by asking about a query HitSonar composed. Derive the
direct set with `suggestion.sources.includes("seed")` — or read `directSuggestions`, which `/v2` hands you already filtered, and
`seedEvidence`, which pairs the bare-phrase count with how to read it (`strong` at 10 or more, `thin` at 5 to 9, `very_thin` at 4 or
fewer, `unknown` when the lookup failed). Neither is new evidence: both are computed from values already in the same response, and a
caller who derives them gets identical results. Prefer the direct set wherever a phrase will be used as evidence rather than as a
research candidate. **Neither field exists on `/v1`**, which is frozen.

**Demand:** `score.demand` (0 to 100), `score.searchBand.label` (a tenfold band such as "10K to 100K", the honest resolution of a
modeled figure), and `score.searchesLow` / `score.searchesHigh` (a rough range; lead with the band).

**Competition:** `score.competition` (0 to 100, lower is easier), `ranking.medianSubscribers`, `ranking.smallChannelShare` and
`ranking.bigChannelShare` (shares of the first page under 10K and over 100K subscribers, among channels whose count is public).

**Status, and what to do with it:**

| Field | Values | Do this |
| --- | --- | --- |
| `analysisStatus` | `complete`, `degraded`, `provisional` | Mark a provisional score as provisional on screen: it read no first page. A degraded one stands, with less certainty. |
| `signals.*` | per source: `ok`, `failed`, `no_data`, `off` | Trends `ok` means a usable series and a direction. `no_data` means Trends answered but no usable direction could be taken -- too few points, or a series of zeroes for a phrase too small to chart. `failed` means the request itself did not answer. Both leave `trend` null, so read this field rather than inferring a failure from the null. |
| `score.confidence` | `low`, `medium`, `high` | |
| `score.lowSuggestionWarning` | `none`, `thin`, `very_thin`, `unknown` | Evidence that few people type the phrase, shown beside the score. |
| `volumeStatus` | `modeled`, `provider`, `provider_no_data_fallback_modeled`, `provider_failed_fallback_modeled` | Where the search figure came from. |

**Nulls that mean unknown, not zero:** `trend` and `ranking` are `null` when that signal did not answer. `ranking.videos[].subscribers`
is `null` when a channel hides its count; `ranking.medianSubscribers` is `null` when none publish one. `seedSuggestions` is `null` when
the autocomplete lookup failed. Never treat any of these as 0.

`score.explanation` carries the inputs, intermediate values and formulas, so a score can be reproduced from its own response.

### Response headers

Every response, success or error, carries:

| Header | Meaning |
| --- | --- |
| `X-Request-Id` | On every `/v2` response, success or error. Also in every error body as `error.requestId`. Quote it to support. |
| `X-RateLimit-Limit`, `-Remaining`, `-Reset` | This key's per-minute rate limit (60/min by default). Sent for organization API keys where a rate-limit state exists. Absent for operator-configured keys, which have no organization counter, and absent on a `401`, which has no identified key to report a state for. |
| `X-Usage-Limit`, `-Remaining`, `-Reset` | This organization's **credits** for the calendar month, not calls. Only on `POST /v2/analyze`, and only where an organization's credits were consulted. Divide by 5 for calls. |
| `Retry-After` | On a `429` from the per-key rate limit, and authoritative for how long to wait. An exhausted monthly allowance does not set it -- read the `usage` object instead. |

### Errors

One envelope, whatever the route or the status:

```json
{
  "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`. Before API contract 2.0.0 (2026-09-22), an
error response was `{ "error": "some string" }`; if any code still checks `typeof body.error === "string"`, it needs updating.

| `error.code` | Status | `error.type` | Meaning |
| --- | --- | --- | --- |
| `unauthorized` | 401 | `authentication_error` | Missing or unknown key |
| `invalid_request` | 400 | `invalid_request_error` | Phrase, `q` or `title` shorter than two characters, an unsupported `geo`, or a `researchId` not available to your key. `error.param` names the field. |
| `insufficient_context` | 422 | `invalid_request_error` | `POST /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_limited` | 429 | `rate_limit_error` | This key is sending requests too fast. See the `Retry-After` header. |
| `organization_limit` | 429 | `usage_limit_error` | This organization's credits for the month are used. See `usage`. |
| `global_limit` | 429 | `usage_limit_error` | HitSonar's own monthly ceiling is reached — not about your organization. See `usage`. |
| `service_unavailable` | 503 | `api_error` | YouTube or Trends did not answer, or the usage check failed. Not charged. |

### Credits

Every organization spends from **one monthly pool of credits**. A call costs **5 credits**, whatever it is:

| plan | credits a month | calls |
| --- | ---: | ---: |
| Free | 50 | 10 |
| Pro | 2,000 | 400 |

`POST /v2/analyze` and `POST /v2/titles` each cost one call. **`GET /v2/suggest` is free** — it spends no credits at all. So are a
cached repeat, a provisional result, and any run that fails: a reservation is taken before the work and given back if the work does
not happen, so an outage never costs you anything.

One `POST /v2/titles` is **one call**, not several, however many topics it measures internally. `research.analysesRun` reports that
number for transparency and is not billed. Credits reset on the first of the calendar month, UTC, and do not roll over.

## The cheap endpoint

    GET /v2/suggest?q=how+to+start+a+podcast&geo=US

Autocomplete only: a few seconds, no allowance spent. Returns the same `suggestions` array as the full analysis plus `seedSuggestions`.
Use it to inspect how YouTube autocomplete responds around a phrase before deciding what to analyze. **A non-empty result does not
mean the phrase is worth making a video about** — the expansion will find completions around almost any sufficiently long string.
Opportunity evaluation is `POST /v2/analyze`, which is the only thing that produces `score.overall`, competition, confidence and
`score.lowSuggestionWarning`.

```json
{
  "phrase": "how to start a podcast",
  "geo": "US",
  "suggestions": [
    {
      "phrase": "how to start a podcast for beginners 2025",
      "hits": 9,
      "sources": [
        "seed",
        "how to",
        "why",
        "which",
        "is",
        "for",
        "for beginners",
        "b",
        "f"
      ]
    },
    {
      "phrase": "how to start a podcast for beginners",
      "hits": 8,
      "sources": [
        "seed",
        "how to",
        "why",
        "which",
        "is",
        "for",
        "for beginners",
        "f"
      ]
    },
    {
      "phrase": "how to start a podcast with your friends",
      "hits": 7,
      "sources": [
        "seed",
        "how to",
        "why",
        "which",
        "is",
        "w",
        "y"
      ]
    }
  ],
  "seedSuggestions": 14
}
```

## The title endpoint

    POST /v2/titles
    Content-Type: application/json

    { "title": "No Condemnation — Putting Down the Shame You Were Never Meant to Carry" }

That is a complete request. You do not supply a keyword, a search volume, a competition figure, or a brief. HitSonar:

1. identifies possible search topics from the title,
2. researches them with the same engine `POST /v2/analyze` uses,
3. checks whether each one's first page belongs to the same kind of video, and rejects the ones that do not,
4. reads how the titles that actually rank on the selected page are built,
5. scores the title you sent,
6. writes alternatives and scores every one of them.

| Field | | |
| --- | --- | --- |
| `title` | required | 2 to 120 characters. Trimmed, with runs of whitespace collapsed; otherwise kept exactly as sent, because it is scored and echoed back. |
| `geo` | optional | `US` is the only value today. Default `US`. |
| `description` | optional | What actually happens in the video, up to 800 characters. With it, nothing about the video's contents has to be inferred. |
| `targetViewer`, `format`, `angle`, `tone` | optional | Free text that steers the writing. |
| `direction` | optional | `emotional`, `curiosity`, `search`, `shorter` or `direct`. Steers the whole set. |
| `count` | optional | How many titles to return, 1 to 10. Default 10. Trims the set; it does not shrink the generation — see below. |
| `researchId` | optional | Start from an analysis your organization already has instead of deriving topics. An id belonging to another organization answers exactly as a non-existent one does. |
| `challengeTopic` | optional | Only meaningful with `researchId`. Derives and measures other topics too and lets them compete with it. Can cost analyses. |

Allow **120 seconds**: a request that has to measure three topics it has never seen does real work. It costs **one call, 5
credits**, however many topics it measures internally — `research.analysesRun` reports that for transparency and is not billed.

`count` trims rather than shrinks. The model is asked for the same 5 to 10 titles whatever you send, and the deterministic title
score still picks `recommended` from all of them — so `count: 3` gives the same top pick as `count: 10`, just fewer alternatives
beside it. Asking for fewer costs no less: it is one model request either way. `recommended` is one of the count, so `count: 3`
returns one recommendation and two alternatives.

### Two scores, and they are not the same number

| | Answers | Where it comes from |
| --- | --- | --- |
| **HitSonar Score** — `research.selected.opportunityScore` | Is this *topic* worth pursuing? | `score.overall`, from the same scoring model `POST /v2/analyze` reports. Versioned by `score.version`. |
| **Title Opportunity Score** — `titleScore.overall` | How well is this *title* built against that evidence? | `titleScoreVersion` in the same response. |

### The six dimensions, and the trade they expose

| dimension | points | asks |
| --- | ---: | --- |
| `coverage` | 20 | does the title contain the researched search phrase |
| `alignment` | 15 | is it built like the titles already ranking on that page |
| `differentiation` | 15 | does it stand apart from them |
| `clickAppeal` | 30 | does it give a qualified viewer a reason to choose this video |
| `clarity` | 10 | can a viewer tell what it means, without obvious empty bait |
| `length` | 10 | does 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 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 alone.

Appeal outweighs findability on purpose, and that is what changed in `titleScoreVersion` 3.0.0 (it was
45/45 in 2.0.0); 4.0.0 changed how Click Appeal is measured without changing any weight. Inserting the researched phrase is the half a rewrite can always improve
mechanically; the reason somebody clicks is the half it deletes by accident. Read `weight` from the
response rather than these numbers, and store `titleScoreVersion` beside any score you keep.

### The Click Appeal floor

`titleScore.appealFloor` is `null` on nearly every title. When it is not, a weak `clickAppeal` has
held `overall` **below what the dimensions add up to** — the one rule in this score that is not a
weighted sum:

| `clickAppeal` | most `overall` may be |
| --- | ---: |
| 0–9 of 30 | 69 |
| 10–14 of 30 | 79 |
| 15 and up | no cap |

It exists because 30 points still leaves 70, which is enough for a title that is easy to find and
gives a viewer no reason to choose it to read as excellent. Every dimension still reports what it
actually measured, so a caller can show a real 20 of 20 for coverage *and* show that it did not save
the title.

**If you store or re-render these, read this paragraph.** Before 3.0.0, `overall` was always the sum
of `dimensions`. It is not any more. When `appealFloor` is present it carries `rawOverall` (the sum),
`cap`, and a sentence for display. Show `overall` and explain it with `appealFloor`; re-adding the
breakdown yourself will print `rawOverall` and look like an arithmetic error.

**What `clickAppeal` is.** Click Appeal 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**. Real click performance depends on the thumbnail, the channel, the audience, the traffic
surface and what is on screen beside it, none of which a title-only function can see. **Do not
convert it into a percentage or a rate.**

**How it scores, since 4.0.0.** A token never scores on its own. A word *nominates* a candidate
structure, which earns nothing until it composes: does the structure 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 — named subject,
identifiable prior behaviour, a reversal, the cause withheld. `Why Morning Routine` contains the same
word, states nothing that could *have* a cause, and scores zero for curiosity.

Six components, internal evidence behind the one public number: relevance 0–7, information gap 0–7,
stakes 0–5, specificity 0–5, contrast 0–4, comprehension 0–2. Direct utility is a complete route — a
plain `How to install X in Y` opens no loop and still scores well, because a search-intent title wins
by satisfying a need rather than by manufacturing mystery. 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.

Capitalisation and punctuation cannot move it — a colon, an exclamation mark and ALL CAPS all score identically, by
construction.

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 model, either — both are computed, which is why 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`.

### Sample response

Shortened; `alternatives` and the style profile's full counts are cut where marked.

```json
{
  "input": { "title": "No Condemnation — Putting Down the Shame You Were Never Meant to Carry", "direction": null },
  "context": {
    "subject": "condemnation, guilt and shame",
    "viewerProblem": "feeling condemned or carrying shame after being forgiven",
    "contentIntent": "a Christian teaching or devotional",
    "contentFormat": "teaching",
    "candidateTopics": ["overcoming shame", "no condemnation", "freedom from guilt", "romans 8", "letting go of shame"]
  },
  "research": {
    "status": "selected",
    "selected": {
      "phrase": "overcoming shame",
      "researchId": "res_8Kd2mQ1pR9vNc4w",
      "opportunityScore": 67,
      "analysisStatus": "complete",
      "demand": 61,
      "competition": 39,
      "searchBandLabel": "10K to 100K",
      "trendDirection": "steady",
      "intentFit": "strong",
      "intentReason": "The ranking videos are mostly teaching or devotional content (12 of 20 titles), which matches your video's apparent intent.",
      "reused": false
    },
    "considered": [
      {
        "phrase": "overcoming shame",
        "researchId": "res_8Kd2mQ1pR9vNc4w",
        "opportunityScore": 67,
        "analysisStatus": "complete",
        "hasRanking": true,
        "intentFit": "strong",
        "decision": "selected",
        "reason": "HitSonar Score 67; the first page is the same kind of video; the analysis is complete; its first-page titles could be read for style"
      },
      {
        "phrase": "no condemnation",
        "researchId": "res_3Nb7vQ8mR2pKc1x",
        "opportunityScore": 74,
        "analysisStatus": "complete",
        "hasRanking": true,
        "intentFit": "mismatch",
        "decision": "rejected",
        "reason": "The first-page results are primarily music content (14 of 20 titles) while this video appears to be teaching or devotional content."
      }
    ],
    "quality": "complete",
    "skipped": [],
    "analysesRun": 2
  },
  "titleStyle": {
    "sampleSize": 18,
    "duplicatesDropped": 2,
    "averageCharacters": 54,
    "medianCharacters": 52,
    "shortestCharacters": 29,
    "longestCharacters": 88,
    "typicalRange": { "low": 42, "high": 65 },
    "separators": {
      "colon": { "count": 11, "share": 0.61 },
      "pipe": { "count": 3, "share": 0.17 },
      "hyphen": { "count": 1, "share": 0.06 },
      "enDash": { "count": 0, "share": 0 },
      "emDash": { "count": 2, "share": 0.11 },
      "slash": { "count": 0, "share": 0 }
    },
    "quotes": { "count": 3, "share": 0.17, "straight": 2, "curly": 1 },
    "questions": { "count": 2, "share": 0.11 },
    "numberLed": { "count": 5, "share": 0.28 },
    "topicPosition": { "beginning": 9, "firstHalf": 13, "secondHalf": 3, "absent": 2 },
    "shapes": [{ "shape": "Topic: Payoff", "count": 8, "share": 0.44 }],
    "commonShapes": ["Topic: Payoff", "Number + Problem + Promise"],
    "observations": [
      "11 of 18 titles use a colon.",
      "13 of 18 titles place the topic in the first half.",
      "Typical title length: 42 to 65 characters (median 52).",
      "Most common structure: Topic: Payoff."
    ]
  },
  "currentTitle": {
    "title": "No Condemnation — Putting Down the Shame You Were Never Meant to Carry",
    "titleScore": {
      "overall": 46,
      "characters": 70,
      "dimensions": [
        { "key": "coverage", "label": "Topic coverage", "weight": 20, "earned": 9, "note": "Only partly covers the researched topic." }
      ],
      "strongest": { "key": "clarity", "label": "Clarity and credibility", "weight": 10, "earned": 10, "note": "Says plainly what it is, with nothing overclaimed." },
      "weakest": { "key": "alignment", "label": "Search-result alignment", "weight": 15, "earned": 0, "note": "Does not pick up any pattern working on the first page." },
      "appealFloor": null
    }
  },
  "recommended": {
    "title": "Overcoming Shame: How to Stop Carrying What You Were Forgiven For",
    "rationale": "Names the search plainly and promises the viewer a way out of it rather than a description of the problem.",
    "titleScore": { "overall": 85, "characters": 65, "dimensions": [], "strongest": {}, "weakest": {}, "appealFloor": null }
  },
  "alternatives": [],
  "brief": { "contentSupplied": false },
  "titleScoreVersion": "4.0.0",
  "usage": { "credits": { "used": 60, "limit": 2000, "remaining": 1940, "resetsAt": "2026-10-01T00:00:00.000Z", "perCall": 5 } }
}
```

### The fields worth mapping

| Field | Read it as |
| --- | --- |
| `research.status` | `selected`, or `no_suitable_topic` when every candidate was rejected on search intent or had no analysis. Titles still come back in that case, from the brief alone — **do not present them as search-backed.** |
| `research.quality` | `complete`, `degraded`, `provisional` or `none`. A recommendation resting on `none` must not look like one resting on `complete`. |
| `research.considered[]` | Every finalist and what happened to it. The rejections are the useful part: they say which search angles HitSonar looked at and would not recommend, and why, with the count behind it. |
| `research.analysesRun` | New uncached analyses this request spent from the monthly allowance. Reused and cached ones are not counted. |
| `titleStyle` | Counted from the selected page's real titles, never produced by a model. Prevalence only: a colon is worth using on a page that uses colons because a viewer scanning it has already seen several. **Punctuation does not make a video rank.** Null when no first page was read. |
| `titleStyle.topicPosition` | `beginning` is a subset of `firstHalf`. `firstHalf + secondHalf + absent` is the sample. |
| `context` | A reading of your title's words, used to decide what to measure. Hypotheses, not evidence. It contains no number, and nothing about what the video contains. |
| `brief.contentSupplied` | `false` when you sent no `description`, so the brief was inferred from the title. Nothing about the video's contents was invented either way. |
| `usage.credits` | Credits for the calendar month, in **credits rather than calls** — divide `remaining` by `perCall` for calls left. One pool: analyses, titles and related-topic searches all draw on it. Null when metering is not configured. |

### What this endpoint will not do

It will not invent a subject. `"Episode 11"`, `"Sunday Message"` and `"My New Video"` name an occasion or the fact that a video
exists rather than anything researchable, and they get `422 insufficient_context` rather than a guess. Send a `description` or a
more descriptive working title.

It will not invent what is in the video. Given a title alone it writes about the subject the title states, and does not introduce
lists, statistics, scripture references, names, events or results that were not supplied to it.

It will not copy a competitor. It borrows structure — `Topic: Payoff` on a page full of `Topic: Payoff` — and never wording.

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, and the response says so in `research.considered`.

## The bulk scoring endpoint

`POST /v2/titles/score` — score titles you already have against research you already paid for.

```json
{
  "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"
  ]
}
```

**It costs nothing.** No credits, no analyses, no title generations. It performs no YouTube call, no
autocomplete, no Trends request and no model request — it reads one saved analysis and runs a pure
function. The per-key rate limit still applies; that is what bounds it, not a meter.

### Use this instead of calling the title endpoint once per title

That is the whole point. Six candidates through `POST /v2/titles` would be six requests, thirty
credits, and — worse than the cost — **six different pieces of research**, so the six scores would not
be comparable with each other. One request here scores all six against one piece of evidence.

`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 API key's organization; an id from
another organization answers exactly as a non-existent one does, so existence cannot be probed.

### What comes back

| field | |
| --- | --- |
| `scored[]` | one entry per title sent, **in the order sent**, each with `title` and `titleScore` |
| `researchId`, `phrase`, `geo` | the evidence these were scored against, echoed for storage |
| `opportunityScore` | `score.overall` for the TOPIC, unchanged by anything here |
| `titleStyle` | how the first page's own titles are built, or null when the analysis has no ranking |
| `titleScoreVersion` | store 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 makes a score
cacheable and explainable. Sort by `titleScore.overall` yourself if you want a ranking. (`POST
/v2/titles` does the opposite on purpose: there the titles are competing to be recommended, so
near-duplicates score each other down.)

`subject` is optional and only affects `clickAppeal`, 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 is scored here exactly as the same title is scored inside a recommendation — same function,
same context, same number.

## Health

    GET /v2/health

Open, no key, and no rate-limit or usage headers — there is no key to report a state for.

```json
{ "ok": true, "ranking": true, "clients": true, "volume": "modeled", "scoringVersion": "1.2.0", "apiVersion": "3.1.0",
  "geos": ["US"], "accounts": true, "youtubeCache": true, "titles": true }
```

`volume` is `modeled` or a provider's name. `titles` says whether a model provider is configured for AI title generation. When it
is `false`, `POST /v2/titles` answers `503` on this deployment: HitSonar never falls back to gluing a keyword into a template, so
there is no degraded success for that endpoint.

## Example call

```ts
const res = await fetch("https://app.hitsonar.com/v2/analyze", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.HITSONAR_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ phrase, geo: "US" }),
  signal: AbortSignal.timeout(60_000),
});

const requestId = res.headers.get("X-Request-Id");
if (!res.ok) {
  const { error } = await res.json();
  // error.code: "unauthorized" | "invalid_request" | "rate_limited"
  //           | "organization_limit" | "global_limit" | "service_unavailable"
  throw new Error(`HitSonar ${error.code}: ${error.message} (${requestId})`);
}
const analysis = await res.json();
```

## SDKs and OpenAPI

`@hitsonar/sdk` (npm) and `hitsonar` (PyPI) wrap all of the above — `analyze`, `suggest`, `generateTitles` / `generate_titles` and
`health` — with typed models, the error classes implied by the table above, and automatic rate-limit/usage tracking. Guides: https://hitsonar.com/developers/sdks/typescript and
https://hitsonar.com/developers/sdks/python. The machine-readable OpenAPI 3.1 document this reference is generated alongside:
https://app.hitsonar.com/v2/openapi.json (and `.../v2/openapi.yaml`). The frozen /v1 contract has its own spec at
https://app.hitsonar.com/v1/openapi.json.
