---
title: "Resolve a content name to a content ID"
method: POST
path: "/content/resolve"
tags: ["Content"]
---

# Resolve a content name to a content ID

`POST /content/resolve`

## Request

- Base URL: `https://next.frase.io/api/v1`
- URL: `https://next.frase.io/api/v1/content/resolve`
- Auth: API key in header `X-API-KEY`

Look up content in your organization by exact title, case-insensitively, so an agent can turn a human-supplied name into the `content_id` every other content endpoint needs. Requires the `read:content` scope (a write-only key cannot enumerate titles) and a plan with content optimization; orgs below that plan get 403. Read-only, no side effects.

The match is exact-but-case-insensitive (`lower(title) = lower(name)`), scoped to your organization, and excludes trashed content. It is NOT a fuzzy or partial search. Always branch on `status`:
- `ok` — exactly one match; `content_id` is returned and nothing else.
- `not_found` — no match; only `status` is present.
- `needs_disambiguation` — two or more matches; `candidates` (at most 10), `total_matches`, and `truncated` are returned. When `truncated` is true, `total_matches` is a LOWER BOUND (11), not a real count: at most 11 rows are ever read, so treat it as "at least this many" and ask for a more specific name rather than paginating. Candidates are ordered newest-updated first.

`name` is trimmed, must be 1-255 characters and must not contain control characters (any byte below 0x20, or 0x7F) — those are rejected with 400 rather than smuggled into audit logs.

CORS preflight is supported (OPTIONS returns 204; allowed methods POST, OPTIONS; allowed headers Content-Type, X-API-KEY).

## Request body

- object
  - `name` string, required: Required. The content title to resolve. Trimmed before matching; must be 1-255 characters after trimming and must not contain control characters.

## Response `200`

Resolution result. Always 200 — a name that matches nothing is `status: not_found`, not a 404.

- object
  - `data` object
    - `status` 'ok' | 'not_found' | 'needs_disambiguation': Which of the three result shapes this is. Always present.
    - `content_id` string, uuid: Present only when status is `ok`: the single matching content row.
    - `candidates` object[]: Present only when status is `needs_disambiguation`. At most 10 entries, ordered by most recently updated first.
      - `id` string, uuid
      - `title` string
      - `slug` string, nullable
      - `status` 'draft' | 'generating' | 'review' | 'published' | 'archived'
      - `word_count` integer: 0 when the row has no recorded word count.
      - `updated_at` string, date-time
    - `total_matches` integer: Present only when status is `needs_disambiguation`. Exact when `truncated` is false; a lower bound of 11 when `truncated` is true.
    - `truncated` boolean: Present only when status is `needs_disambiguation`. True when more than 10 rows matched, in which case `total_matches` is a lower bound and you should re-ask with a more specific name.

## Other responses

- `400`: Validation failed
- `401`: Authentication failed
- `403`: Either the plan gate or the credential. The plan gate (org lacks content optimization) answers with the data-wrapped shape `{"data": {"error": "Content optimization requires <plan> plan or higher."}}`. Credential refusals (missing `read:content` scope, IP not allowlisted, expired trial) answer with the standard error envelope.
- `429`: Rate limit exceeded
- `500`: Lookup failed

---

[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)
