Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Frase Templates API
version: 1.0.0
description: 'The Frase API provides programmatic access to your SEO content platform. Use it to manage content, research, audits, AI visibility tracking, and more. ## Authentication
All API requests require authentication using an API key. Include your key in the `X-API-KEY` header:
```
X-API-KEY: sk_live_your_api_key_here
```
For legacy compatibility, the `token` header is also supported. ## Rate Limits
Three independent limits apply, each with its own headers and error code. Headers are returned on `429` responses, not on successful ones. **Per-minute burst** - 60/min on trial and Starter, 120 Professional, 300 Scale, 1,000 Enterprise. Error code `rate_limit_exceeded`. - `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (epoch seconds)
**Sustained hourly ceiling** - 2,000/hour trial, 3,000 Starter, 5,000 Professional, 15,000 Scale, 50,000 Enterprise. Error code `hourly_limit_exceeded`. - `X-RateLimit-Hourly-Limit`, `X-RateLimit-Hourly-Remaining`, `X-RateLimit-Hourly-Reset` (epoch seconds)
**Monthly request budget** - error code `monthly_budget_exceeded`, headers `X-Monthly-Limit`, `X-Monthly-Used`, `X-Monthly-Remaining`. Every `429` carries `Retry-After` in seconds. On the hourly ceiling this can be up to 3,600, so clients should honour it rather than retrying on a short fixed backoff - a retry sooner than `Retry-After` cannot succeed. ## Errors
The API uses standard HTTP status codes and returns errors in a consistent format:
```json
{
"error": {
"type": "authentication_error",
"code": "invalid_api_key",
"message": "Invalid API key provided.",
"doc_url": "https://docs.frase.io/api/errors#invalid_api_key"
}
}
```
## Changelog
Breaking changes are listed here, newest first. "Breaking" means a field that was always present with a value can now be `null`, or an existing value now means something different — not that a field was removed or renamed. Additive fields are not listed. ### 2026-08-31 — Fact `sources[]` may omit `url`, and minted facts default to `approved`
- `GET /briefs/{id}/facts`, `PATCH /briefs/{id}/facts/{factId}` → a `sources[]` entry may now carry only `title`, with no `url` or `domain`. Research names a publisher far more often than it captures a link (793 of 801 plan-evidence items in production), and those names were previously discarded rather than returned. Read `url` defensively. - `GET /briefs/{id}/facts` → `approval` on a newly minted fact is now `approved` rather than `pending`. A client polling for `pending` facts to review will see far fewer; rejection is unchanged and remains permanent. Facts the writer asserted in its own output (`provenance: generated`) are still minted `pending`. ### 2026-08-24 — Content Guard''s hasPublishedUrl is now recoverability, not the stored URL
- `GET /content/{id}/monitoring` → `hasPublishedUrl`: was a judgment on the STORED URL (false for a relative path even when the page was live). It now answers "could Guard watch this article right now" — the stored URL when absolute, or an address the server recovers (CMS inventory; FraseCMS re-derivation from current settings when a live published post exists). `true` no longer implies the stored URL itself is absolute, and a FraseCMS article published before domain setup flips to `true` when setup finishes, with no re-publish. It remains exactly the condition enabling enforces. - `publishedUrlIssue` is now the RESIDUAL after recovery failed, not a raw-column classification — "not_absolute" can also mean the FraseCMS post is not live or FraseCMS is disabled for the site. - `POST /content/{id}/publish` → `url` (frase_cms): now derived from the site''s current CMS settings rather than the adapter''s host-of-the-moment value; the same URL is stored on the content row and sent in the content.published webhook.'
contact:
name: Frase Support
url: https://frase.io
email: support@frase.io
license:
name: Proprietary
url: https://www.frase.io/company/terms
servers:
- url: https://next.frase.io/api/v1
description: Production
- url: /api/v1
description: This host
security:
- ApiKeyAuth: []
- BearerAuth: []
tags:
- name: Templates
description: Content templates for standardized outlines and formatting
paths:
/templates/apply:
post:
operationId: applyTemplate
summary: Resolve a template into a ready-to-use outline
description: 'Loads one of your templates and returns it resolved into the shape the brief-creation and playbook flows consume: the outline sections mapped to outline entries (each tagged `source: "template"`), the template''s field defaults, and its boilerplate blocks. Requires the `read:content` scope. Read-only — nothing is created or modified.
The lookup is organization-scoped and skips soft-deleted rows, and a template that exists but is disabled is refused as well; both refusals come back as 404 in the plain `{ "data": { "error": ... } }` envelope with the reason in `error` (`Template not found` / `This template is currently disabled`). Note the response does not carry the template''s `content_type`.
The three payload keys are snake_case, but the objects inside them keep the stored camelCase field names.'
tags:
- Templates
security:
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- template_id
properties:
template_id:
type: string
format: uuid
description: Required. The template to apply. Must be a UUID.
responses:
'200':
description: The resolved template
headers:
X-Request-ID:
schema:
type: string
description: Correlation id for this request
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
outline:
type: array
description: The template's sections mapped to outline entries. Empty array when the template stores no sections.
items:
type: object
description: An outline section. Field names are camelCase here (unlike the snake_case `ContentOutlineSection` used by the brief endpoints).
properties:
heading:
type: string
level:
type: integer
minimum: 1
maximum: 6
keyPoints:
type: array
items:
type: string
suggestedWordCount:
type: integer
generationMode:
type: string
enum:
- ai
- manual
description: Defaults to `ai` when the section does not set one.
isRequired:
type: boolean
description: Defaults to true when the section does not set one.
source:
type: string
enum:
- template
description: Always `template` on this path.
field_defaults:
allOf:
- $ref: '#/components/schemas/TemplateFieldDefaults'
description: The template's stored defaults, or `{}` when it has none. May also carry Smart Flow keys (`authoritySource`, `blogFormat`, `guideFormat`, `skillLevel`, `blogContext`, `guideContext`).
boilerplate_blocks:
type: array
description: The template's boilerplate blocks, or `[]` when it has none.
items:
$ref: '#/components/schemas/BoilerplateBlock'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: The API key lacks the `read:content` scope (`insufficient_scope`), the caller's IP is not allow-listed (`ip_not_allowed`), or the organization's subscription/trial is no longer active.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 'The template does not exist, is soft-deleted, belongs to another organization, or is disabled. Returned in the plain `{ "data": { "error": ... } }` envelope, not the structured error object.'
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
error:
type: string
example:
data:
error: Template not found
'429':
$ref: '#/components/responses/RateLimited'
'500':
description: Unhandled failure, including a malformed/non-JSON request body.
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
error:
type: string
example:
data:
error: Failed to apply template
/templates/extract:
post:
operationId: extractTemplate
summary: Extract a reusable template from a brief or an article
description: 'Derives a template from something you already have and saves it. Requires the `write:content` scope.
With `source_type: "brief"` the brief''s outline becomes the template sections and the brief''s word count, language, country and resolved tone become the field defaults. With `source_type: "content"` the article''s own brief outline is preferred when it has one; otherwise the HTML (or Markdown) headings of the body are parsed into sections with per-section word counts, falling back to a single `Main Content` section when the body has no headings. Both lookups are organization-scoped and skip soft-deleted rows. Extraction never produces boilerplate blocks — `boilerplate_blocks` always comes back empty.
This endpoint writes: on success the extracted template is created for your organization (a `Template from {topic or title}` name unless you pass `name`), so 201 means a new template exists and `data.id` is its id. The saved rows are clamped to the content type''s allowed word-count range, while the `template` echoed in the response is the pre-clamp extraction — re-read the template via `GET /templates/{id}` if you need the stored values.
One special case returns 200 instead of 201: if the source yields no outline sections, nothing is saved and the response carries `saved: false` plus a `message` explaining that sections must be added manually.'
tags:
- Templates
security:
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- source_type
- source_id
properties:
source_type:
type: string
enum:
- brief
- content
description: Required. Whether `source_id` names a brief or a content item.
source_id:
type: string
format: uuid
description: Required. The brief or content item to extract from. Must be a UUID.
name:
type: string
description: Optional name for the saved template. Truncated to 255 characters. Defaults to the generated `Template from ...` name. Ignored on the unsaved (no sections) path.
responses:
'200':
description: Extracted but NOT saved — the source produced no outline sections
headers:
X-Request-ID:
schema:
type: string
description: Correlation id for this request
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
template:
type: object
properties:
name:
type: string
description:
type: string
content_type:
type: string
nullable: true
outline_sections:
type: array
description: Always empty on this path.
items:
$ref: '#/components/schemas/TemplateSectionDef'
boilerplate_blocks:
type: array
items:
$ref: '#/components/schemas/BoilerplateBlock'
field_defaults:
$ref: '#/components/schemas/TemplateFieldDefaults'
source_type:
type: string
enum:
- from_brief
- from_content
source_content_id:
type: string
format: uuid
nullable: true
saved:
type: boolean
enum:
- false
message:
type: string
example:
data:
template:
name: Template from Q3 launch post
description: Extracted from brief "Q3 launch post"
content_type: null
outline_sections: []
boilerplate_blocks: []
field_defaults: {}
source_type: from_brief
source_content_id: null
saved: false
message: Template extracted but not saved — no outline sections found. Add sections manually via POST /templates.
'201':
description: Template extracted and saved
headers:
X-Request-ID:
schema:
type: string
description: Correlation id for this request
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
format: uuid
description: Id of the newly created template.
template:
type: object
properties:
name:
type: string
description: The stored (trimmed) name.
description:
type: string
content_type:
type: string
nullable: true
description: The brief's content type, or the article's type, as stored on the source; null when the source has none.
outline_sections:
type: array
description: The extracted sections BEFORE the save-time word-count clamping.
items:
$ref: '#/components/schemas/TemplateSectionDef'
boilerplate_blocks:
type: array
description: Always empty — extraction does not produce boilerplate.
items:
$ref: '#/components/schemas/BoilerplateBlock'
field_defaults:
allOf:
- $ref: '#/components/schemas/TemplateFieldDefaults'
description: Word count, language, country, resolved tone and (content sources) meta title format, as extracted before clamping.
source_type:
type: string
enum:
- from_brief
- from_content
description: '`from_brief` for a brief source; `from_content` for a content source, including when the outline was read through the article''s brief.'
source_content_id:
type: string
format: uuid
nullable: true
description: The source content id, or null for a brief source.
'400':
description: 'Either a request-validation failure (`invalid_param` on `source_type` / `source_id`, in the structured error envelope) or a save refusal, which uses the plain `{ "data": { "error": ... } }` envelope: an empty (whitespace-only) name, a name that collides with an existing template in your organization (`A template with this name already exists`), an invalid heading-level sequence, or your plan''s custom-template limit being reached. (An over-long `name` cannot occur - the route truncates to 255 before the check. A non-string `name` is a 500, not a 400.)'
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/Error'
- type: object
properties:
data:
type: object
properties:
error:
type: string
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: The API key lacks the `write:content` scope (`insufficient_scope`), the caller's IP is not allow-listed (`ip_not_allowed`), or the organization's subscription/trial is no longer active.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 'The brief or content item does not exist, is soft-deleted, or belongs to another organization. Returned in the plain `{ "data": { "error": ... } }` envelope, not the structured error object.'
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
error:
type: string
example:
data:
error: Brief not found
'429':
$ref: '#/components/responses/RateLimited'
'500':
description: Unhandled failure, including a malformed/non-JSON request body.
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
error:
type: string
example:
data:
error: Failed to extract template
/templates:
get:
operationId: listTemplates
summary: List templates
description: Retrieve all content templates in your organization. Supports pagination and filtering by content type.
tags:
- Templates
security:
- ApiKeyAuth: []
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: page_size
in: query
schema:
type: integer
default: 20
maximum: 100
- name: content_type
in: query
description: Filter templates by content type
schema:
type: string
enum:
- blog
- guide
- landing
- product
- comparison
responses:
'200':
description: List of templates
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedTemplates'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/templates/{id}:
get:
operationId: getTemplate
summary: Get template
description: Retrieve a specific content template by ID.
tags:
- Templates
security:
- ApiKeyAuth: []
parameters:
- name: id
in: path
required: true
description: Template ID
schema:
type: string
format: uuid
responses:
'200':
description: Template details
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/Template'
request_id:
type: string
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
delete:
operationId: deleteTemplate
summary: Delete template
description: Delete a template. System templates cannot be deleted.
tags:
- Templates
security:
- ApiKeyAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: Template ID
responses:
'200':
description: Template deleted
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
deleted:
type: boolean
request_id:
type: string
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: System template cannot be deleted
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
components:
schemas:
Template:
type: object
description: A content template defining outline structure, boilerplate blocks, and field defaults.
properties:
id:
type: string
format: uuid
name:
type: string
description:
type: string
nullable: true
content_type:
type: string
enum:
- blog
- guide
- landing
- product
- comparison
nullable: true
outline_sections:
type: array
items:
$ref: '#/components/schemas/TemplateSectionDef'
section_count:
type: integer
description: Number of sections in the outline
total_word_count:
type: integer
description: Sum of suggested word counts across all sections
boilerplate_blocks:
type: array
items:
$ref: '#/components/schemas/BoilerplateBlock'
nullable: true
field_defaults:
allOf:
- $ref: '#/components/schemas/TemplateFieldDefaults'
nullable: true
source_type:
type: string
nullable: true
description: Source from which this template was extracted
is_system:
type: boolean
description: Whether this is a system-provided template
is_default:
type: boolean
description: Whether this is the default template
is_enabled:
type: boolean
description: Whether this template is enabled
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
nullable: true
PaginatedTemplates:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Template'
pagination:
$ref: '#/components/schemas/Pagination'
request_id:
type: string
Error:
type: object
properties:
error:
type: object
properties:
type:
type: string
code:
type: string
message:
type: string
param:
type: string
doc_url:
type: string
request_id:
type: string
BoilerplateBlock:
type: object
required:
- id
- position
- content
- label
description: A reusable content block that can be inserted at specific positions in generated content.
properties:
id:
type: string
minLength: 1
description: Unique identifier for the block
position:
type: string
enum:
- before_section
- after_section
- header
- footer
description: Where to insert the block
anchorSectionHeading:
type: string
description: Section heading to anchor to (for before_section/after_section positions)
content:
type: string
maxLength: 50000
description: The boilerplate content (max 50,000 characters)
label:
type: string
minLength: 1
description: Display label for the block
TemplateSectionDef:
type: object
required:
- heading
- level
description: A section definition within a template outline.
properties:
heading:
type: string
minLength: 1
description: The section heading text
level:
type: integer
minimum: 1
maximum: 6
description: Heading level (1-6)
keyPoints:
type: array
items:
type: string
default: []
description: Key points to cover in this section
suggestedWordCount:
type: integer
minimum: 0
default: 200
description: Suggested word count for this section
isRequired:
type: boolean
default: true
description: Whether this section is required
generationMode:
type: string
enum:
- ai
- manual
description: Whether AI generates this section or user provides content
TemplateFieldDefaults:
type: object
description: Default field values applied when the template is used.
properties:
targetWordCount:
type: integer
minimum: 0
description: Default target word count
metaTitleFormat:
type: string
description: Default meta title format
suggestedTone:
type: string
description: Default suggested tone
targetLanguage:
type: string
description: Default target language
targetCountry:
type: string
description: Default target country
Pagination:
type: object
properties:
page:
type: integer
page_size:
type: integer
total:
type: integer
has_more:
type: boolean
responses:
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
type: invalid_request_error
code: resource_not_found
message: The requested resource was not found.
doc_url: https://docs.frase.io/api/errors#resource_not_found
request_id: req_abc123
Unauthorized:
description: Authentication failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
type: authentication_error
code: invalid_api_key
message: Invalid API key. Check that you are using the correct key.
doc_url: https://docs.frase.io/api/errors#invalid_api_key
request_id: req_abc123
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
description: Seconds until rate limit resets
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
type: rate_limit_error
code: rate_limit_exceeded
message: Rate limit exceeded. Please retry after 60 seconds.
doc_url: https://docs.frase.io/api/errors#rate_limit_exceeded
request_id: req_abc123
ValidationError:
description: Validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
type: invalid_request_error
code: validation_error
message: The request body is invalid.
param: prompt_text
doc_url: https://docs.frase.io/api/errors#validation_error
request_id: req_abc123
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-KEY
description: 'API key for authentication. Get your key from Settings > API Keys. You may also send the same key as `Authorization: Bearer <key>` — see BearerAuth.'
BearerAuth:
type: http
scheme: bearer
bearerFormat: Frase API key (sk_live_* / sk_test_*) or MCP access token (mcp_at_*)
description: 'Send either your API key or an MCP access token as `Authorization: Bearer <token>`. An API key sent this way is exactly equivalent to sending it in X-API-KEY — same identity, same scopes, same rate limits — so use whichever your HTTP client makes natural. X-API-KEY takes precedence if you somehow send both. Cookie/session auth is intentionally NOT supported on /api/v1/* endpoints.'