---
title: "Create a brief from an opportunity"
method: POST
path: "/opportunities/{id}/brief"
tags: ["Opportunities"]
---

# Create a brief from an opportunity

`POST /opportunities/{id}/brief`

## Request

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

Accept a gap opportunity: mint a content brief from it and attach the brief to the row. Requires the `write:opportunities` scope AND an API key linked to a user (the linked user becomes the brief's `created_by`); an unlinked key is refused 403 before any lookup, so it never learns whether the id exists.

Only `gap_` ids are accepted. `prog_` ids return 400 (programmatic opportunities use batch generation) and `decay_` ids return 400 (use the update action). A `gap_` id whose suffix is not a valid UUID returns 400.

Ordering is load-bearing and observable. The opportunity is fetched first, so a 404 costs nothing and never mutates the row. The monthly brief allowance is checked BEFORE the row is claimed, so an at-cap organization is refused with the row untouched rather than left stranded at `in_progress`. Only then is the row claimed with a conditional UPDATE to `status = in_progress`; that single statement is also the closed-check and the concurrency check, so a dismissed/completed row, a row that already carries a `suggested_brief_id`, and a row another live accept is holding all lose it and get 409. Any failure after the claim releases it and restores the prior status.

Billing and metering: one accepted call consumes one brief from the organization's monthly brief allowance (calendar-month window; imported briefs and regeneration forks do not count). When the allowance is exhausted the call returns 429 with code `brief_limit_exceeded` and a truthful `Retry-After` giving the seconds until the monthly window rolls - this is a QUOTA, not a burst limit, so retrying before that instant cannot succeed and an SDK's default backoff must not re-hammer it. When the organization's subscription is canceled, paused, past due beyond grace, or its trial has expired, the call returns 403 with code `subscription_inactive` instead. A topic matching one of the organization's never-write-about terms returns 422 and no quota is consumed.

Success is 201 and asynchronous: the brief row exists immediately with status `pending`, and outline generation plus full research run in the background. Poll `GET /api/v1/briefs/{brief_id}` (or `/briefs/{brief_id}/status`) for readiness. The brief inherits the opportunity's cluster (`cluster_id`) and site, and its relevance bucket rides along - anything but `confident` is held for review at dispatch rather than auto-published.

## Path parameters

- `id` string, required

## Response `201`

Brief creation started

- object
  - `data` object
    - `brief_id` string, uuid: The new brief. Status is `pending` until outline generation and research finish.
    - `topic` string: The brief's topic: the opportunity title with the `New Article: ` / `Update: ` prefix stripped, falling back to `Opportunity <first 8 chars of source_id>` when the title is empty.
    - `opportunity_id` string: The composite id this brief was created from, echoed back.
    - `message` string: Fixed guidance string: `Brief creation started. Use GET /api/v1/briefs/{brief_id} to check status.`

## Other responses

- `400`: Rejected before any work. Two body shapes: the structured Error object (code `invalid_param`, `param: "id"`) for a missing/too-short id, a non-`gap_` prefix, or a non-UUID suffix; and the plain `{ "data": { "error": "..." } }` envelope for `prog_` ids (`Programmatic opportunities use batch generation, not individual briefs`), `decay_` ids (`Decay opportunities should use the update action`).
- `401`: Authentication failed
- `403`: Either the API key is not linked to a user (`API key must be linked to a user to create briefs`, plain `{ "data": { "error": "..." } }` envelope), or the key lacks the `write:opportunities` scope, or the organization's subscription is inactive - canceled, paused, past due beyond grace, or an expired trial (structured Error, code `subscription_inactive`).
- `404`: Resource not found
- `409`: The opportunity exists but cannot be accepted: it is dismissed, already published, already covered by content on the site, already carries a brief, or another accept is holding it (claims older than 10 minutes are treated as abandoned and self-heal). Structured Error with code `opportunity_closed`. 409 rather than 404 because the row is still fetchable - it is the STATE that makes the request invalid.
- `422`: The opportunity's topic matches one of the organization's never-write-about terms, so no brief is minted and no quota is consumed. Plain `{ "data": { "error": "..." } }` envelope.
- `429`: The organization's monthly brief allowance is exhausted (structured Error, code `brief_limit_exceeded`), or the shared API tier/budget limit denied the request. `Retry-After` on the brief-limit denial carries the seconds until the calendar-month counting window rolls, so it can be minutes or weeks - retrying sooner cannot succeed. The opportunity row is left untouched (the quota gate runs before the claim), so the same call can be replayed after the reset.
- `500`: Brief creation failed (`Failed to create brief from opportunity`). Any claim taken on the row is released first, so the opportunity is not left wedged at `in_progress`. 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)
