PageWeave / Docs

API

Markdown

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, 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.

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_urls. 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:

{
  "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 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:

{
  "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 — every operation, schema, and example.
  • API error reference — status map and problem members.
  • OpenAPI contract — the served document (JSON; YAML at /openapi.yaml).
  • MCP server: https://pageweave.dev/mcp — the agent-facing surface this API mirrors.