---
title: "Get an opportunity"
method: GET
path: "/opportunities/{id}"
tags: ["Opportunities"]
---

# Get an opportunity

`GET /opportunities/{id}`

## Request

- Base URL: `https://next.frase.io/api/v1`
- URL: `https://next.frase.io/api/v1/opportunities/{id}`
- Auth: API key in header `X-API-KEY`

Full detail for a single opportunity, resolved across the source tables by the id prefix: `gap_` reads content_gap_opportunities, `prog_` reads programmatic_opportunities, `decay_` reads performance_alerts. The id is `<prefix>_<uuid>`; a malformed UUID suffix is refused with 400 (not 500). A bare UUID with no prefix is tolerated and resolves as a gap opportunity. Everything the list route returns is present, plus the detail-only fields `source_id`, `source_table`, `traffic_source`, `estimated_clicks`, `existing_content_url`, `parameters`, `suggested_brief_id`, `brief_status`, `content_id`, `content_status` and `methodology` (the transparency block: data sources, analysis, calculation, assumptions, confidence factors). Requires the `read:opportunities` scope. Reads are org-scoped, so an id belonging to another organization returns 404, indistinguishable from one that does not exist. A gap row whose keyword fails the shared junk-keyword filter also resolves to 404 - the same rows the hub hides are unavailable here, so an agent cannot plan off a row a human would never see. `relevance` is derived at read time (cluster fit + first-party evidence + never-write-about terms) and is null when no verdict was derived.

### 2026-09-07 - decay_ ids resolve decay alerts only

A `decay_<id>` whose alert is a ranking or traffic alert now answers 404. The list never emitted such an id (it lists decay alerts only), and the detail gives the list's answer for every source.

### 2026-09-07 - closed rows report their status
A completed or dismissed programmatic row and a resolved decay alert still resolve by their id, and now carry that status (`completed` or `dismissed`) where they carried `new`; a resolved alert is closed whoever resolved it, and reads `dismissed` only when the dismissal wrote its own default words. The lists never show them; a stored id reads its final state.

## Path parameters

- `id` string, required

## Response `200`

Opportunity detail

- object
  - `data` object
    - `id` string: Composite id, echoed back (gap_/prog_/decay_ + UUID).
    - `source_id` string, uuid: Primary key in the source table.
    - `source_table` 'content_gap_opportunities' | 'programmatic_opportunities' | 'performance_alerts'
    - `title` string
    - `description` string
    - `source` 'gsc' | 'competitor' | 'programmatic' | 'decay'
    - `action` 'update' | 'create' | 'batch'
    - `view` 'quick-wins' | 'growth' | 'strategic'
    - `scores` object
      - `overall` number: 0-100 weighted composite.
      - `effort` object
        - `score` number: 0-100, lower = easier.
        - `factors` string[]
      - `impact` object
        - `score` number: 0-100, higher = more valuable.
        - `factors` string[]
      - `speed` object
        - `score` number: 0-100, higher = faster to rank.
        - `factors` string[]
      - `strategic` object
        - `score` number: 0-100, higher = more strategic value.
        - `factors` string[]
    - `confidence` 'high' | 'medium' | 'low'
    - `relevance` object, nullable: How far this topic travels. Same projection as the list route (bucket + reason only). off_strategy rows are excluded from default lists; uncertain rows are demoted and never auto-publish.
      - `bucket` 'confident' | 'uncertain' | 'off_strategy'
      - `reason` string: Plain-language clause. Reads `matches one of your never-write-about terms` when the off_strategy verdict came from an excluded term.
    - `estimated_traffic` integer: Estimated monthly visits.
    - `traffic_source` 'gsc_clicks' | 'search_volume' | 'none': Where the traffic estimate came from.
    - `estimated_clicks` integer
    - `search_volume` integer, nullable
    - `current_position` number, nullable
    - `difficulty` integer, nullable
    - `page_count` integer, nullable: Programmatic rows only: how many pages the batch would produce.
    - `parameters` object[], nullable: Programmatic rows only: the per-page parameter set. Free-form objects.
    - `programmatic_type` 'location' | 'comparison' | 'integration' | 'custom', nullable
    - `site_id` string, nullable
    - `site_domain` string, nullable
    - `existing_content_id` string, nullable
    - `existing_content_title` string, nullable
    - `existing_content_url` string, nullable
    - `cluster_id` string, nullable
    - `cluster_name` string, nullable
    - `competitors` object[], nullable
      - `domain` string
      - `position` integer, nullable
      - `url` string, nullable
    - `suggested_brief_id` string, nullable: Set once a brief has been minted from this opportunity. A non-null value means POST /opportunities/{id}/brief will refuse with 409.
    - `brief_status` 'draft' | 'research_preview' | 'pending' | 'ready' | 'failed', nullable
    - `content_id` string, nullable
    - `content_status` 'draft' | 'generating' | 'review' | 'published' | 'archived', nullable
    - `methodology` object, nullable: Transparency block: how the estimate was produced.
      - `data_sources` object[]
        - `type` 'gsc' | 'competitor_crawl' | 'keyword_research' | 'brand_settings' | 'site_audit'
        - `name` string
        - `description` string
        - `last_updated` string, date-time, nullable
        - `quality` 'verified' | 'estimated' | 'inferred'
      - `analysis` object: What was analyzed. Keys are present only when the producer had them.
        - `competitorsAnalyzed` string[]
        - `pagesAnalyzed` integer
        - `keywordsAnalyzed` integer
        - `timeRange` string
      - `calculation` object
        - `formula` string
        - `inputs` object
        - `result` number
        - `unit` string
      - `assumptions` string[]
      - `confidence_factors` object[]
        - `factor` string
        - `value` string
        - `impact` 'positive' | 'negative' | 'neutral'
    - `status` 'new' | 'reviewed' | 'in_progress' | 'completed' | 'dismissed' | 'auto_resolved' | 'unknown': `unknown` is a read-time value for a DB string outside the known set; no row is created with it.
    - `detected_at` string, date-time
    - `last_analyzed_at` string, date-time

## Other responses

- `400`: Malformed opportunity id - missing, shorter than 5 characters, or a suffix that is not a valid UUID (`Invalid opportunity ID format. Expected: gap_UUID, prog_UUID, or decay_UUID`). Note the resolver is lenient about the prefix itself: a bare UUID (no prefix) passes the guard and resolves as a gap opportunity. Not retryable. This body is the plain `{ "data": { "error": "..." } }` envelope, not the structured Error object.
- `401`: Authentication failed
- `403`: Forbidden - action not allowed on this resource
- `404`: Resource not found
- `429`: Rate limit exceeded
- `500`: Lookup failed (`Failed to get opportunity`). Plain `{ "data": { "error": "..." } }` envelope.

---

[API](https://skmtc.dev/frase/apis/frase-api.md) · [All operations](https://skmtc.dev/frase/apis/frase-api/llms.txt) · [OpenAPI document](https://skmtc.dev/frase/apis/frase-api/revisions/7fb75d89f7a4?raw)
