---
title: "API Guide — PageWeave"
---

# HTTP API

The programmatic control plane for PageWeave: the same management surface MCP exposes to agents, available to any HTTP client. v1 is under `/api/v1`.

The generated reference — every operation, parameter, response schema, and example — is at [pageweave.dev/docs/api](https://pageweave.dev/docs/api), rendered from the contract served at `/openapi.json` (OpenAPI 3.1.2). `/openapi.yaml` serves the same document. Treat only what appears in that contract as callable.

This guide covers what a reference cannot: authentication, request conventions, pagination, conditional requests, rate limits, idempotency, and the shared error model. The status map lives in the [API error reference](https://pageweave.dev/docs/api/errors).

## Account, workspaces, websites, access

All surfaces are bearer-only and scoped to the caller; a resource outside the caller's reach is `404`, never a `403` that confirms it exists.

- **Account** — `GET/PATCH /me`, `GET /me/account`, `GET /me/permissions` (effective roles and bundles for an optional workspace/website/environment context; the default workspace when none is given), `me/api_keys`, `me/authorized_applications`, `me/mcp_tools`, `me/notifications` (list, show, update read state, `read_all`), `me/legal_acceptances`, and platform feedback (`feedback_reports`). Caller-owned data lives under `/me`; write-only platform submissions (`feedback_reports`) and published documents (`/legal/{key}`) stay top-level.
- **Workspaces** — CRUD, self-leave (`DELETE /workspaces/{id}/membership`), memberships, custom roles, the permission catalog, invitations (list, create, cancel, resend — a resend restarts the 7-day window from the new send and leaves `created_at` alone), the registrant contact, and the workspace billing-portal session. `workspace_id` accepts a UUID or slug.
- **Roles** — `GET/POST /workspaces/{id}/roles`, `GET/PATCH/DELETE /workspaces/{id}/roles/{role_scope}/{role_key}`. System roles are readable and immutable; custom roles go through the same no-escalation rule as assignment — an author can only bundle permissions they hold.
- **Websites** — CRUD (`website_id` accepts a UUID or subdomain), analytics (`metrics` selects a subset; the default is every dashboard metric), live visitors, subscription state and checkout start, the website billing portal, and the per-user MCP write lock.
- **Access** — direct website grants (`POST /websites/{id}/accesses`) target an existing user by id or email and **may override a workspace member's implicit role on that website**. Email invitations (`/access_invitations`) keep the opposite guard: an address that already has access cannot be invited, so an invitation never silently demotes. Environment grants (`/environments/{id}/accesses`) take a website-scoped role and require the user to already reach the website.
- **Self-edit** — a caller cannot change their own membership or access role, or grant themselves access. They may revoke their own website/environment grant without the manage permission — pure de-escalation and the exit from an explicit downgrade. Leaving a workspace uses the leave endpoint (the last owner cannot leave).
- **Workflows** — `GET /workflows` and `GET /workflows/{id}` expose the caller's own executions (`pending`, `executing`, `expired`, `completed`, `cancelled`, `failed`). Approving stays human-only in `/actions/:id`; `state`, `step_history`, and internal step names are never returned.
- **Async** — `POST /websites/{id}/subscription` returns `202` with `Location: /api/v1/workflows/{id}`. Payment stays in Stripe's checkout, opened from the approval page.
- **Secrets** — API-key creation, billing-portal URLs, and live-visitor counts answer `Cache-Control: no-store`, including on an idempotent replay.

## Content

All content resources are nested under `/websites/{website_id}`; `website_id` accepts a UUID or subdomain and every member id is a UUID.

- **Pages** — list (`path` — the page at that path and every page under it — plus `language`, `indexable` filters), create, detail, settings (`PATCH`), discard (`DELETE`), restore (`POST .../restore`). A page carries settings only; its bodies (`html_content`, `markdown_content`, `<head>`) live on versions. Creating a page also creates its first version and, with `old_path`, a redirect from the previous URL.
- **Versions** — pages, snippets, and components expose `GET/POST /versions`, `GET /versions/{version_number}`, and `POST /versions/{version_number}/revert`. A content write publishes immediately: the new version becomes the live content, and settings are untouched. Writes accept a full overwrite (`html_content`, `markdown_content`, `additional_html_head_html`, or a snippet/component `content`/`html_content`) or find-and-replace lists (`html_content_replacements`/`markdown_content_replacements`, component/snippet `replacements`; max 10). Revert never deletes: it creates a new version that records `rolled_back_from_version_number`. Snippet versions with identical content return `200` and `unchanged: true` instead of `201`.
- **Snippets** — list (`path` — the snippet at that path and every snippet under it — and a `content_type` prefix filter), create/detail/settings/discard/restore/versions. Paths are relative (no leading slash); the content type is inferred from the extension when omitted.
- **Components** — one collection for `header`, `footer`, and `html_head` instances; any number per kind. `kind` filters the list, `conditions` and `priority` are versioned routing (a settings `PATCH` creates a version, `conditions: {}` clears). Discard/restore/versions as above.
- **Theme** — singleton `GET/PATCH /theme`, plus versions and revert. Before the first update the resource is null-versioned (`version_number`, `config`, timestamps are `null`). `PATCH` accepts a built-in `name`, a `css` block, or a raw CSS string (equivalent to `{ "css": ... }`); bare CSS variables are wrapped in `[data-theme]`.
- **Redirects** — list/create/detail/update/delete. A redirect stores its target page, so renaming the page moves the destination without a write; the response carries the target's live `target_path` and `active`. `active` is `false` when the target page is discarded: the path is still reported, but the site stops following the redirect. Deleting a redirect is permanent.
- **Discard bin** — `GET /discarded/{type}` (`page`, `snippet`, `component`) and `GET /discarded/{type}/{id}` for the row's latest content. `type` is a path segment: the three tables cannot share one keyset order. Rows carry `discarded_at` and the retention `purge_at` (30 days). A discarded resource is not readable at its live URL (`404`); the bin is its read path. Restore is a separate operation on the resource and answers `409` with `suggested_path` when a live row now uses the path.

Settings `PATCH` requests and content versions are deliberately separate: `PATCH` never changes a body, and a version never changes settings. A supplied page `title` is a settings write that creates a title-only version (titles live on versions), matching MCP `update_page_settings`; `title: null` and `title: ""` clear it on a new version.

Revert is content-only for the same reason: a page revert copies the source body/head and keeps the **current** title, and a component revert copies the source HTML and keeps the **current** conditions and priority. Changing a title or routing needs the settings permission; revert needs the content permission.

Create implies its initial content: `page.create` writes the first version, `snippet.create` writes content, `component.create` writes HTML and routing, and a page created with `old_path` also creates a redirect. Those are part of the create permission, not separate `*.content.update` / `redirect.create` checks.

### Content limits

Authored fields are bounded in **bytes** (not characters), independent of the request-body cap:

| Field | Limit |
|---|---|
| Page `html_content`, `markdown_content` | 5 MB each |
| Page `additional_html_head_html` | 512 KB |
| Page `title` | 1 KB |
| Page path, snippet path, redirect source path | 255 B |
| Page `placeholder_defaults` | 16 KB |
| Component `html_content` | 5 MB |
| Component `conditions` | 64 KB |
| Theme `config` | 5 MB |
| Snippet content | 256 KB |
| One replacement's `old_content` / `new_content` | 64 KB |

Oversized values are `422` with the field named, except replacement items, which are rejected as `400` before the service runs. A page detail embeds at most 100 `incoming_redirects`, ordered by `source_path`; use the redirects collection for the full list.

### MCP lock and REST credentials

The per-user MCP write lock and the MCP confirmation workflows apply to MCP tool calls only. An API key with `write` is a separate consent boundary: REST writes are governed by the key's `read`/`write` scope and the user's permissions, and they do not run confirmation workflows. Revoke or downscope the key to stop REST writes; the lock does not.

### Audit and logging

Page, snippet, component, and redirect changes (settings, discard, restore, redirect create/update/delete) are audited with the acting user. Authored bodies are filtered from request logs, and authentication/authorization failures emit a structured `api_authn_failure` / `api_authz_failure` warning with request id, credential id (never the token), website, and permission.

## Assets and uploads

Assets are the files pages reference (`/assets/{id}/{filename}`); they are not versioned. Uploads are deliberately separate from the JSON body rule: they accept raw bytes or `multipart/form-data`.

- **Assets** — `GET/POST /websites/{id}/assets`, `GET/DELETE /assets/{id}`, `PUT /assets/{id}/content`. The list is keyset-paginated and filters on `content_type` (a category alias — `image`, `font`, `document`, `audio`, `video`, `text` — or a MIME prefix) and `filename` (case-insensitive substring). Create accepts a multipart `file` part or a raw body with `?filename=`; the response carries the asset and the website's current `storage` usage.
- **Replacement is permanent.** `PUT /assets/{id}/content` keeps the id, filename, and URL path; the `?v=` cache-bust token flips and every page serving the asset serves the new bytes. The new content type must match the original, and `filename` is not accepted (a replacement cannot rename). `DELETE /assets/{id}` removes the row, its file, and every variant; pages that still reference the asset will 404 it. There is no discard bin for assets.
- **Imports** — `POST /websites/{id}/asset_imports` with exactly one of `url` or `base64`. Base64 requires `filename` and `content_type` (missing either is `400`) and is capped at 20 KB after decoding; sending either field with `url` is `400` because the fetched response supplies them. Passing `asset_id` replaces that asset's bytes; `filename` is `400` there too, because replacement preserves the existing name. URL fetches are SSRF-guarded (private networks rejected) and size-capped. JSON imports support `Idempotency-Key`.
- **Upload sessions** — `POST /websites/{id}/upload_sessions` mints a 10-minute capability URL for a large binary upload; `GET` returns session state (bearer required); `PUT` accepts the session alone or a bearer key with `write`. A session belongs to the website in its path. A plain session is reusable: each PUT creates a new asset; a session created with `asset_id` replaces that asset instead, keeps its filename, and rejects `filename` with `400`. Raw and multipart uploads are accepted; an empty body, a multipart body without a `file` part, or an empty `file` part is `400`.
- **Limits** — per-type size ceilings come from the asset policy (images 5 MB, fonts 2.5 MB, video/audio 15 MB, other files 10 MB); a request over the cap for its declared type is `413`. Passing the website storage quota is `422`. Upload and import writes share a **20/min per-IP** abuse floor in addition to the per-credential write bucket.
- **No versions** — assets are mutable bytes, not authored content: there is no version list, revert, or discard. Use releases for immutable snapshots of pages, not for asset bytes.

## Forms and submissions

Forms are website-owned; their field definitions and submit settings live on versions. The submit URL is `/f/{version_id}`, the URL the renderer embeds.

- **Forms** — `GET/POST /websites/{id}/forms`, `GET/PATCH/DELETE /forms/{id}`. The list is keyset-paginated and returns summaries; detail adds `fields`, `success_message`, `redirect_path`, and `spam_protection`. Creating a form writes its first version atomically. `PATCH` updates the name and/or publishes a new version: a supplied `fields` or versioned setting creates a version, a name-only update does not; omitted versioned fields inherit the latest version, and `null`/`""` clears optional settings. Body-shape errors are `400` (unknown keys, wrong types, a missing or blank `name` on create, an empty `fields` array, an unknown `spam_protection`); field definitions and redirect paths are domain-validated (`422`).
- **No notification/recipient fields** — `notification_email` is not accepted on create or update (`400`), and form responses expose no recipient or verification fields. Recipient management, trusted addresses, subscriptions, and unsubscribe ship with the notification replacement; they arrive as additive `recipients` fields and operations.
- **Submissions** — `GET /forms/{form_id}/submissions` with `spam_verdict` (`ham`, `quarantined`, `discarded`, `incomplete`) and `read` filters; `GET/PATCH/DELETE /submissions/{id}`. List rows include the stored field payload (`data`) and spam state; detail adds spam signals, salvage failure details, and file uploads with permanent capability `download_url`s. Submissions are submitter PII: every response is `Cache-Control: no-store`, and every read requires `submission.read`. Viewers hold neither `form.read` nor `submission.read`; content may read and moderate submissions but cannot write forms.
- **Mutation** — `PATCH` accepts `read` (boolean) and `spam_verdict` (`ham` or `quarantined`). Releasing a non-ham submission re-enters the notification path; re-affirming `ham` is a no-op. `DELETE` is permanent and writes an audit row with form/version/verdict metadata only — never submitter data, field values, or upload filenames.
- **Delete is permanent** — deleting a form cascades its versions, submissions, and uploads; embedded `/f/{version_id}` URLs stop working. There is no discard bin for forms or submissions.
- **Version retention** — a form version with submissions is protected from the website version cap, so editing a form never deletes submitted data. Versions without submissions follow the normal cap.
- **Limits** — no new permission keys, no migrations. `form.read/create/update/delete` and `submission.read/update/delete` are the existing catalog keys.

**Not yet in the contract:** `notification_email`, verification commands, trusted addresses, subscriptions, unsubscribe/preference pages, and delivery status. Those belong to the notification subscription replacement and the follow-up API phase.

## Data tables, rows, imports, and export

Tables are the schema; rows are data; a template page exposes rows as URLs. All three are website-scoped and use the existing `table.*` and `row.*` permissions.

- **Tables** — `GET/POST /websites/{id}/tables`, `GET/PATCH/DELETE /tables/{id}`. `{id}` accepts a UUID or slug. List rows carry `row_count`; detail adds `fields_schema` and `template_page` (`{id, path}` or `null`). `fields_schema` is `{fields: [{name, type}]}` with types `string`, `text`, `boolean`, `datetime`, `number`, capped at 64 KB encoded and 100 fields; new field names must match `[A-Za-z_][A-Za-z0-9_]*`, are capped at 64 characters, and may not be `id` or `published_at`. Name is capped at 100 characters, slug at 64 (existing over-long values remain editable). Row `data` is capped at 256 KB encoded; existing larger rows remain readable and publishable, but data edits must bring them under the cap. Deletion is permanent and audited with counts only (never row data). A table referenced by a live template page returns `409` with a `template_pages` extension naming the pages; already-discarded template pages are hard-deleted with the table. Rows, versions, and import runs cascade.
- **Rows** — `GET/POST /tables/{table_id}/rows`, `GET/PATCH/DELETE /rows/{id}`. Create requires `data` and accepts optional `published_at`: omitted or `"draft"` stays unpublished, `"now"` publishes, and an ISO 8601 instant schedules; timestamps outside PostgreSQL's range are `422`. `data` values must be finite JSON numbers (`Infinity`/`NaN` are `422`). PATCH merges `data` into the stored object (an omitted key is unchanged, `null` stores null) and only records a version when data or publish state changed. Every write that supports it accepts `If-Match`.
- **Row queries** — `published=true|false` (omitted returns every row), `search` across string/text fields (max 200 characters), `sort` (prefix `-` for descending; default `created_at desc`), and `filter` as one URL-encoded JSON object, e.g. `filter={"price_gte":10,"name_cont":"widget"}`. Unknown fields, operators, malformed JSON, or an overlong search are `400`. A sort by a JSONB field uses the signed offset cursor because Pagy keysets require a column order; every other row page is keyset-paginated.
- **Row versions** — `GET /rows/{row_id}/versions` (newest first) and `GET /versions/{version_number}`. Versions are immutable; the detail adds `data`. The website version cap applies (50 per row, release-exempt pins aside).
- **Imports** — `POST /tables/{table_id}/row_imports` with exactly one of `source_url` or `content` (both or neither is `400`), optional `mode` (`create` default, `upsert` matches the `id` column), and `column_mapping` (string map, max 1,000 entries, names max 255 characters). `source_url` must be HTTPS without embedded credentials (max 2,048 characters); invalid or blocked sources are `422`. Inline content is capped at 10 MB (`413`). Up to 25,000 rows run synchronously and return `200` with `created`/`updated`/`skipped`/`failed` row numbers; larger URL imports return `202` with a `Location` pointing at `GET /row_imports/{id}`. Inline content above 25,000 rows is `422` — it cannot be re-fetched, so use `source_url` for larger imports. Every import is capped at **50,000 rows**; result arrays carry at most **1,000** entries per bucket, with exact `counts` and `truncated` flags; each error detail is truncated. Fetch capacity saturation is `503`; malformed CSV is `422`. `Idempotency-Key` prevents a retry from importing twice.
- **CSV export** — `GET /tables/{table_id}/rows/export` streams `text/csv` in 1,000-row batches with the same filters as the row list. OWASP formula escaping is on by default: string cells beginning with `=`, `+`, `-`, `@`, tab, CR, LF, or the full-width `＝`/`＋`/`－`/`＠` variants get a single-quote prefix. `sanitize=false` returns raw output that round-trips through import (the `id` column drives `upsert`) but is unsafe to open in a spreadsheet. The export has its own **10/min** credential bucket and is `no-store`.

## Releases and environments

A release is an immutable snapshot; an environment is a surface that serves one. Permissions resolve per environment for every environment-scoped action.

- **Releases** — `GET/POST /websites/{id}/releases`, `GET /releases/{id}` (`{id}` is a UUID or number). Create never publishes anything. `scope` defaults `all` (the complete live world); `selection` accepts page/snippet paths or `{path, version}`, components as `"header"`, `"header:<instance_id>"`, or `{type, instance_id?, version?}`, and `theme: true|{version}`; `remove` unpublishes items; `force` keeps your versions on mine-conflicts. Selection parameters with `all`/`mine` are `422`; a mine-conflict is `409` with `conflicts: [{identifier, mine_version, latest_version, latest_author}]`; unknown items/versions and removal conflicts are `422` with `items`. Detail lists pinned `pages`/`snippets`/`components` with version numbers, `theme`, `removed`, and the environments serving the release.
- **Environments** — `GET/POST /websites/{id}/environments`, `GET/PATCH/DELETE /environments/{id}`. The list is filtered to environments the caller can read; a caller with no readable environment gets `403`. Create accepts `name`, optional `access_password` (8–72 bytes, bcrypt), `live_reload`, and `feedback_enabled`. Update accepts any of `name`, `live_reload`, `feedback_enabled`, `access_password`, `remove_access_password`; `access_password` is write-only and never returned. Renaming rotates the generated host (the old URL stops resolving). The default environment (name `live`) cannot be renamed, deleted, or have `live_reload`/`feedback_enabled` enabled (`422`).
- **Serving a release** — `PUT /environments/{environment_id}/release` with `{release}`, and `DELETE` to return to track-latest. PUT is a singleton assignment and is the only relation-assignment PUT in v1 (the other replacement PUTs cover asset bytes, upload sessions, and the MCP lock). It returns the environment, the release, and drift warnings (`reverts`, `unpinned`, `removals`) computed against the current state. Both actions resolve `release.attach`/`release.detach` on the target environment; detaching an already-track-latest environment is `204`.

## Hostnames

Custom hostnames are Pro-only. `GET/POST /websites/{id}/hostnames`, `GET/PATCH/DELETE /hostnames/{id}`.

- Create accepts `name`, optional `response_type` (`serve` default, `redirect_to_subdomain`, `redirect`, `disabled`), `redirect_url` (required for `redirect`), and `environment` (name or UUID; omitted binds the default environment). A serving apex also creates its derived `www.<apex>` redirect companion atomically.
- `platform_domain` is derived from the actor (super admin on a platform subdomain), never a body key.
- Update changes `response_type`, `redirect_url`, or `environment`; `environment: null` unbinds, and `name` is read-only (`400` for an unknown key).
- Delete removes an apex's `www` companion too. While the name's DNS still resolves to PageWeave the delete is `409` (subdomain-takeover guard): remove DNS first.

## Webhooks

`GET/POST /websites/{id}/webhooks`, `GET/PATCH/DELETE /webhooks/{id}`, plus member actions and the delivery log.

- **Create starts paused** and returns the signing secret exactly once. The response is `Cache-Control: no-store`. No other representation (list, detail, update, delete) includes the secret.
- `PATCH` is partial: `url`, `events`, `form_ids`, `active`. `url` must be HTTPS without embedded credentials (max 2,048 characters); `events` is capped at 20 and `form_ids` at 100. `form_ids: []` clears the form filter; form filters require the `form.submission.created` event. `active: true` resumes and resets the failure streak.
- **Secret rotation** — `POST /webhooks/{id}/rotate_secret` replaces the secret immediately and returns `{webhook_id, secret}` once, `no-store`. This is a compromise-response command: the old secret stops verifying at once. Planned rotation uses a second endpoint with the new secret (GitHub/Shopify pattern); dual-signing overlap is a future addition.
- **Test delivery** — `POST /webhooks/{id}/send_test` enqueues a payload marked `test: true` and returns `202` with `{queued, event}`. It works while paused, which is how a consumer verifies signatures before enabling.
- **Deliveries** — `GET /webhooks/{webhook_id}/deliveries` (newest first; filters `event` and `success`) and `GET /deliveries/{id}`. Detail adds the payload and consumer response body and is `no-store` (submission data). It requires both `webhook.delivery.read` and `submission.read`; delivery read alone lists metadata only. `POST /deliveries/{id}/redeliver` replays the recorded event and payload (`202`, returning `{queued, event, source_delivery_id}`). Both commands honor `Idempotency-Key`.

## Feedback

Visitor feedback from the on-site widget. Every response is `Cache-Control: no-store` because the inbox contains contact data. Viewers hold no `feedback.read` and receive `403`.

- `GET /websites/{id}/feedback` defaults to `open` + `in_progress`; `status` accepts `open`, `in_progress`, `completed`, `closed`, or `all`; `path` filters by exact page path. List rows omit `author_email`, capture, replies, and attachment URLs — fetch the item for those.
- `GET /feedback/{id}` accepts a UUID or a unique 4+ hex prefix of the item's short id (the id shown on the page pin; a leading `#` is tolerated). An ambiguous prefix is `422` with `matches`; an invalid reference is `400`; an unknown one is `404`. Detail adds `author_email`, `user_agent`, `selector`, `position`, `viewport`, `capture`, `screenshot_url`, attachments with signed one-hour URLs, and the reply thread.
- `PATCH /feedback/{id}` accepts `status` only. Terminal statuses (`completed`, `closed`) stamp `resolved_at` once; any other status clears it. `responded_at` is owned by replies.
- `POST /feedback/{feedback_id}/replies` posts an agent reply (`message`, 1–2000 characters) and stamps `responded_at` on the first one. Agent replies are allowed on closed threads, capped at 900 per thread (100 visitor + 900 agent = 1,000 total). The author name is the OAuth application's name, or `Agent` for API keys. Replies support `Idempotency-Key`.


## Domains

Account-level domains, independent of websites: `GET /domains`, `GET /domains/availability`, `GET/PATCH /domains/{id}`, and the cancellation, verification, auth-code, and deletion commands. `{id}` accepts the UUID or the exact domain name. Every query runs through the caller's `domain.read` scope; a domain outside their workspaces is `404`.

- **List** — `GET /api/v1/domains` is keyset-paginated, ordered by name. `workspace_id` accepts a UUID or slug; an unknown workspace returns an empty page. `registrar_managed` reports whether the registration is under PageWeave's registrar account (settings and lifecycle commands apply only then).
- **Availability** — `GET /api/v1/domains/availability?name=example.com,example.org&period=3`. `name` is one comma-separated parameter (Rails collapses repeated keys), at most 20 names; `period` is 1–10 and defaults to 1, and a batch always quotes one year (`400` for another period on a batch). Prices are quoted for that period as `{amount, currency}` and the response states the VAT basis. Each result also carries `renewal_price` (one renewal charge at the TLD's renewal base period — `.ai` renews in 24-month units, `.ac.mw` registers 2 years but renews 1) and `restore_price` (ERRP disclosure; restore is single-name only), the TLD's `min_period`/`max_period`, `base_period`, and `whois_privacy_supported`. A period the TLD does not offer (a one-year .ai, a 24-month product) is `422` — send a whole multiple of `base_period` within `min_period`/`max_period` (`.ai` prices in 2-year units: 2/4/6…, not 3). Premium names return `available: false` with null prices in a batch; a single-name premium check is `422` because the registry fee cannot be quoted. An invalid name, a subdomain, an over-long label, an unsupported TLD, or an indeterminate answer is `422`; in a batch an invalid name still appears with `error` instead of being dropped. A registrar outage is `503`. Bucket: **10/min** per credential.
- **Settings** — `PATCH /api/v1/domains/{id}` accepts `auto_renew` and/or `transfer_lock` (booleans). Settings are applied one at a time, each committed before the next registrar call: when the second call fails, the first change is saved (the registrar already has it) and the `503` names which one failed. Only registrar-managed domains can be changed (`422` otherwise). `If-Match` is honored.
- **Cancellation** — `POST /api/v1/domains/{id}/cancellation` schedules deletion at the end of the paid term (`409` when any deletion is already scheduled); `DELETE` undoes it (`409` when not cancelled, once the registrar deletion is under way, when an immediate deletion is scheduled, or when a deletion request was already confirmed).
- **Verification** — `POST /api/v1/domains/{id}/verification` resends the registrant email. `throttled: true` means the per-handle cooldown suppressed the send; it is still `200`.
- **Auth code** — `GET /api/v1/domains/{id}/auth_code` fetches the transfer code live. The response is `Cache-Control: no-store`; the code is never stored, and every fetch writes an audit row (never the code). Requires `domain.auth_code.read` **and a `write` credential** — a read-only key gets `403`, because the code hands the domain to another registrar. Owners/admins only; plain members get `403`. `422` when the registrar exposes no code for the domain. Bucket: **10/min** per credential.
- **Lifecycle fields** — domains carry `status` (`external`, `active`, `expired`, `deletion_scheduled`, `released`), `deletion_status` (`none`, `pending`, `scheduled`, `initiated`), `deletion_kind` (`cancellation` vs `immediate`, null without a deletion), `registered_at`/`last_renewed_at` (registry dates), and `transfer_status` (registry transfer mode). `cancelled` is term-end only: an immediate deletion reports `false`.

## Domain purchases

`POST /api/v1/domain_purchases` starts (or reuses) the purchase approval workflow. This is a handoff, not a checkout: the response carries the pending workflow, a `Location` to `/workflows/{id}` (unchanged), and an `approval_url` to the human page (`/actions/{id}`); payment and registration happen only after a human approves the workflow, so no checkout or payment URL is ever returned. `GET /domain_purchases` (keyset-paginated, newest first, `workspace_id` filter) and `GET /domain_purchases/{id}` expose the same purchase; `order` is the registrar transaction once execution reached it. Purchases are per-user workflows: another user's execution is `404`.

- Body: `domain` (required), `period` (1–10, default 1; the TLD must allow it), optional `workspace_id` (UUID or slug; defaults to the caller's default workspace) and `website_id` (UUID or subdomain; defaults the workspace to the website's, and requires `website.hostname.create`). Requires `domain.purchase` and a `write` credential. Both permissions are re-checked when the approved order executes; if the caller has lost `website.hostname.create` by then, the registration completes without attaching the domain as a hostname.
- Pending-first: an open purchase for the same **workspace and domain** is resolved before any availability check. `202` starts a purchase; `200` returns the caller's existing pending one. A different `period` while one is pending is `409` naming the pending period, and a pending purchase owned by **another member** of the workspace is also `409` — two pending orders for one name would race at checkout.
- The payload discloses `renewal_price` (one renewal charge) and a `vat_note` alongside `price` (ERRP §4.1), and `failure_reason` once the workflow failed.
- **Cancellation:** `POST /domain_purchases/{id}/cancellation` cancels a purchase that is still `pending` — it releases an open Checkout Session and concludes the workflow as `cancelled` (200). Repeating it returns the already-cancelled purchase (200, no Stripe call), so a retried request is safe. `409` once money moved (a completed session) or the workflow is executing; `503` when Stripe cannot be asked (never cancelled blind).
- `422`: invalid name, taken or premium name, a period the TLD does not offer, an indeterminate availability answer, a Stripe test-mode workspace, an unknown workspace/website, or a website from another workspace. `503` when the registrar is unreachable. `Idempotency-Key` is supported.
- Bucket: **5/hour** per credential, counted before the action, so dedupe and `Idempotency-Key` replays still consume quota.

## Registrar deletion

Deleting a registered domain is irreversible and runs through an emailed single-use token. The token is never returned by the API; it exists only in the message sent to the workspace's owners and admins. Every response is `Cache-Control: no-store`.

- `GET /domains/{id}/deletion` reports `status` (`none`, `pending`, `scheduled`, `initiated`), `kind` (`cancellation` or `immediate`; null without a deletion), `requested_at`, `expires_at`, and `confirmed_at`. State derives from the latest confirmation request, not from `deletion_scheduled_at` alone (a term-end cancellation also sets that column). Unregistered rows (released — no registration at the registrar) report `none`. Requires `domain.deletion.read`.
- `POST /domains/{id}/deletion` with `{confirmation: "<exact name>"}` emails the link and returns `202` with `Location` to the deletion resource and `status: pending`. Repeating it while a request is outstanding returns that request (no second email, no new token); only an expired request is replaced. `422` for a mismatch or an unregistered (released) row; `409` once the registrar deletion is under way or a deletion is already confirmed and scheduled. Bucket: **10/hour** per credential (`domain_deletion`, published in discovery).
- `PATCH /domains/{id}/deletion` with `{token}` claims the request and executes the registrar deletion, answering `200` with `status: scheduled` — the registrar runs it asynchronously, usually the following night, so success does not mean gone. A token that already succeeded replays as `scheduled` (idempotent retry); an invalid, expired, or superseded token is `422`; a renewal charge in flight is `409`; a registrar rejection/outage is `503` and releases the claim, so the same emailed link can be retried. A deletion the registrar reports as already executed (`2303`) or queued (`1001`) is a success. Requires `domain.deletion.manage`.

## DNS zones and records

DNS hosting lives on `/dns_zones`: a zone is a name served by PageWeave's nameservers, independent of a `Domain` registration (a purchased domain gets one; a customer's own name can too). `{id}` (and `{dns_zone_id}` on the record routes) accepts the UUID or the exact zone name, so `example.com.` and `EXAMPLE.com` resolve to the same row. Every query runs through `dns_zone.read`/`dns_record.read`, so a zone outside the caller's workspaces is `404`. `dns_record.*` implies `dns_zone.read`.

- **List** — `GET /api/v1/dns_zones` is keyset-paginated and ordered by name; `workspace_id` accepts a UUID or slug. `delegated` and `checked_at` are the last recorded check, not a live probe.
- **Create** — `POST /api/v1/dns_zones` with `name` (a registrable apex, e.g. `example.com`; subdomains are `422`) and optional `workspace_id`/`website_id`. The workspace comes from `workspace_id`, else the website's workspace, else the caller's default; a website seeds the apex `ALIAS` and `www` `CNAME` pointing at its pageweave.site target. `422`: a duplicate or invalid name, a subdomain, a name registered by another workspace, the workspace's zone allowance (3 + 10 per Pro website), an unknown or mismatched workspace/website, or a provider rejection. `503` when the provider is unreachable. `Idempotency-Key` is supported; the response carries `Location`.
- **Detail and delete** — `GET` revalidates with `If-None-Match`. `DELETE` answers `204` and honors `If-Match`; it is refused with `409` while a registration for the name is in flight or while the name's parent NS records still point at PageWeave — the problem's `nameservers` member carries the live answer. A background delegation check updates the zone's timestamp, so a validator read before the check can answer `412`; re-read and retry.
- **Delegation check** — `POST /api/v1/dns_zones/{dns_zone_id}/delegation_checks` queries the live parent NS RRset, records the answer on the zone the same way the background sweep does, and returns `delegated`, `nameservers` (sorted; empty when resolution failed), `expected_nameservers`, and `checked_at`. Any expected nameserver means delegated. Requires `dns_zone.check` and a `write` credential. Bucket: **10/min** per credential.
- **Records** — `GET /dns_zones/{dns_zone_id}/records` returns every record matching `type` (case-insensitive) and `host` (`@`/empty = apex), ordered by host, type, and provider record id. This list is **not** keyset-paginated: the envelope reports `has_more: false`, `next_cursor: null`, and `limit` equal to the returned count, and `limit`/`cursor` are rejected. Records stay provider-authoritative, so `id` is the provider's record id.
- **Record writes** — `POST` requires `type`, `host`, and `ttl` (the provider's 13-value set), plus `record` unless the type carries its whole value in `params` (`CAA`, `HINFO`, `LOC`, `NAPTR`, `RP`). `PATCH` changes only the supplied fields; `type` is immutable (delete and recreate). `422` for a field the type does not take, a missing structural field, an exact duplicate, an apex `NS`, a `CNAME` at the apex, or a provider rejection; `404` for a record id the zone does not hold; `503` when the provider is unreachable. `SOA` and the zone's apex `NS` records are provider-managed and read-only (`422` on update/delete).
- **Record validators** — a record's `ETag` covers the serializer version, the zone's record-cache version, and the representation, so **any write in the zone invalidates every record validator**. `GET` revalidates with `If-None-Match`; `PATCH`/`DELETE` honor `If-Match`. Records carry no `Last-Modified`.

Zone and record creates support `Idempotency-Key`; the delegation check does not — it is a live probe, safe to repeat.

## Request bodies

Write requests carrying a body must use `application/json`; anything else is `415`. Body values are read from `request.request_parameters` only — never merged with the query string or route, and framework parameter wrapping is disabled so no controller-derived key is injected. Undeclared keys are `400` (`Unknown body parameter: <name>`): a misspelled field never turns into a silent partial write. Action POSTs without a body (subscription start, mark-all-read, resend, revert, bodyless restore) are allowed. Uploads are the deliberate exception: they accept raw bytes or multipart.


## Discovery and contract

- `GET /api/v1` — version, documentation, contract URLs, MCP endpoint, and the advertised rate limits. Public and cacheable, revalidates with `If-None-Match` (including `*`), and sends `Vary: Authorization` because the quota headers are per credential.
- `GET /openapi.json` / `GET /openapi.yaml` — the OpenAPI 3.1.2 contract, served from `openapi/openapi.yaml`. Each representation carries its own strong `ETag`; revalidate with `If-None-Match` and the validator for that format.
- `GET /.well-known/api-catalog` — RFC 9727 catalog with one linkset entry per API (`/api/v1` and `/mcp`). GET and HEAD share the content type, cache policy, and validator.
- Every API response carries `Link: rel="service-doc"` and `rel="service-desc"` headers pointing at the docs and the contract.

The contract is a single file on purpose: `openapi_first` (the validator used by the contract specs) has no public bundler, and a second generated copy would drift. `bundle exec rake api:contract` validates every exercised documented request and response — including documented 401/403/404/409/422/429 bodies — and fails below 100% success-operation coverage. `spec/contract/mcp_api_parity_spec.rb` and `spec/contract/ui_api_parity_spec.rb` fail when an MCP tool or dashboard action has no REST counterpart and no written exception.

## Versioning

- Base path `/api/v1`. Additive changes only within v1.
- Breaking changes ship as `/v2`; v1 then answers with `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers, and `410 Gone` after the sunset date.
- No silent behaviour changes; `docs/API-CHANGELOG.md` records every addition.

## Authentication

- `Authorization: Bearer pagew_...` — an API key created in Settings → API keys.
- Tokens never travel in query strings.
- API keys are trusted automation credentials: they are not subject to the interactive MCP approval workflow.
- A key's scopes (`read`, `write`) are a ceiling. A read-only key cannot write even when its owner's role allows it, including account endpoints such as API-key management and session revocation. MCP is per-tool: its HTTP request declares no single ceiling, and each `tools/call` checks the tool's read/write annotation.
- An unverified account may create API keys and its first non-indexable website (the setup path). Creating another website, setting `indexable: true`, or creating/resending a website access invitation is `403` with detail `Email verification is required` and no `required_*` members until the account is verified. This mirrors the MCP tool policies; workspace invitations and direct website grants are unchanged. An invalid body is `400` ahead of this and any permission check (see Errors).
- `401` responses carry `WWW-Authenticate: Bearer resource_metadata="https://<platform>/.well-known/oauth-protected-resource"` (RFC 9728). API routes are host-constrained to the configured platform domain; a spoofed `Host` cannot change this URL.
- A presented credential must resolve. An unknown, expired, or revoked key gets `401` with `WWW-Authenticate: …, error="invalid_token"` instead of being downgraded to an anonymous request — including on the public discovery endpoint, so a revoked key never looks healthy.
- Anonymous requests to public endpoints stay anonymous. `/openapi.json`, `/openapi.yaml`, and the legal-document feed never read credentials; they are plain public resources.
- Upload session PUTs are capability URLs: the session id in the path is sufficient on its own, and an API key with `write` is an alternative rather than a requirement. Reading a session (`GET`) requires a bearer credential; only the upload PUT accepts the capability alone.

## Errors

Errors use RFC 9457 `application/problem+json`:

```json
{
  "type": "https://pageweave.dev/errors/validation",
  "title": "Validation Error",
  "status": 422,
  "detail": "Validation failed",
  "instance": "/api/v1/websites/acme/pages",
  "request_id": "01a1b2c3-...",
  "errors": [{ "field": "path", "detail": "has already been taken" }]
}
```

Structured extensions ride the same problem body: `template_pages` on a table-delete `409`, `conflicts` on a release mine-conflict `409`, and `matches` on an ambiguous feedback short id `422`. `items` names the offending release selection items. All are additive RFC 9457 extension members.

See the [API error reference](https://pageweave.dev/docs/api/errors) for the status map and error types.

Request validation runs before authorization: query parameters, the request media type, and the body shape (type and key checks) are all enforced before the action's policy decision. An authenticated caller who sends an invalid body therefore gets `400` even when they would not be permitted to perform the action; a malformed request never reaches the permission check, and the body contract is public at `/openapi.json`, so nothing is disclosed.

## Query parameters

Query strings are allowlisted per action, and the check reads the URL only (`request.query_parameters`), never the merged `params` (which also carries route and body values). Consequences:

- An undeclared parameter returns `400 bad-request`. A misspelled filter never silently returns an unfiltered page.
- Non-scalar values (`?language[]=en`) return `400`; the framework's implicit array/hash coercion is not part of the contract.
- An action with no declaration accepts no query parameters at all (fail closed).

The OpenAPI document is the allowlist reference: parameters absent from an operation are rejected.

## Pagination

Collections use keyset cursors — no offsets:

```json
{
  "data": [ { "id": "..." } ],
  "pagination": { "limit": 25, "has_more": true, "next_cursor": "opaque" }
}
```

- `limit` defaults to 25, maximum 100; `0`, `101`, and non-numeric values return `400`.
- `cursor` is signed, expires after 24 hours, and is bound to the request method, path, and query (minus `cursor`/`limit`). A cursor from another query or endpoint returns `400`; a stale cursor is never silently treated as the first page.
- `next_cursor` is `null` on the last page; `Link: rel="next"` carries the same cursor.
- Stable order is `(sort column, id)`; a collection without an explicit order is a server error, not an unstable page.
- Row lists sorted by a JSONB data field use a signed **offset** cursor instead: Pagy keysets require an ORDER BY over real columns, and `data->>'field'` is an expression. The envelope, signing, expiry, and query binding are identical; only the cursor payload differs.
- Bounded collections (roles, permission catalog, MCP tools, authorized applications) use the same envelope with `limit` equal to the item count, `has_more: false`, `next_cursor: null`, and reject `limit`/`cursor` as unknown parameters.
- DNS records are the same shape for a different reason: one provider read returns the whole filtered set, so the envelope is truthful about there being no next page (see **DNS zones and records**).

## Idempotency

`POST` may carry `Idempotency-Key` (1–255 printable ASCII characters), including bodyless action POSTs such as `revert`, `restore`, webhook test/redelivery, and secret rotation. Every create and command supports it (tables, rows, imports, releases, environments, hostnames, webhooks, feedback replies), as do domain purchases and DNS zone/record creates. The wrapper runs **after** authentication and authorization, so a replay is re-authorized against the current role and credential.

- The first request wins. A replay returns the stored status, body, and representation headers with `Idempotent-Replayed: true`; the action does not run again, and the stored response is returned even if the resource has changed since (a replay is not re-validated against `If-Match`).
- Same key with a different method/path/query/body fingerprint returns `422`.
- A concurrent request with the same key returns `409` and `Retry-After`.
- Keys are scoped per credential and retained for 24 hours (daily cleanup job).
- Any response the action renders (2xx, 3xx, 4xx, 5xx) is stored. An exception that escapes the action releases the lock.
- Stored bodies are capped at 64 KB. A larger response keeps only status and representation headers; its replay is bodyless and carries `Idempotent-Replay-Body-Omitted: true` alongside `Idempotent-Replayed: true`, so follow `Location` when it is present (restores set it).
- Each user may hold 10,000 unexpired records across credentials. A request with a **new** key past the cap returns `429` with `Retry-After` and does not execute; a retry of an existing key still replays.
- Representation headers are stored too, including `Cache-Control`, so a replayed API-key creation stays `no-store` and cannot leak a one-time token through a cache.
- If a process dies between claiming the key and storing the response, the lock is reclaimable after 60 seconds; the retry then executes (at-least-once). Form-encoded bodies (uploads, multipart) and `GET`/`HEAD` are not idempotency-wrapped.

## Conditional requests

Single resources return a strong `ETag`: SHA-256 over the serializer version and the record's `cache_key_with_version`, so it is stable across processes and changes when the representation can change. Content resources derive it from the JSON payload instead, because the body embeds the latest version (a child row), which the parent's cache key does not cover; `GET` and the write's `If-Match` therefore share the same validator.

- `If-None-Match` returns `304` with no body; `*` matches any current representation.
- A compression layer at the edge may rewrite a strong tag with the coding it applied (`"<hash>-gzip"`). Comparisons ignore that suffix (`gzip`, `br`, `deflate`, `zstd`), because the validator describes the identity representation — so the ETag a compressed `GET` returned is still a usable `If-Match`.
- `If-Match` is optional on writes that support it, but when present it uses RFC 9110 strong comparison: `*` matches any current representation, weak tags (`W/"..."`) never match, and a mismatch returns `412 precondition-failed`. It applies to authored-resource writes — settings/content `PATCH`, version create/revert, redirect update/delete, form/submission `PATCH`/`DELETE`, table/row/environment/hostname/webhook updates and deletes, feedback status updates, and environment release attach/detach; the contract declares it per operation. Discard and restore do not evaluate it: a restore has no live representation to validate, and a discard is recoverable. Asset writes and upload sessions do not evaluate it: an upload replaces bytes outright, and the session id is the capability.
- Tables, rows, environments, hostnames, webhooks, feedback, releases, and import runs use payload-derived validators because their bodies embed child or derived state (`row_count`, latest version, current release, delivery health excluded, replies, attached environments). A feedback validator deliberately excludes the signed attachment/screenshot URLs: those expire and would rotate the ETag on every request.
- A page's validator covers its embedded latest version and `incoming_redirects`, and a redirect's covers `target_path`/`active`; creating a version or a redirect therefore invalidates a previously read page ETag. That is correct strong-validator behaviour, and it means `If-Match` is a whole-representation check, not a settings-only check.
- `Last-Modified` is sent alongside the ETag on single resources; the OpenAPI document is ETag-only.
- DNS zones use the record-derived validator, so a background delegation check (which stamps `checked_at`) can invalidate a validator between a `GET` and a `DELETE`; records carry no local timestamp, so their validator is the zone's record-cache version plus the representation instead of `Last-Modified`.
- `/openapi.json` and `/openapi.yaml` derive separate strong ETags from their own representation bytes, so a validator from one format never revalidates the other (RFC 9110 §8.8.1).
- Authenticated responses are `Cache-Control: private, must-revalidate` with `Vary: Authorization`, so presenting a credential overrides a public endpoint's cache policy and a shared cache cannot reuse one credential's copy for another. `/api/v1` follows that rule: anonymous requests are public for five minutes, authenticated ones are private and vary on `Authorization` because the quota headers are per credential. The contract, legal documents, and well-known metadata are credential-independent and stay public.
- `no-store` is never downgraded: responses carrying a one-time token or a billing-portal URL keep it even when the request was authenticated.

## Rate limits

Limits are per credential, reported on every authenticated response with the draft-11 fields:

```
RateLimit-Policy: "read";q=600;w=60
RateLimit: "read";r=598;t=42
```

- `read` 600 requests/minute (GET/HEAD), `write` 120 requests/minute, per API key or OAuth token. Counters key on credential IDs, never tokens.
- `export` 10 requests/minute covers `GET /tables/{table_id}/rows/export`; one streamed export is far more expensive than a JSON read and must not consume the read bucket. Discovery publishes the policy.
- `domain_availability` and `domain_auth_code` each allow 10 requests/minute: both make a live registrar call (and the auth code is a transfer secret), so neither may consume the ordinary read quota. Discovery publishes both.
- `domain_purchase` allows 5 requests/hour per credential on `POST /domain_purchases`. One purchase is a registrar registration and a charge; dedupe and `Idempotency-Key` replays still count because the quota is enforced before the action runs.
- `domain_deletion` allows 10 requests/hour per credential on `POST /domains/{id}/deletion`. Each request emails every workspace owner/admin and can order a real deletion. Discovery publishes it.
- `dns_check` allows 10 requests/minute per credential on `POST /dns_zones/{dns_zone_id}/delegation_checks`. Each check is a live DNS probe (resolver timeouts included), so it may not consume the ordinary read or write quota. Discovery publishes it.
- `429` responses carry `Retry-After` and an RFC 9457 problem body. The same quota headers appear on the 429.
- Authenticated 4xx responses (403, 404, 412, 422, …) keep `RateLimit`/`RateLimit-Policy` and the private cache policy; a request with no credential is rejected before quota accounting, so its 401 has no quota headers.
- IP abuse floors for `/api/v1` are 1200/min read and 240/min write; endpoint floors (e.g. asset/import/upload-session writes 20/min per IP) stay stricter. Middleware-level 429s use the same draft-11 headers.
- If the cache backing the counters is unavailable, the API fails open and omits the headers rather than reporting a quota it did not count.

## Deprecation

Retiring an endpoint means `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers, optional `Link` relations to the successor endpoint and the migration docs, a changelog entry, and `deprecated: true` on the operation in `openapi/openapi.yaml`.

```
Deprecation: @1762000000
Sunset: Tue, 01 Dec 2026 12:00:00 GMT
Link: <https://pageweave.dev/api/v2>; rel="successor-version", <https://pageweave.dev/docs/api/migrating-to-v2>; rel="deprecation"; type="text/html"
```

The `Deprecation` value is the deprecation timestamp (`@<unix seconds>`); `Sunset` is the HTTP-date after which the endpoint stops executing. Both links are optional. Between the two dates the endpoint keeps working with the headers; from the sunset date it returns `410` before authentication or business logic runs, and keeps the same headers so a client that only notices then still finds the replacement. The registry lives in `Api::Deprecations`; a value spec rejects an entry whose controller/action has no route and a sunset earlier than the deprecation date. Before retiring an endpoint, confirm traffic has moved off it and publish the changelog entry. There is no retired endpoint in v1 today — the plumbing is exercised by `spec/requests/api/v1/deprecation_spec.rb`. The registry is code, not a gem: no maintained Ruby library emits RFC 9745 — the available emitters predate it and hook `after_action`, so they cannot answer `410` before authentication.

## Related

- [API reference](https://pageweave.dev/docs/api) — every operation, schema, and example.
- [API error reference](https://pageweave.dev/docs/api/errors) — status map and problem members.
- [OpenAPI contract](https://pageweave.dev/openapi.json) — the served document (JSON; YAML at `/openapi.yaml`).
- MCP server: `https://pageweave.dev/mcp` — the agent-facing surface this API mirrors.
