# Writavo > Writavo is a content platform with AI article generation on top. It stores and serves a > site's articles, media, taxonomy and authors, publishes them to the customer's own domain, > and exposes the whole surface as a REST API, an MCP server and a CLI. An optional > autonomous pipeline researches, plans, writes, illustrates and publishes articles without a > human in the loop. Multi-tenant, platform-metered, and pre-launch: plan prices are not yet > published and the first customers are hand-invited design partners. ## When to use Writavo Reach for Writavo when the task is one of these. - **Programmatic content management for a blog or marketing site.** Create, edit, organise, schedule and publish articles, with media, categories, tags, authors and revisions, over a REST API. The API is the same surface the dashboard is built on, so anything a person can do to content, a key can do. - **Publishing to someone else's domain without touching their front end.** Writavo serves published content at `customer.com/blog` through a reverse proxy, at a managed subdomain, or through a headless read API the customer renders themselves. - **Generating a blog for a site that has none.** One switch turns on a staged pipeline that researches the market, plans topics, drafts original articles, generates images, quality-checks and publishes on a schedule. Useful when the user has a domain and no writers. - **Reading a Writavo-published blog back out.** Published content is retrievable as structured JSON rather than scraped from HTML. Do NOT reach for Writavo when the task is one of these. - **One-off text generation.** If the user wants a paragraph written, write it. Writavo is a content platform with a publishing pipeline, not a text endpoint. - **Managing a WordPress, Ghost or Webflow site the user already runs.** Writavo can push into those, but it does not administer them. Use that platform's own API. - **Anything about the user's account, team, roles or billing.** Those live in the dashboard and are deliberately outside the API surface, because they are the controls that decide what a key is allowed to do. - **Sending cold outreach from Writavo's infrastructure.** Writavo never sends from its own domains. Outreach uses the customer's own connected mailbox, gated behind identity checks and an accepted acceptable use policy. ## How an agent should call it 1. Read the OpenAPI specification at https://writavo.com/openapi.json. It is OpenAPI 3.1, every operation has a unique `operationId`, a description, typed parameters and typed response schemas, so function-calling definitions can be generated from it directly. 2. Authenticate with `Authorization: Bearer ` against `https://api.writavo.com/v1`. Keys are created by a signed-in person in the dashboard and never through the API. 3. The Site is resolved from the key on every request. There is no site, tenant or workspace parameter anywhere in the API, and supplying one does nothing. 4. Writes are draft by default. `POST /articles` always creates at `status: draft`; publishing is a separate explicit call. Nothing an agent sends becomes public by accident. 5. Branch on `error.code`, never on the message. Every code and its remedy is at https://writavo.com/docs/errors. 6. Honour `RateLimit-Remaining` and `Retry-After`. Limits are per key, per minute, by endpoint class, and every operation states its class. The fastest path for an assistant is not the raw API at all: the first-party MCP server `@writavo/mcp-server` exposes 38 tools generated from the same specification. Its server card is at https://writavo.com/.well-known/mcp. ## Constraints worth knowing before you call - **Cross-Site access returns 404, never 403.** A 404 does not prove an object does not exist. - **Pipeline runs cost money and are capped.** `POST /pipeline/runs` is the only billable operation; everything else, publishing included, is free. Four distinct 402 codes tell you which limit you met: plan, credits, spend cap, or payment method. - **CMS allowances bill rather than block.** Exceeding a content, storage or request allowance does not take a published blog offline. - **Ten of the thirteen article statuses belong to the pipeline** and are read-only. A PATCH that tries to set one is refused. ## Machine-readable resources - [OpenAPI specification (JSON)](https://writavo.com/openapi.json): OpenAPI 3.1, 50 operations, every one with a unique operationId, description and typed schemas - [OpenAPI specification (YAML)](https://writavo.com/openapi.yaml): the same document, byte-identical in content, for generators that prefer YAML - [MCP server card](https://writavo.com/.well-known/mcp): discovery document for @writavo/mcp-server: transports, tools, auth and version - [Public metadata API](https://writavo.com/api/v1): unauthenticated JSON: service status, the documentation index and the spec, with RFC 9331 rate-limit headers - [Full corpus](https://writavo.com/llms-full.txt): every page on this site as one markdown document - [Sitemap](https://writavo.com/sitemap.xml): every indexable URL ## Documentation - [Overview](https://writavo.com/docs.md): What the API is, what it is for, and where the base URL points. - [Quickstart](https://writavo.com/docs/quickstart.md): Create a key, create an article, publish it, read it back. - [Authentication and keys](https://writavo.com/docs/authentication.md): The two key kinds, scopes, rotation, and why a key never outranks its creator. - [The content lifecycle](https://writavo.com/docs/content-lifecycle.md): Draft, scheduled, published, and the ten statuses that belong to the engine. - [Pagination, fields and concurrency](https://writavo.com/docs/pagination.md): Cursor paging, sparse fieldsets, Idempotency-Key and If-Match. - [Errors](https://writavo.com/docs/errors.md): Every code, what it means, and what to do about it. - [Rate limits](https://writavo.com/docs/rate-limits.md): The per-class limits and the Retry-After contract. - [Media uploads](https://writavo.com/docs/media.md): The three step presigned flow, accepted types and size limits. - [Webhooks](https://writavo.com/docs/webhooks.md): The event catalog, the payload envelope, and a runnable signature check. - [API reference](https://writavo.com/docs/api.md): Every operation, generated from openapi.yaml. - [MCP server](https://writavo.com/docs/mcp.md): Point an AI assistant at your Site's content. - [Command line](https://writavo.com/docs/cli.md): Drive the whole API from a shell or a script. - [Versioning and changelog](https://writavo.com/docs/versioning.md): What /v1 promises, and what has changed. ## API reference - [All operations](https://writavo.com/docs/api.md): index of all 50 operations with their operation ids - [Meta](https://writavo.com/docs/api/meta.md): Connectivity, Site information, content types, limits and usage. - [Articles](https://writavo.com/docs/api/articles.md): The content spine. Create, edit, organise, publish and schedule. - [Categories](https://writavo.com/docs/api/categories.md): The closed taxonomy. Exactly one category per article. - [Tags](https://writavo.com/docs/api/tags.md): The cross cutting taxonomy. Many tags per article. - [Authors](https://writavo.com/docs/api/authors.md): The byline roster for a Site. - [Media](https://writavo.com/docs/api/media.md): The media library. Two step upload, then registration. - [Pipeline](https://writavo.com/docs/api/pipeline.md): The AI generation engine. The only billable surface in this API. - [API keys](https://writavo.com/docs/api/api-keys.md): Key management. Specified here, implemented in API-2. - [Webhooks](https://writavo.com/docs/api/webhooks.md): Outbound event delivery. Register an HTTPS endpoint, subscribe it to events, and receive a signed POST whenever your content changes, from this API or from the dashboard. ## Tools - [@writavo/mcp-server](https://www.npmjs.com/package/@writavo/mcp-server): MCP server, 38 tools generated from the specification. Docs at https://writavo.com/docs/mcp - [@writavo/cli](https://www.npmjs.com/package/@writavo/cli): command line client. `npx @writavo/cli articles list`. Docs at https://writavo.com/docs/cli ## Optional - [About](https://writavo.com/about.md): What Writavo is, who builds it, and how the product works. - [Contact](https://writavo.com/contact.md): How to reach support, security and sales. - [Privacy](https://writavo.com/privacy.md): What data Writavo holds, why, and how to have it erased. - [Terms](https://writavo.com/terms.md): The terms of service for the Writavo platform and API. - [Support](mailto:support@writavo.com): questions a document did not answer --- # A blog that grows itself, on your domain. Point Writavo at your website and get a continuously growing, SEO-optimized blog that publishes on your own domain. No writing, no commissioning, no busywork. Research, write, optimize, and publish. End to end, on autopilot. ## From domain to ranking blog, untouched by you. ### Connect your site Tell Writavo your domain and niche. It studies your market, your competitors, and the keywords worth winning. ### It writes, autonomously A staged pipeline plans topics, drafts original articles, generates images, and self-checks quality before anything ships. ### Publishes on your domain Articles go live on your own root domain for full SEO equity. Stay hands-off, or step in to edit any time. ## Four ways to reach your domain. We pick the strongest for you. ### Subdirectory proxy (yoursite.com/blog) The strongest zero-code option. A one-click reverse proxy keeps every bit of ranking equity on your root domain. ### Headless API + SDK (developer wedge) Pull published content into your own stack with our client SDK. Best for teams that render on their own front end. ### Subdomain (blog.yoursite.com) A managed subdomain with automatic SSL. A clean fallback when a proxy is not an option. ### CMS push (WordPress, Ghost, more) Publish straight into the CMS you already run. Your stack, filled automatically. ## An autonomous engine, plus the controls when you want them. ### Original content, always Every article is generated from scratch and quality-gated. We extract signal from research, never copy prose. ### Built-in SEO suite Keyword tracking, content-gap analysis, internal linking, and competitor insight, all in one place. ### Per-site control Tune the AI prompts, formats, authors, and publishing schedule for each site you run. ### Multi-site ready Run one site or fifty under a single account. Built for agencies and portfolios from day one. ## Plans that scale with you. ### Free Get your first articles live on your domain. ### Indie A steady cadence for a single growing site. ### Growth More volume, the full SEO suite, priority pipeline. ### Agency Many sites, one account, team roles and seats. ### Enterprise Custom volume, controls, and support. Join the waitlist for founding-member pricing. 50% off your plan for the first 6 months. ## The things people ask before they sign up. ### Do I have to use the AI, or can Writavo just be my CMS? You can use it as the CMS alone. Articles, media, categories, tags, authors, revisions and scheduling all work whether or not a single word is generated, and everything is readable back over the REST API. The AI pipeline is a switch on top of that, not a requirement of it. Turning it off does not remove features from the content platform. ### Where do the articles actually get published? On your domain, by whichever of four routes suits your stack. A reverse-proxy subdirectory at yoursite.com/blog is the default because it keeps ranking equity on the domain you already have. You can also pull published content through the headless API and render it yourself, serve it from a managed subdomain with automatic SSL, or push it into a CMS you already run. Writavo recommends the strongest option your setup supports rather than defaulting everyone to the same one. ### Is the content original, or is it rewritten from other people's articles? It is written from scratch. The research stage extracts structured signal from what it reads - the questions being asked, the gaps, the entities that matter - and that signal grounds the writing. Scraped prose is never published, and the generation and research paths are separated precisely so it cannot be. A quality gate runs before an article becomes publishable, and you can edit anything at any stage. ### Can an autonomous pipeline run up an unlimited bill? No, and this is enforced rather than promised. Every Site has a spend cap that is checked before a run starts and again inside the loop after each billable call, so a runaway stops at the ceiling instead of at the invoice. Writavo owns every third-party key, so you never hold an AI or SEO data credential yourself and no vendor bills you directly. ### Is there an API, and can an AI agent use it? Yes. The Writavo Content API is a REST API at api.writavo.com/v1, documented at writavo.com/docs and published as an OpenAPI 3.1 specification at writavo.com/openapi.json. Every operation has a stable operation id, a typed schema and a description, so a client or an LLM function-calling schema can be generated from it directly. There is a first party MCP server on npm as @writavo/mcp-server and a command line client as @writavo/cli. ### Can I get started today? Writavo is pre-launch. Plan prices are deliberately not published yet and the first customers are hand-invited design partners in dissimilar niches, because the open question about an autonomous content engine is whether it works outside the niche it was built in. Join the waitlist for founding-member pricing, or get in touch if you want to be a design partner. ## Your blog should be working while you sleep. - Join the waitlist: https://writavo.com/waitlist - API documentation: https://writavo.com/docs - OpenAPI specification: https://writavo.com/openapi.json - Guidance for agents: https://writavo.com/llms.txt - About: https://writavo.com/about - Contact: https://writavo.com/contact - Privacy: https://writavo.com/privacy - Terms: https://writavo.com/terms --- # Join the Writavo waitlist Join the waitlist for founding-member pricing. 50% off your plan for the first 6 months. ## What happens when you join 1. You give us an email address. Nothing else is required. 2. The offer you joined on is recorded against your signup, so it is honoured later even if the published terms change. 3. We contact you when your cohort opens. Design partners are invited by hand. ## What we store - The email address you give us. - The referral parameters that brought you here. - A SHA-256 hash of your IP address. The raw address is never stored. Full detail: https://writavo.com/privacy ## Sign up Open https://writavo.com/waitlist in a browser. There is no public API for joining the waitlist. --- # About Writavo A content platform with AI article generation on top: what it does, how the pipeline works, where your articles are served from, and where the product actually is today. ## What Writavo is Writavo is a content platform with AI article generation built into it. You manage articles, media, taxonomy and authors, publish to your own domain, and read everything back over a REST API. On top of that, one switch turns on an autonomous pipeline that researches your market, plans topics, drafts original articles, generates images, quality-checks the result and publishes it on a schedule you set. The two halves are deliberately separable. A team that never wants a single AI-written word can use Writavo purely as the CMS behind its own writing process. A team with no writers at all can turn the pipeline on and have real articles on day one instead of an empty content library. Most customers sit somewhere between the two, and the product is built so that moving along that line costs nothing. ## How the pipeline works The engine is a staged pipeline rather than a single prompt, and each stage is separately budgeted, separately retried and separately observable. 1. **Research.** Writavo studies your domain, your competitors and the keyword landscape, and extracts structured signal from what it finds. 2. **Plan.** It turns that signal into a topic queue, ordered by what is winnable rather than by what has the highest search volume. 3. **Draft.** It writes each article from scratch against the plan. Scraped prose is never published: research grounds the writing, it does not become it, so what ships is original. 4. **Illustrate.** Images are generated for the article rather than pulled from a stock library. 5. **Review.** A quality gate runs before anything becomes publishable, and an article that fails it does not silently ship. 6. **Publish.** The article goes live on your domain by whichever delivery route you chose. You can intervene at any stage. Nothing in the pipeline is a black box you are locked out of: every article is an editable document in the CMS at every step. ## Where your content lives Ranking equity belongs on your root domain, so Writavo offers four ways to get it there and recommends the strongest one your stack can support. - **Subdirectory proxy** at `yoursite.com/blog`. The strongest zero-code option, because every article accrues authority to the domain you already rank on. A one-click reverse proxy for Cloudflare, Next.js or Nginx. - **Headless API** with our client SDK, for teams that render on their own front end. The best SEO outcome available, because you serve the HTML yourself. - **Subdomain** at `blog.yoursite.com`, with automatic SSL. An honest fallback when a proxy is not possible, with the honest caveat that a subdomain is not the same as your root domain. - **CMS push** into WordPress, Ghost and others, so the stack you already run stays the stack you run. ## How Writavo is built - **Multi-tenant, with isolation enforced in our own layer and not only in the database.** Every operational row carries the Site it belongs to, resolved server-side from your verified session or your API key, never from anything the client sends. Cross-Site access returns 404 rather than 403, so the API never discloses that another tenant's object exists. - **We own every third-party key.** There is no bring-your-own-key step: the AI provider, the SEO data provider and the image provider are all billed to Writavo and metered back to your Site. You never hold a vendor credential in order to make the product work. - **Every billable stage has a hard spend cap.** An autonomous pipeline spends real money on every run, so the ceiling is enforced per Site both before a run starts and inside the loop after each billable call. Pricing is not the safety mechanism; the cap is. - **The CMS never stops serving because of a limit.** CMS allowances bill when exceeded rather than blocking, because a live blog going dark over an invoice is not an acceptable failure. ## Where the product is today Writavo is pre-launch. There is a founding-member waitlist, plan prices are deliberately not published yet, and the first customers are hand-invited design partners in different niches. That last part is the point rather than a limitation: the open question about an autonomous content engine is whether it works outside the niche it was built in, and that question is answered with design partners, not with a launch. If you want to be one, join the [waitlist](/waitlist) or [get in touch](/contact). --- # Contact Writavo How to reach support, report a security problem, apply as a design partner, and find every machine-readable resource this site publishes. ## Support Email [support@writavo.com](mailto:support@writavo.com) for anything to do with your account, your Sites, billing, or the product not doing what the documentation says it does. Writavo is pre-launch and support is handled by the people who build it, so a reply comes from someone who can actually fix the thing you are writing about. We aim to answer within one working day. Include your Site name and, if the problem is an API call, the error code from the response body: every code is listed with what to do about it in the [error reference](/docs/errors). ## Security Email [security@writavo.com](mailto:security@writavo.com) to report a vulnerability. Please do not open a public issue for a security problem. We will acknowledge a report within two working days and tell you what we intend to do about it. Testing against your own Site and your own data is welcome. Testing that degrades service for other tenants, or that reaches for another tenant's data beyond proving the boundary holds, is not: tell us what you found and we will reproduce it ourselves. ## Sales and design partners Email [support@writavo.com](mailto:support@writavo.com) with "design partner" in the subject. The first cohort is deliberately small and deliberately spread across dissimilar niches, so tell us what you publish about and what you are trying to rank for. ## Developers and agents The [API documentation](/docs) is public and needs no account to read. The machine-readable surface is published at stable URLs: | Resource | URL | |---|---| | OpenAPI specification, JSON | [/openapi.json](/openapi.json) | | OpenAPI specification, YAML | [/openapi.yaml](/openapi.yaml) | | Agent guidance and when to use Writavo | [/llms.txt](/llms.txt) | | The full agent corpus | [/llms-full.txt](/llms-full.txt) | | MCP server card | [/.well-known/mcp](/.well-known/mcp) | | Public metadata API | [/api/v1](/api/v1) | The MCP server is on npm as `@writavo/mcp-server` and the command line client is `@writavo/cli`. Both are generated from the same OpenAPI specification linked above, so neither can describe an endpoint that does not exist. --- # Privacy What Writavo holds, why it holds it, who else processes it, how long it is kept, and how to have it erased. This marketing site sets no cookies and runs no tracking script. Effective 25 August 2026. ## The short version Writavo holds the account details you give us, the content and configuration of the Sites you create, and the usage records we need in order to bill you and to stop an autonomous pipeline from spending without limit. We do not sell any of it, we do not build advertising profiles, and we do not use one customer's content to train anything for another customer. ## What this site collects This marketing site, `writavo.com`, sets **no cookies**, runs **no advertising or analytics tags**, and requires no account to read. Pages are server-rendered and ship no tracking script. Two things are recorded when you use it: - **Request logs.** Method, path, referring host, a shortened user agent, the client IP and the country, written to our application log. Query strings, request bodies and any header that can carry a token are deliberately never logged. - **Error reports.** If a page throws, an error report goes to Sentry with the user's email, IP address and username stripped before it leaves the browser. If you join the waitlist we store the email address you give us, the referral parameters that brought you here, and a **SHA-256 hash of your IP address**. The raw address is never stored. You can have the row deleted by emailing [support@writavo.com](mailto:support@writavo.com). ## What the product collects | Category | What it is | Why we hold it | |---|---|---| | Account | Email address, name, password hash, the organisations and Sites you belong to | To sign you in and to decide what you may do | | Site configuration | Domain, niche, prompts, schedule, authors, delivery settings | It is the product | | Content | Articles, media, taxonomy, revisions | It is your content, held for you | | Usage | Per-Site counts of API requests, generated articles, storage and bandwidth | Billing, and the spend caps that stop a runaway pipeline | | Audit | Who did what, when, in an append-only log | Security, and answering "who changed this" | | Integrations | Search Console OAuth tokens, CMS push credentials, connected mailbox credentials | Only the connections you explicitly authorise | Visitors to a blog you publish through Writavo are counted in aggregate for your analytics. We do not set a cross-site identifier on them, and engagement writes are attributed by a hash rather than by a stored visitor identity. ## Who else processes it Writavo runs on third-party infrastructure. These are the processors that can hold customer data, and what each one is for. | Processor | Purpose | |---|---| | Supabase | The application database, authentication and file storage | | Vercel | Hosting for the dashboard, the marketing site and the public blog gateway | | Cloudflare | The API gateway, DNS, and SSL for customer custom domains | | Stripe | Subscriptions and payment. Card details go to Stripe and never touch our systems | | Sentry | Error reporting, with personal fields scrubbed before send | | DeepSeek | The language model behind article generation | | DataForSEO | Keyword, competitor and backlink data | | Image generation providers | Article illustrations | We hold the account with every one of these. There is no step where you hand us a vendor key, and no step where a vendor bills you directly. ## What we do not do - We do not sell personal data, and we never have. - We do not use your content to train a model for anyone else. - We do not send marketing email to addresses we obtained anywhere other than from you. - We do not run advertising trackers on this site or on a blog we publish for you. ## Retention Content and configuration are kept for as long as your organisation exists. Append-only ledgers, the audit log and the API call record, are kept on a fixed retention window and then removed by a scheduled job rather than accumulating forever. Deleting your organisation deletes its Sites, its content and its memberships. Some records are kept afterwards where we are required to keep them, principally billing records. ## Your rights Email [support@writavo.com](mailto:support@writavo.com) to ask for a copy of what we hold about you, to correct it, or to have it erased. We will respond within 30 days. Erasure of an outreach contact is a first-class operation in the product rather than a support ticket: a Site owner can erase a contact and every message record attached to it directly. ## Children Writavo is a business product and is not directed at anyone under 16. We do not knowingly hold data about children. If you believe we have, tell us and we will remove it. ## Changes If this statement changes materially we will update the effective date above and tell account holders by email before the change takes effect. This page is the current version. ## Contact Privacy questions: [support@writavo.com](mailto:support@writavo.com). Security reports: [security@writavo.com](mailto:security@writavo.com). --- # Terms of service How the platform, the public API and the documentation may be used, who owns the content, and how the CMS and the AI pipeline are billed on two separate axes. Effective 25 August 2026. **These terms are written in plain language and are not a substitute for a signed agreement.** Writavo is pre-launch and has no self-serve paid plans. A full written agreement, including the governing law and the jurisdiction, is issued and signed before any paid engagement begins. This page states how the service is intended to be used in the meantime, and it binds anyone using the free product, the public API or the documentation. ## The service Writavo is a hosted content platform with an optional AI article generation pipeline, operated at `writavo.com`, `api.writavo.com` and the domains you connect to it. "You" means the organisation that holds the account. ## Your account You are responsible for what happens under your account, including anything done with an API key you issued. A key never carries more authority than the person who created it. Keep keys out of client-side code and out of version control, and rotate one you believe is exposed, from the dashboard, immediately. One person may belong to several organisations. Authority comes from your membership of an organisation rather than from your login, so removing someone from an organisation removes their access to it. ## Your content is yours You own the content you create in Writavo, including the articles the pipeline generates for you. We hold it in order to serve it and to show it to you. We do not claim a licence to reuse it, we do not resell it, and we do not train models for other customers on it. You are responsible for what you publish. That includes reviewing generated articles before they go out: a quality gate is not an editorial policy, and it does not know what your business must not say. ## AI-generated content Articles are generated by a language model against research that Writavo gathers. Output is written from scratch rather than paraphrased from a source, and a post-generation sanitiser enforces our house style. What we do not promise is factual perfection, and no reasonable person should treat an unreviewed generated article as a verified statement of fact. Review before you publish, particularly in a regulated field. ## Acceptable use You may not use Writavo to publish content that is unlawful, that infringes someone's rights, or that exists to deceive a search engine rather than to inform a reader. You may not attempt to reach another tenant's data, to defeat a spend cap or a rate limit, or to resell raw access to the third-party data providers behind the product. Outreach has its own rules because the law has its own rules. Writavo never sends outreach from its own domains: you connect your own mailbox, and your own sending reputation is what is at stake. Before a mailbox can connect you must accept the outreach acceptable use policy and pass identity checks. Every campaign carries a working opt-out and a physical postal address, both enforced by the system rather than by a checkbox, and a contact who replies is removed from the remaining steps automatically. ## Billing Two things are priced on two different axes, and it matters which one you are looking at. - **The CMS is pay as you go and identical for every customer.** Seats, Sites, custom domains, documents, storage, bandwidth and API requests come with an included allowance. Exceeding an allowance bills for the overage; it does not switch your blog off. If you are over the allowance with no payment method on file we will ask for one, and nothing already published stops serving. - **The plan sells the AI pipeline.** Credits, tracked keywords, generation volume and the SEO suite are what a plan tier changes. No plan tier changes a CMS allowance. Our own usage ledger is the record of what you used. Fees are exclusive of tax unless stated. ## Availability We do not offer an uptime guarantee on the free product. A published blog is served through a cache tier and is designed to keep serving through an outage of the dashboard, because your readers are not our users. ## Suspension and termination You may close your organisation at any time. We may suspend an organisation that is being used in breach of these terms, or whose payment has failed after notice. Suspension stops the service responding for that organisation; it does not delete content, and a suspended Site returns a "temporarily unavailable" response rather than pretending it never existed. ## Liability Writavo is provided as it is. To the extent the law allows, we are not liable for lost profits, lost rankings, or indirect or consequential loss, and our total liability is limited to the fees you paid us in the twelve months before the claim. ## Changes We will update the effective date above when these terms change, and tell account holders by email before a material change takes effect. ## Contact Questions about these terms: [support@writavo.com](mailto:support@writavo.com). --- # The programmable half of the CMS The CMS control plane for a Writavo Site. ## What this API is for Writavo stores your content, serves it to your domain, and can generate it for you. This API is how you drive all three from your own code: create and edit articles, organise them with categories, tags and authors, upload media, publish and schedule, and ask the AI engine to do a piece of work. It is the same surface the dashboard is built on, so anything the dashboard can do to your content, you can do here. A change made through this API and the same change made by a person in the dashboard are indistinguishable afterwards, including to your webhooks. > The one thing this API deliberately does not cover is account administration. Team members, roles, billing and delivery routing stay in the dashboard, because they are the controls that decide what a key is allowed to do in the first place. ## Base URL base url: ``` https://api.writavo.com/v1 ``` One hostname, every customer. There is no per-account subdomain and no Site parameter, because the Site is resolved from your key on every request. ## How it works The Writavo Content API is the programmable half of the CMS. It lets you create, edit, organise, publish and schedule content without opening the dashboard, and it is the same surface the dashboard itself is built on. #### The Site is resolved from your key, never from your request Every key belongs to exactly one Site. The API resolves the owning Site from the key on every request, so there is no site or tenant parameter anywhere in this specification. An object belonging to a different Site is not visible to you at all: it returns 404, never 403, so the API never discloses that it exists. #### Draft by default: what happens if you create an article and never publish it `POST /articles` always creates the article at `status: draft`. A draft is private. It is not readable on your public blog, it is not in your sitemap, it is not in any feed, and no part of the AI pipeline will ever pick it up, rewrite it or publish it. It simply sits there, editable, until you call `POST /articles/{id}/publish` or delete it. There is no way to create an article that is public in one call. Publishing is always a separate, explicit request. That is the safety property this whole API rests on: nothing you send us becomes public by accident. #### Statuses you may set, and statuses you may only read Three statuses are yours: `draft`, `scheduled` and `published`. You move between them with the lifecycle endpoints (`/publish`, `/unpublish`, `/schedule`, `/cancel-schedule`), not by PATCHing `status`. The remaining ten statuses belong to the AI pipeline. You can read them, so you can see what the engine is doing, but you cannot set them. A `PATCH` that tries to move an article into a pipeline status is rejected with `422 VALIDATION_FAILED`. This prevents an API client from injecting work into the generation engine, and therefore into your bill. #### What happens if you run out of credits mid-pipeline `POST /pipeline/runs` is the only billable operation in this API. Everything else, including publishing, is free: publishing an article you already have makes no external call. A pipeline run is gated by four independent checks, in this order: 1. **Scope.** Your key must carry `pipeline:run`, or you get `403 INSUFFICIENT_SCOPE`. 2. **Entitlement.** Your plan must include the capability, or you get `402 NOT_ENTITLED`. Note this applies to the AI pipeline only. The CMS half of this API - content, media, taxonomy, webhooks, reads and writes - is included on every plan, and is billed by usage rather than gated by tier. 3. **Credits.** Your organisation must be able to afford the next unit of work, or you get `402 INSUFFICIENT_CREDITS`. 4. **Spend cap.** Your Site's own monthly cap must not be reached, or you get `402 SPEND_CAP_REACHED`. These are four distinct error codes on purpose, because the fix differs: buy credits, raise your cap, or upgrade your plan. A run is a request to the engine, not a transaction. If credits run out part way through a run that has already started, the engine stops cleanly at the next stage boundary. Work already completed is kept and already charged. Nothing is rolled back, no article is left half written, and every article stays at whatever status it legitimately reached. The run finishes with `status: partial` and a reason you can read from `GET /pipeline/runs/{id}`. Top up and request another run and the engine resumes from where it stopped. #### Versioning promise `/v1` is additive only. We may add new endpoints, new optional request fields and new response fields, and you must tolerate unknown response fields. We will not, within `/v1`, remove a field, remove or narrow an enum value, rename anything, make an optional request field required, or change the HTTP status code of an existing outcome. Any of those would ship as `/v2`, with `/v1` supported alongside it. New values may be added to read-only enums (for example `article.status`, if the pipeline grows a stage). Treat every read-only enum as open and fall through gracefully on a value you do not recognise. ## Where to start ## The specification Everything in the reference is generated from one file, which is served at a stable URL so you can generate your own client rather than hand writing one. - OpenAPI 3.1, so most generators consume it directly. - Every operation states the scope, the plan feature, the rate limit class and whether a publishable key may reach it. - The docs you are reading are built from it. If a page and the spec ever disagreed, the build would fail rather than publish. --- # Your first published article Five requests. Create a key, create a draft, publish it, read it back, and read it the way a browser would. ## Get a key Keys are created in the dashboard, not through this API, because creating the first one needs a signed in person rather than a credential you do not have yet. 1. **Open Settings, then API keys** In the dashboard, go to Settings and choose API keys. You need the `api_keys.manage` permission; an owner or admin has it by default. 2. **Create a secret key** Choose **Secret** and give it the scopes this quickstart uses: `articles:read`, `articles:write` and `meta:read`. A key can never do more than the person who created it, so grant yourself what you need first. 3. **Copy it now** The secret is shown once and never again. Only a hash is stored, so a lost key can be rotated but not recovered. 4. **Create a publishable key too** Choose **Publishable** with `articles:read`. Step 5 uses it. This is the kind that is safe in a browser bundle. > A secret key is a server side credential. Do not put one in a browser bundle, a mobile app, or a repository. If one leaks, revoke it from the same screen; revocation takes effect on the next request. ## Set your shell up Every example on this page uses these two values. Export them once and the requests below are copy-pasteable as they stand. shell: ``` export WRITAVO_SECRET_KEY="wv_sk_your_secret_key" export WRITAVO_PUBLISHABLE_KEY="wv_pub_your_publishable_key" export WRITAVO_BASE="https://api.writavo.com/v1" ``` The snippets print the key inline so that each one is complete on its own. Substitute your real key, or replace the literal with `$WRITAVO_SECRET_KEY`. ## The five requests **1. Check your key works** (returns 200) ```bash curl https://api.writavo.com/v1/ping \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` The cheapest authenticated call there is. It tells you the kind of key you presented and the scopes it carries. **2. Create a draft** (returns 201) ```bash curl -X POST https://api.writavo.com/v1/articles \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: b6f4b0e2-0000-4000-8000-0000000000aa" \ -d '{ "title": "How to choose a headless CMS", "slug": "how-to-choose-a-headless-cms", "excerpt": "A practical framework for picking a headless CMS without regretting it.", "content": "## Start with your delivery model\n\nThe first question is not which CMS. It is where the HTML is rendered." }' ``` This always creates a draft. There is no request field that can make it public. **3. Publish it** (returns 200) ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/publish \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` Publishing is free. It makes no external call, so it spends no credits and is unaffected by your spend cap. **4. Read it back** (returns 200) ```bash curl https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001?fields=id,status,title,slug,published_at \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` A sparse fieldset. `fields` is an allow list, so there is no way to ask for everything. **5. Read it with a publishable key** (returns 200) ```bash curl https://api.writavo.com/v1/articles?limit=5&fields=id,title,slug,published_at \ -H "Authorization: Bearer wv_pub_EXAMPLE000000000000000000000000000" ``` A publishable key is safe in a browser. It sees only published articles, and it cannot write. ## What just happened - **The Site came from your key.** Nothing you sent named a Site, and nothing you can send would change which one you reached. - **Nothing became public by accident.** `POST /articles` created a draft. It took an explicit publish call to put it on the web, and that is true of every article, from this API or from the engine. - **Publishing was free.** It made no external call, so it spent no credits and ignored your spend cap. Only `POST /pipeline/runs` bills. - **The publishable key saw only what is published.** The same request with the secret key would also have returned drafts. ## Next - [Authentication and keys](/docs/authentication) for scopes, rotation, and why demoting a colleague changes what their keys can do. - [The content lifecycle](/docs/content-lifecycle) for scheduling, and for which statuses you may set. - [Webhooks](/docs/webhooks) to be told when content changes instead of polling for it. - [The API reference](/docs/api) for every operation. --- # Keys, kinds and scopes Every request carries a key. The key decides which Site you are talking to, what you may do, and whether you are safe to embed in a browser. ## Sending a key `Authorization: Bearer `. Two kinds of key, distinguished by their prefix: - **`wv_pub_`, publishable.** Safe in a browser or a mobile app. Read only, and it can only see content at `status: published`. It cannot read drafts, cannot write, and cannot see keys, usage, media, the pipeline queue or webhooks. The operations it may reach are marked `x-publishable: true`; everything else is `403 INSUFFICIENT_SCOPE` for a publishable key regardless of its scopes. - **`wv_sk_`, secret.** Server side only. Never ship one to a client, and never commit one. It can carry any scope. A secret key's authority is its creator's live permissions intersected with the scopes it was given. Narrowing or removing that person's access narrows every key they created, on the next request. A key never outranks the person who made it. Only a hash is stored. A lost key cannot be recovered, only rotated. ## The two kinds `publishable` keys are prefixed `wv_pub_` and are safe in client code: read only, limited to published content, and admitted only to the operations marked `x-publishable: true`. Anything else returns `403 INSUFFICIENT_SCOPE`, whatever scopes the key carries. `secret` keys are prefixed `wv_sk_` and are server side only. **What a publishable key cannot do** (returns 403) ```bash curl https://api.writavo.com/v1/usage \ -H "Authorization: Bearer wv_pub_EXAMPLE000000000000000000000000000" ``` `GET /usage` returns the organisation's credit balance and plan, so a browser-safe key is refused whatever scopes it carries. ## What a publishable key may reach The list is deny by default. An operation is reachable by a publishable key only when it has been explicitly reasoned about, which is why three read scopes are not available to one at all. > `GET /usage` is the instructive exception. It sits under `meta:read`, which a publishable key may hold, but it returns the organisation credit balance and plan. Scope alone was not a fine enough gate, so each operation states its own answer. ## Scopes What a key is permitted to do. A key carries a set of these, and every operation names the one it needs. Scope is the outer bound, not the whole answer. A request must also pass the creator's live permissions, the plan entitlement, and, for a pipeline run, credits and the spend cap. A scope you hold can still be refused by the gate behind it. A publishable key may only carry `:read` scopes, and only ever sees published content. Three of them are deliberately NOT available to a publishable key, because the endpoints behind them return things a browser bundle must not carry: `media:read` lists the whole library including assets attached only to drafts, `pipeline:read` returns the Site's unpublished editorial queue, and `webhooks:read` is account configuration. `meta:read` IS publishable, but `GET /usage` within it is not: it returns the organisation's credit balance and plan, and a publishable key presenting it gets `403 INSUFFICIENT_SCOPE`. Every operation states which side of that line it is on in `x-publishable`. ## A key never outranks its creator > A secret key's authority is **its creator's live permissions intersected with its scopes**. It is resolved on every request, not frozen at creation. This has a consequence worth knowing before it surprises you rather than after. If the person who created a key is demoted, has a permission removed, or leaves the organisation, every key they created narrows or stops working on the next request. An integration that has been running for months can start returning `403 INSUFFICIENT_SCOPE` because of a change nobody connected to it. - Create keys for long lived integrations under an account that will not be reorganised, not under whoever happened to be setting it up. - When someone leaves, expect their keys to stop. Recreate them under a current member rather than trying to restore the old permissions. - `GET /keys` shows the scopes actually granted, after the intersection. If a scope you asked for is missing, the creator did not have it. **List the keys on this Site** (returns 200) ```bash curl https://api.writavo.com/v1/keys \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` Metadata only. A secret is shown once at creation and is never retrievable, because only a hash is stored. ## Expiry, rotation and revocation - **Shown once.** Only a hash is stored. A lost key cannot be recovered, only rotated. - **Expiry is optional.** Set `expires_at` when you create a key and it stops working at that moment with `API_KEY_EXPIRED`, which is a cleaner failure than a key that lives for ever. - **Rotation issues a new secret** and returns it once. Update your integration, then let the old one go. - **Revocation is immediate** on the next request, and answers `API_KEY_REVOKED`. - **`key_prefix` is the only displayable fragment.** Use it to tell keys apart in your own logs and UI. All four are operations on the [keys resource](/docs/api/api-keys), and all four are also available in the dashboard. ## When authentication fails `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. --- # Nothing becomes public by accident `POST /articles` always creates a draft. Publishing is always a separate, explicit request. That is the safety property this whole API rests on. ## Draft by default There is no request field that can create a public article in one call. Supplying `status` on a create is a validation error rather than a silent ignore, so a client written against a different CMS fails loudly instead of quietly leaving your content unpublished. A draft is genuinely private. It is not on your blog, not in your sitemap, not in any feed, and no part of the AI pipeline will pick it up, rewrite it or publish it. It sits there, editable, until you publish it or delete it. ## Statuses you may set, and statuses you may only read The article state machine. Thirteen values, in two groups. **Yours, settable through the lifecycle endpoints:** - `draft` - private and editable. Where every article starts. Never touched by the AI. - `scheduled` - has a future `scheduled_publish_at`. A cron publishes it at that time. - `published` - public on your blog. **The pipeline's, readable but not settable.** A `PATCH` naming any of these is rejected with `422 VALIDATION_FAILED`: - `discovered`, `scored`, `skipped` - a candidate source found and triaged. - `scraped` - source fetched, research signal extracted. - `generated` - a draft the engine wrote, awaiting quality review. - `needs_improvement` - failed quality review; queued for a rewrite. - `needs_images` - text is final, images are being generated. - `queued` - finished and waiting for its publishing slot. - `rejected`, `failed` - abandoned, or errored past retry. More pipeline values may be added within v1 as the engine grows. Handle unknown values gracefully. ## Why the engine's statuses are read only > A `PATCH` that tries to move an article into a pipeline status is rejected with `422 VALIDATION_FAILED`. This is what stops an API client injecting work into the generation engine, and therefore into your bill. **Why you cannot PATCH a status** (returns 422) ```bash curl -X PATCH https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -d '{ "status": "queued" }' ``` The ten pipeline statuses are read only. This is what stops an API client injecting work into the generation engine, and therefore into your bill. ## Moving between states Four endpoints, all on the [articles resource](/docs/api/articles). Each is safe to repeat: calling one on an article that is already in the target state is a no-op that returns the current state. - `POST /articles/{id}/publish` puts it on the web now. It needs a non empty title, slug and content, and tells you which is missing. - `POST /articles/{id}/unpublish` takes it back down and returns it to `draft`. Nothing is deleted. - `POST /articles/{id}/schedule` sets a future time. A cron publishes it then, whether or not the AI engine is switched on for your Site. - `POST /articles/{id}/cancel-schedule` returns it to `draft` and clears the time. **Take a post off the web** (returns 200) ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/unpublish \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` Reversible, and nothing is deleted. `published_at` is left intact so a post that goes back up keeps its place in the archive. **Schedule a publish** (returns 200) ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/schedule \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -d '{ "scheduled_publish_at": "2026-12-01T09:00:00Z" }' ``` The time must be in the future, and the title, slug and content are checked now rather than at the scheduled moment. **Cancel a scheduled publish** (returns 200) ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/cancel-schedule \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ## published_at is the first publication date It is set when an article first becomes public and then left alone. Unpublishing does not clear it and republishing does not move it, because it is your blog's ordering key: a post that goes back up keeps its place in the archive rather than jumping to the top of the feed. If you want a post to be new again, that is a new article. If you want to correct the date, that is a dashboard action, deliberately: silently rewriting publication dates is a thing search engines notice. ## Delete or unpublish Deleting is permanent. There is no trash and no undo in `/v1`. If you only want a post off the web, unpublish it: everything is kept and the operation is fully reversible. Both make the URL start returning 404 on your blog once the cache is purged, which happens as part of the request. --- # Paging, projections and safe retries Four mechanics that decide whether an integration is correct under load: how you page, how much you ask for, how you retry, and how you avoid overwriting someone else's edit. ## Cursor pagination The shape of `data` on every list endpoint. Paging is cursor based rather than offset based, so rows are never skipped or repeated when content changes mid pagination. The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. **The first page** (returns 200) ```bash curl https://api.writavo.com/v1/articles?limit=2&fields=id,title,status,updated_at \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` Cursor paging, so rows are never skipped or repeated when content changes mid pagination. **The next page** (returns 200) ```bash curl https://api.writavo.com/v1/articles?limit=2&fields=id,title,status,updated_at&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMDgtMDZUMTA6MDA6MDBaIl19 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` Pass back `data.next_cursor` verbatim. Do not parse it or construct one. > There is no total count, on purpose. Counting a growing table on every list request is the kind of query that gets slower exactly as a customer succeeds. Page until `next_cursor` is `null`. Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. ## Incremental sync To keep a copy of your content in step without re-reading everything, store the greatest `updated_at` you have seen and pass it back as `updated_since`. Combine it with the default ordering, `updated_at.desc`, and page until the cursor runs out. pattern: ``` # first run GET /articles?limit=100&order=updated_at.asc # every run after that GET /articles?limit=100&order=updated_at.asc&updated_since=2026-08-06T09:41:12Z ``` Webhooks are the better answer when you want to react quickly. Polling is the right answer when you want to be sure you have everything, including anything a delivery failure lost. Most integrations do both. ## Sparse fieldsets List endpoints omit article bodies by default. A list endpoint that returns every body is the classic way to make a content API slow and expensive, so `content` is something you ask for one article at a time, or explicitly with a small `limit`. > `fields` is an allow list, not a wildcard. There is no way to ask for every field, and `id` is always returned whether or not you name it. ## Idempotency-Key A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. > Two responses deliberately do **not** replay: the secret from a key creation, and the presigned URL from an upload reservation. Both are live credentials, and persisting one so that a retry could see it again would put it in every database backup taken during the replay window. A replay returns the same object id with the credential field `null` and a `*_replayable: false` flag, so you can tell the difference between a redacted replay and a missing field. ## If-Match and lost updates The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. **A safe update with If-Match** (returns 200) ```bash curl -X PATCH https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -H "If-Match: W/"1767225600000"" \ -d '{ "excerpt": "A practical framework for picking a headless CMS." }' ``` Send the ETag from your last read. If someone else changed the article you get 412 instead of silently overwriting their work. - Read the object, keep the `ETag`. - Send it back as `If-Match` on your update. - On `412 PRECONDITION_FAILED`, re-read, merge, and retry with the new `ETag`. Nothing was written. The tag is weak, and deliberately: it versions the row rather than the bytes, so a `fields` projection legitimately returns a different body for the same version. --- # Every code, and what to do about it Branch on `error.code`. It is stable within `/v1`; the message is not, and may be reworded at any time. ## The shape of a failure Every failed response has this shape. 422: ```json { "ok": false, "error": { "code": "VALIDATION_FAILED", "message": "Add a title before publishing.", "fields": { "title": "Add a title before publishing." }, "request_id": "0f2a8c31-0000-4000-8000-00000000abcd" } } ``` - `code` is what you switch on. Treat an unknown code as a generic failure of its HTTP status class rather than crashing: new codes may be added within `/v1`. - `fields` is present on `VALIDATION_FAILED` and maps each offending field to a message fit to put next to an input. - `request_id` identifies the exact request in our logs. Quote it when you contact support. - A message never contains a stack trace, SQL, an internal identifier, or anything belonging to another customer. > Every code below has its own anchor, so an error handler can link a person straight to the paragraph that explains what they hit: `https://writavo.com/docs/errors#NOT_ENTITLED`. ## The three different 402s These are the ones worth reading before you hit them, because they look identical in a log and have three different fixes. They are 402 rather than 403 deliberately: a 403 says *you may not*, and each of these says *not yet, and here is how to change that*. They are checked in that order, so a caller who is not on the right plan never learns anything about the balance. **Check before you spend** (returns 200) ```bash curl https://api.writavo.com/v1/usage \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` Read this before a pipeline run if you would rather fail fast than handle a 402. ## The full catalog | Code | HTTP | Meaning | What to do | |---|---|---|---| | INVALID_REQUEST | 400 | Malformed JSON, a bad parameter, or a missing required header. | Fix the request before retrying. Common causes: a body that is not JSON, an unknown value in a query parameter, a cursor you constructed rather than passed back, or a missing `Idempotency-Key` on a creating POST. | | INVALID_API_KEY | 401 | No key, an unparseable key, or one that does not exist. | Check the key is present, complete, and sent as `Authorization: Bearer `. This response never distinguishes a missing key from a wrong one, so you cannot tell from it which mistake you made. | | API_KEY_REVOKED | 401 | The key was revoked or rotated past its grace window. | Someone revoked or rotated this key. Create a new one in the dashboard and update your integration. Nothing you can send will make this key work again. | | API_KEY_EXPIRED | 401 | The key passed its `expires_at`. | The key passed its `expires_at`. Create a replacement. If you did not expect an expiry, check whether the key was created with one. | | INSUFFICIENT_SCOPE | 403 | The key is valid but lacks the scope this operation needs. The only 403 in the API. | Either the key lacks the scope, or its creator's permissions no longer cover it. Check `GET /keys` for the scopes actually granted, then check the creator still has the matching dashboard permission. This is the only 403 in the API, and it never means the object belongs to someone else. | | NOT_ENTITLED | 402 | Your plan does not include this AI pipeline capability. Upgrade. CMS capabilities are on every plan and never return this. | Your plan does not include this capability. Upgrading is the only fix; topping up credits will not help. This is answered before the credit check, so it tells you nothing about your balance. | | INSUFFICIENT_CREDITS | 402 | The organisation cannot afford the next unit of work. Top up. | The organisation cannot afford the next unit of work. Top up. The balance is shared by every Site under the account, so another Site may have spent it. | | SPEND_CAP_REACHED | 402 | This Site hit its own monthly ceiling. Raise it or wait. | This Site hit the monthly ceiling you configured for it, not a platform limit. Raise the cap in the dashboard or wait for the period to reset. `GET /usage` shows the cap, what has been spent against it, and whether it is reached. | | PAYMENT_METHOD_REQUIRED | 402 | This would go past your included CMS allowance and there is no payment method on file. Not a plan limit and not an upgrade: CMS resources are pay-as-you-go on every plan. Add a card and retry. Nothing already published stops serving. | You have used everything included with your account for this resource, and there is no payment method on file to bill the rest to. Add a card and retry; the same request will then succeed. This is not a plan limit and upgrading will not fix it: storage, documents, seats, Sites and domains are pay-as-you-go on every plan including Free, so the fix is a card rather than a tier. Nothing you have already published stops serving while this is outstanding, and `GET /usage` shows which resource ran out and what the next unit costs. | | NOT_FOUND | 404 | No such object, or it belongs to another Site. Deliberately indistinguishable. | Either there is no such object, or it belongs to a different Site. If you are certain the id is right, check you are using the key for the correct Site. | | SLUG_CONFLICT | 409 | Another object on this Site already uses that slug. | Another object on this Site already uses that slug. Pick a different one, or let the API derive one by omitting `slug`. | | IDEMPOTENCY_KEY_CONFLICT | 409 | The key was reused with a different request body. | You reused a key with a different body. That is a bug in the client rather than a retry: generate one key per logical operation and reuse it only to repeat that same operation. | | IDEMPOTENCY_KEY_IN_FLIGHT | 409 | The first request with this key is still running. Retry shortly. | The first request with this key is still running. Wait a moment and retry with the same key; you will get the original response replayed. | | CONFLICT | 409 | The object changed under you mid request. Re-read and retry. | The object changed under you mid request. Re-read it and retry. | | PRECONDITION_FAILED | 412 | Your `If-Match` did not match. Someone else edited it. Re-read and retry. | Your `If-Match` did not match, so someone edited the object since you read it. Nothing was written. Re-read, merge your change on top, and retry with the new `ETag`. | | VALIDATION_FAILED | 422 | The request parsed but the values are not acceptable. See `fields`. | Read `error.fields`. It maps each offending field to a message you can show next to the input. The three most common causes are publishing without a title, slug or content; scheduling in the past; and sending `status`, which is read only. | | RATE_LIMIT_EXCEEDED | 429 | Too many requests. Back off and honour `Retry-After`. | Back off and honour `Retry-After`. Watch `RateLimit-Remaining` on successful responses so you can slow down before you are refused rather than after. | | MAINTENANCE | 503 | Writes are paused for maintenance. Retry later. | Writes are paused. Reads usually keep working and your published blog is served from cache, so your site stays up. Retry after the window in `Retry-After`. | | INTERNAL_ERROR | 500 | Our fault. Safe to retry an idempotent request. | Ours, not yours. Safe to retry, and safer with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. Quote the `request_id` if you contact support. | ## Why a wrong Site returns 404 `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. **An id that is not yours** (returns 404) ```bash curl https://api.writavo.com/v1/articles/00000000-0000-4000-8000-000000000000 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` An object belonging to another Site answers exactly like an object that does not exist. That is what stops anyone enumerating other customers' content by id. --- # Limits are per key, per minute, by class Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. ## The classes | Class | Applies to | Limit | |---|---|---| | read | every GET | 600 per minute | | write | POST, PATCH and DELETE on content, taxonomy, keys and webhooks | 120 per minute | | upload | `POST /media/upload-url` | 60 per minute | | pipeline | `POST /pipeline/runs` | 10 per minute | Every operation in the [reference](/docs/api) states its class, so you can work out a budget before you write the loop rather than after it is refused. ## The headers Every response carries the state of your window, so you can slow down before you are refused rather than after. on every response: ``` RateLimit-Limit: 600 RateLimit-Remaining: 587 RateLimit-Reset: 1786000860 ``` - `RateLimit-Limit` is what your key is permitted in the current window for this endpoint class. - `RateLimit-Remaining` is what is left in it. - `RateLimit-Reset` is the Unix second at which the window resets. - `Retry-After`, in seconds, is added to every 429. Honour it. ## When you are limited `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. | Class | Endpoints | Limit | |---|---|---| | read | every GET | 600 per minute | | write | POST, PATCH and DELETE on content, taxonomy, keys and webhooks | 120 per minute | | upload | `POST /media/upload-url` | 60 per minute | | pipeline | `POST /pipeline/runs` | 10 per minute | Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. > The window slides rather than resetting on a fixed boundary. Aligning a burst to the top of a minute buys you nothing, and a steady rate is always better treated than a spike followed by a wait. - **Back off exponentially**, starting from `Retry-After`, and add jitter. A fleet of workers that all retry after exactly the same delay recreates the burst that limited them. - **Batch reads with `limit` and `fields`** rather than fetching one article at a time. One request for 100 projected rows costs one unit; 100 requests cost 100. - **Do not poll `GET /pipeline/runs` in a tight loop.** Subscribe to `pipeline.run.completed` instead. - **A 429 is not a spend gate.** If you are looking for the thing that stops runaway cost, that is the 402 family on the [errors page](/docs/errors#the-three-402s). ## Higher limits Some plans raise the ceiling. It applies automatically to the keys on the Site and needs no change to your code; the first request of a session runs at the standard rate and the elevated rate applies from the next one. If you are hitting a limit with a legitimate workload, that is worth a conversation rather than a retry loop. Contact [support](https://writavo.com/support). --- # Uploading an image Three requests. Reserve an upload, PUT the bytes straight to storage, then register the asset. The bytes never pass through the API. ## Why three steps A presigned upload keeps image bytes off the API path, so a large file does not occupy a request slot and a slow connection does not hold one open. It also means you never name a storage location. The server chooses one inside your Site's namespace, and there is no request field anywhere that could point an upload somewhere else. 1. **Reserve** `POST /media/upload-url` with the filename and the type you believe you are uploading. You get a short lived presigned URL and an `upload_id`. 2. **Upload** `PUT` the raw bytes to that URL. Send no authorization header: the signature inside the URL is the credential. 3. **Register** `POST /media` with the `upload_id`. Only now is the file part of your library. ## The flow **1. Reserve an upload** (returns 201) ```bash curl -X POST https://api.writavo.com/v1/media/upload-url \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: b6f4b0e2-0000-4000-8000-0000000000aa" \ -d '{ "file_name": "hero.png", "content_type": "image/png", "size_bytes": 68 }' ``` You never name a storage location. The server chooses one inside your Site's namespace. **2. PUT the bytes** (returns 200) ```bash curl -X PUT https://uploads.example.com/presigned/EXAMPLE \ -H "Content-Type: image/png" \ --data-binary @hero.png ``` Send no authorization header. The signature inside the URL is the credential, and it expires. **3. Register the asset** (returns 201) ```bash curl -X POST https://api.writavo.com/v1/media \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: b6f4b0e2-0000-4000-8000-0000000000aa" \ -d '{ "upload_id": "3f1b0c7a-0000-4000-8000-000000000006", "alt_text": "A hero image" }' ``` The server reads the real content type from the stored bytes here. The type you declared in step 1 is a hint and is never trusted. ## What the server checks, and when > The `content_type` you declare in step 1 is a **hint only**. At registration the server reads the stored bytes and takes the real type from them. A file whose actual type is not an allowed image is rejected with `422 VALIDATION_FAILED` and deleted from storage, so a rejected upload leaves nothing behind. ## If you never register An uploaded object with no `media_assets` row is not part of your library. It is swept and the reservation expires. Nothing is charged and nothing is left dangling, so an abandoned upload is safe to abandon. The presigned URL expires on its own too. If your `PUT` is slow or fails, request a new reservation rather than retrying an expired signature. ## Retrying a reservation > The presigned URL is a bearer credential, so it is deliberately **not replayed**. A retry with the same `Idempotency-Key` returns the same `upload_id` with `upload_url: null` and `upload_url_replayable: false`. That is the honest answer: the retry has learnt that the reservation exists, and no live credential has been written to a table that gets backed up. Request a fresh reservation. ## Deleting an asset > Deleting a media asset does **not** rewrite your articles. If a published article embeds the image, or uses it as its `featured_image_url`, that reference becomes a broken image on your live blog. - `GET /articles?fields=id,featured_image_url` finds featured uses. - In body content the URL is plain text, so search your `content` for it. - The catalog row and the stored bytes both go. There is no undo. ## Alt text Worth sending, and the only field you can edit after the fact. It is what screen readers announce and what search engines read, and there is no way to generate it for you. The bytes themselves are immutable: to replace an image, upload a new one and repoint whatever referenced the old one. --- # Be told, instead of asking Register an HTTPS endpoint, subscribe it to events, and receive a signed POST whenever your content changes. A change made in the dashboard fires the same event as a change made through this API. ## Setting one up 1. **Stand up an HTTPS endpoint** It must be publicly resolvable. Private, loopback, link-local and metadata addresses are refused, at save time and again at delivery time, so a webhook cannot be pointed at internal infrastructure. 2. **Register it** `POST /webhooks` with the URL and the events you want. The signing secret is in that response and nowhere else, ever. Record it before you close the connection. 3. **Verify every delivery** Before you parse the body. The recipe is below, and an unverified endpoint will accept a forged POST from anyone who learns the URL. 4. **Answer quickly with a 2xx** You have ten seconds. Queue the work and return; do not do the rebuild inside the request. **Register an endpoint** (returns 201) ```bash curl -X POST https://api.writavo.com/v1/webhooks \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: b6f4b0e2-0000-4000-8000-0000000000aa" \ -d '{ "url": "https://example.com/hooks/writavo", "events": [ "article.published", "article.unpublished", "article.deleted" ], "description": "Rebuild the static site" }' ``` The signing secret is in this response and nowhere else, ever. Record it before you close the connection. > A Site may register at most ten endpoints. ## The event catalog Events you can subscribe to. Every one of them corresponds to a mutation this API can perform, and each fires whether the change came from this API or from the dashboard. More may be added within v1, so ignore an event type you do not recognise rather than failing the delivery. `article.published` is the one most integrations want: it is the signal to rebuild a statically generated site. `article.unpublished` and `article.deleted` are the ones people forget, and forgetting them leaves content live on your site after it is gone from ours. ## The payload The body of every delivery. It is JSON, it is what the signature is computed over, and it is byte-identical across retries and redeliveries of the same event. `data` is the resource in the same shape the REST API returns it, so a handler for `article.published` can use the same parser as `GET /articles/{id}`. The one exception is a `*.deleted` event, where the object no longer exists and `data` carries only its `id`. `website_id` deliberately appears nowhere: you already know which Site you configured. body: ```json { "id": "8b1f0f5a-0000-4000-8000-00000000000a", "event": "article.published", "created_at": "2026-08-06T09:41:12.004Z", "data": { "id": "3f1b0c7a-0000-4000-8000-000000000001", "status": "published", "title": "How to choose a headless CMS", "slug": "how-to-choose-a-headless-cms", "published_at": "2026-08-06T09:41:12.004Z" } } ``` The headers that come with it: headers: ``` Writavo-Signature: v1= Writavo-Timestamp: 1786000872 Writavo-Delivery: Writavo-Attempt: 1 Writavo-Event: article.published Content-Type: application/json User-Agent: writavo-webhook/1 ``` > Deduplicate on the body's `id`, which is the **event** id and is stable across every retry and every manual redelivery. `Writavo-Delivery` is the id of this individual HTTP attempt, which is a different thing: it identifies the request rather than the fact. ## Verifying a delivery The signature is `HMAC-SHA256` over `.`, hex encoded, sent as `v1=`. - Read the raw request body as **bytes, before any JSON parse**. If your framework parses it for you, re-serialising will produce a different string and the signature will not match. - Build the signed material: `` `${Writavo-Timestamp}.${rawBody}` ``. - Compute `hex(HMAC_SHA256(your_signing_secret, signed))`. - Compare `Writavo-Signature` to `v1=` **in constant time**. A plain equality check leaks the correct signature one byte at a time. - Reject anything where `|now - Writavo-Timestamp| > 300` seconds. > The timestamp is inside the signed material, and that is the whole reason it is sent. A captured payload cannot be replayed later, because its signature only ever validates against the timestamp it was signed with. Signing the body alone would make every delivery replayable for ever. ## Rotating the secret `POST /webhooks//rotate-secret` issues a new one and returns it once. The old secret stops working immediately, with no grace window, because a window in which two secrets both validate is a window in which a leaked secret still works. Deliveries sent between the rotation and your receiver being updated will fail verification. They are retried on the normal backoff, so update the receiver promptly and the queue drains itself. ## Retries, backoff and auto-disable Delivery is at least once. A delivery is an attempt, and there are up to six of them per event. - Each delay carries up to 25% jitter, so a fleet of endpoints failing on the same publish does not retry in lockstep. - A response outside 200 to 299 is a failure. So is no response within ten seconds, a redirect to a non-HTTPS or private target, and more than one redirect. - After **12 consecutive failed attempts**, exactly two events that exhausted the full ladder, the endpoint is disabled automatically and the reason is recorded on it. - Any 2xx resets the counter to zero. - Re-enable with `PATCH /webhooks/{id}` and `enabled: true`. **Re-enabling does not replay what you missed.** Reconcile with `GET /articles?updated_since=...`. **Read the delivery log** (returns 200) ```bash curl https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000004/deliveries \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` One row per attempt. Every attempt of one event shares its `event_id`, which is what you deduplicate on. **Send one delivery again** (returns 202) ```bash curl -X POST https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000004/deliveries/3f1b0c7a-0000-4000-8000-000000000005/redeliver \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` Same event id, byte identical body, attempt counter back to 1. Use it after fixing a receiver that was down. ## Writing a handler that survives - **Be idempotent.** At least once means you will see the same event twice. Deduplicate on the payload `id`. - **Tolerate unknown event types and unknown fields.** Both may be added within `/v1`. Ignore what you do not recognise rather than failing the delivery. - **Handle `article.unpublished` and `article.deleted`.** These are the ones people forget, and forgetting them leaves content live on your site after it is gone from ours. - **Do not trust the payload without verifying the signature.** The URL is the only thing an attacker needs to guess. - **Return fast.** Ten seconds is the timeout, and a slow receiver only delays itself: delivery is out of band, so a hung endpoint can never slow down a publish. --- # Your content, in an AI assistant The Model Context Protocol server exposes this API as tools, so an assistant can read and write your content directly. ## Install It speaks stdio, so it runs as a subprocess of the client rather than as a service you host. ## Configure a client > The key in this file is a secret key with real write authority. Scope it to what the assistant actually needs, and prefer a key created for the assistant alone so you can revoke it without affecting anything else. ## What it can do The tools are generated from the same `openapi.yaml` this reference is, so the assistant sees exactly the operations documented here, with the same scopes and the same gates. Nothing is available to an assistant that is not available to your own code. - The Site is resolved from the key, so an assistant cannot reach a Site you did not give it a key for. - `POST /articles` still only creates drafts. An assistant cannot publish anything without an explicit publish call. - Pipeline runs still cost credits and still respect your spend cap. - Anything that publishes, deletes or spends asks you first, and does nothing until you say yes. | Tool | Calls | Scope | Asks first | |---|---|---|---| | verify_api_key | GET | none | | | get_site_info | GET | meta:read | | | get_content_types | GET | meta:read | | | get_usage | GET | meta:read | | | list_articles | GET | articles:read | | | create_article | POST | articles:write | | | get_article | GET | articles:read | | | update_article | PATCH | articles:write | | | delete_article | DELETE | articles:write | yes | | publish_article | POST | articles:write | yes | | unpublish_article | POST | articles:write | | | schedule_article | POST | articles:write | yes | | cancel_article_schedule | POST | articles:write | | | list_categories | GET | taxonomy:read | | | create_category | POST | taxonomy:write | | | get_category | GET | taxonomy:read | | | update_category | PATCH | taxonomy:write | | | delete_category | DELETE | taxonomy:write | yes | | list_tags | GET | taxonomy:read | | | create_tag | POST | taxonomy:write | | | get_tag | GET | taxonomy:read | | | update_tag | PATCH | taxonomy:write | | | delete_tag | DELETE | taxonomy:write | yes | | list_authors | GET | authors:read | | | create_author | POST | authors:write | | | get_author | GET | authors:read | | | update_author | PATCH | authors:write | | | delete_author | DELETE | authors:write | yes | | list_media | GET | media:read | | | get_media | GET | media:read | | | update_media | PATCH | media:write | | | delete_media | DELETE | media:write | yes | | list_pipeline_runs | GET | pipeline:read | | | trigger_pipeline_run | POST | pipeline:run | yes | | get_pipeline_status | GET | pipeline:read | | | get_pipeline_queue | GET | pipeline:read | | | upload_media | | media:write | | | get_api_docs | | none | | ## What it deliberately cannot do Two parts of the API are not reachable from an assistant at all, whatever scopes the key carries. ## What it never writes down - Your key is sent to `api.writavo.com` as a bearer header and nowhere else. There is no telemetry and no third-party host. - The key is masked out of every reply, every log line and every error, including one where the API itself echoed it back. - The server speaks stdio, so it writes nothing to standard output except protocol messages. --- # The API, from a shell Drive the whole API from a shell or a script. ## Install Or run it without installing anything, which is usually the right answer in CI and in a one-off script: Node 20 or newer. ## Authenticate Create a key in the dashboard under Settings, then API keys, and put it in the environment. Keys are not created through this API: making the first one needs a signed in person rather than a credential you do not have yet. - `--key` overrides the environment variable for one invocation. - The key is sent as `Authorization: Bearer` to the published base URL and nowhere else. `WRITAVO_API_BASE_URL` is honoured only for a loopback address, so nothing that can write a shell profile can redirect your key to another host. - `--dry-run` prints the request the command would send, with the key redacted, and sends nothing. ## Finding a command Every command is named after its operation id in the specification. That makes the name unambiguous, and it means anything that has read the specification already knows all of them without being told. ## Using it The response body goes to standard output and everything else goes to standard error, so a redirect produces clean JSON rather than JSON with a rate limit line stuck to the end of it. ## Exit codes - `0` succeeded. - `1` the API returned an error, or the request could not be sent. - `2` usage error: unknown command, missing flag, bad value, or no key. Nothing was sent. The distinction between 1 and 2 is the one a script needs: a 2 will never succeed on retry, and a 1 might. > On an API error the CLI prints the code, the message, the per field messages where there are any, what to do about it, and the request id to quote to support. That remedy text comes from the same source as the [errors page](/docs/errors) and the MCP server, so the three can never give different advice. ## What it will not let you do by accident - `Idempotency-Key` is generated per invocation and never asked of you. A key reused with a different body is a `409`, which is the opposite of the retry safety the header exists to provide. - A required flag that is missing is refused before anything is sent, so a billable command cannot cost you a request to be told what the flag table already knew. - Commands that spend credits, publish to your live site, or delete permanently say so in `--help`, in those words. - A key is never printed. `--dry-run` shows that one would be sent, and shows `Bearer ` where it would go. ## See also - [The MCP server](/docs/mcp) exposes the same API as tools for an AI assistant. - [The OpenAPI specification](https://writavo.com/openapi.json) is what both are generated from, so you can generate your own client the same way. - [llms.txt](https://writavo.com/llms.txt) says when an agent should reach for Writavo and how to call it. --- # What /v1 promises Additive only. Anything that could break a working integration ships as a new version, with this one supported alongside it. ## The promise Within /v1 we may: - add a new endpoint, - add a new optional request field, - add a new response field, - add a new value to a read-only enum, for example a new `article.status` if the pipeline grows a stage, or a new webhook event. We will not, within this version: - remove a field, - remove or narrow an enum value, - rename anything, - make an optional request field required, - change the HTTP status code of an existing outcome. > Any of those would ship as `/v2`, at a different base URL, with `/v1` supported alongside it. ## What your client must tolerate The promise above only holds up if clients are written to accept the additions it allows. Two rules: - **Ignore response fields you do not recognise.** A strict parser that rejects unknown keys will break the first time we add one, and adding one is explicitly permitted. - **Treat every read-only enum as open.** `article.status`, `webhook.event` and `error.code` can all gain values. Fall through gracefully on a value you do not know rather than throwing. An unknown error code should be handled as a generic failure of its HTTP status class. A 402 you have never seen is still a payment problem. ## If something is ever deprecated Nothing in `/v1` is deprecated today. If that changes, it will be announced here with a date, and the behaviour will keep working for the lifetime of this version. A deprecation is a recommendation to move, never a removal. ## Changelog The specification itself is versioned with the API and served at [/openapi.yaml](/openapi.yaml). It currently reads `1.0.0`. --- # API reference Every operation in the Writavo Content API, generated from the published OpenAPI 3.1 specification at https://writavo.com/openapi.json. - **Base URL**: `https://api.writavo.com/v1` - **Version**: 1.0.0 - **Operations**: 50 ## Resources | Resource | Operations | What it covers | |---|---|---| | [Meta](/docs/api/meta) | 4 | Connectivity, Site information, content types, limits and usage. | | [Articles](/docs/api/articles) | 9 | The content spine. Create, edit, organise, publish and schedule. | | [Categories](/docs/api/categories) | 5 | The closed taxonomy. Exactly one category per article. | | [Tags](/docs/api/tags) | 5 | The cross cutting taxonomy. Many tags per article. | | [Authors](/docs/api/authors) | 5 | The byline roster for a Site. | | [Media](/docs/api/media) | 6 | The media library. Two step upload, then registration. | | [Pipeline](/docs/api/pipeline) | 4 | The AI generation engine. The only billable surface in this API. | | [API keys](/docs/api/api-keys) | 4 | Key management. Specified here, implemented in API-2. | | [Webhooks](/docs/api/webhooks) | 8 | Outbound event delivery. Register an HTTPS endpoint, subscribe it to events, and receive a signed POST whenever your content changes, from this API or from the dashboard. | ## Every operation | Method | Path | Operation id | Summary | |---|---|---|---| | GET | `/ping` | `ping` | Verify a key | | GET | `/site` | `getSite` | Read Site information | | GET | `/content-types` | `listContentTypes` | List content types | | GET | `/usage` | `getUsage` | Read plan limits, usage and balances | | GET | `/articles` | `listArticles` | List articles | | POST | `/articles` | `createArticle` | Create an article | | GET | `/articles/{id}` | `getArticle` | Read one article | | PATCH | `/articles/{id}` | `updateArticle` | Update an article | | DELETE | `/articles/{id}` | `deleteArticle` | Delete an article | | POST | `/articles/{id}/publish` | `publishArticle` | Publish an article | | POST | `/articles/{id}/unpublish` | `unpublishArticle` | Unpublish an article | | POST | `/articles/{id}/schedule` | `scheduleArticle` | Schedule an article | | POST | `/articles/{id}/cancel-schedule` | `cancelArticleSchedule` | Cancel a scheduled publish | | GET | `/categories` | `listCategories` | List categories | | POST | `/categories` | `createCategory` | Create a category | | GET | `/categories/{id}` | `getCategory` | Read one category | | PATCH | `/categories/{id}` | `updateCategory` | Update a category | | DELETE | `/categories/{id}` | `deleteCategory` | Delete a category | | GET | `/tags` | `listTags` | List tags | | POST | `/tags` | `createTag` | Create a tag | | GET | `/tags/{id}` | `getTag` | Read one tag | | PATCH | `/tags/{id}` | `updateTag` | Update a tag | | DELETE | `/tags/{id}` | `deleteTag` | Delete a tag | | GET | `/authors` | `listAuthors` | List authors | | POST | `/authors` | `createAuthor` | Create an author | | GET | `/authors/{id}` | `getAuthor` | Read one author | | PATCH | `/authors/{id}` | `updateAuthor` | Update an author | | DELETE | `/authors/{id}` | `deleteAuthor` | Delete an author | | GET | `/media` | `listMedia` | List media assets | | POST | `/media` | `registerMedia` | Register an uploaded file | | POST | `/media/upload-url` | `createMediaUploadUrl` | Request an upload URL | | GET | `/media/{id}` | `getMediaAsset` | Read one media asset | | PATCH | `/media/{id}` | `updateMediaAsset` | Update a media asset | | DELETE | `/media/{id}` | `deleteMediaAsset` | Delete a media asset | | GET | `/pipeline/runs` | `listPipelineRuns` | List pipeline runs | | POST | `/pipeline/runs` | `createPipelineRun` | Request a pipeline run | | GET | `/pipeline/runs/{id}` | `getPipelineRun` | Read one pipeline run | | GET | `/pipeline/queue` | `listPipelineQueue` | Read the content queue | | GET | `/keys` | `listApiKeys` | List API keys | | POST | `/keys` | `createApiKey` | Create an API key | | POST | `/keys/{id}/rotate` | `rotateApiKey` | Rotate an API key | | DELETE | `/keys/{id}` | `revokeApiKey` | Revoke an API key | | GET | `/webhooks` | `listWebhooks` | List webhook endpoints | | POST | `/webhooks` | `createWebhook` | Create a webhook endpoint | | GET | `/webhooks/{id}` | `getWebhook` | Read one webhook endpoint | | PATCH | `/webhooks/{id}` | `updateWebhook` | Update a webhook endpoint | | DELETE | `/webhooks/{id}` | `deleteWebhook` | Delete a webhook endpoint | | POST | `/webhooks/{id}/rotate-secret` | `rotateWebhookSecret` | Rotate the signing secret | | GET | `/webhooks/{id}/deliveries` | `listWebhookDeliveries` | List deliveries for an endpoint | | POST | `/webhooks/{id}/deliveries/{delivery_id}/redeliver` | `redeliverWebhookDelivery` | Send one delivery again | --- # Meta Connectivity, Site information, content types, limits and usage. Base URL: `https://api.writavo.com/v1` ### GET /ping Verify a key - **Operation id**: `ping` - **Scope**: `none` - **Permission**: `none` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes The cheapest possible authenticated call. Returns the kind of key you presented and the scopes it carries. Use it to confirm credentials during setup, and as a liveness probe. It touches no content, so it is exempt from the write rate limit. **Responses** | Status | Data | Description | |---|---|---| | 200 | object | The key is valid. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/ping \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### GET /site Read Site information - **Operation id**: `getSite` - **Scope**: `meta:read` - **Permission**: `none` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes Public facing information about the Site your key belongs to: its display name, the domain its blog is served from, its locale and its timezone. Scheduling times are interpreted against this timezone when no offset is supplied. **Responses** | Status | Data | Description | |---|---|---| | 200 | Site | Site information. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/site \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### GET /content-types List content types - **Operation id**: `listContentTypes` - **Scope**: `meta:read` - **Permission**: `none` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes The article formats available to this Site. A format is an SEO blueprint (How-To, Listicle, Versus and so on) that shapes how the generator structures an article, and that you may set on any article via `format_id`. The list merges the platform defaults with any formats defined for your Site. A Site format with the same `key` as a platform default overrides it, and only the override is returned. Formats are read only in v1: defining your own is a dashboard action. **Responses** | Status | Data | Description | |---|---|---| | 200 | object | The available content types. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/content-types \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### GET /usage Read plan limits, usage and balances - **Operation id**: `getUsage` - **Scope**: `meta:read` - **Permission**: `none` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no What your plan allows, what you have used in the current period, and what you can still spend. Read this before a pipeline run if you want to fail fast rather than handle a 402, and read it after a run to see the balance move. `credits.balance` is the organisation wide credit balance shared by every Site under the account. `spend_cap` is this Site's own monthly ceiling and is independent of it: a Site can be capped while the organisation still has credits, which is the point of a cap. **Responses** | Status | Data | Description | |---|---|---| | 200 | Usage | Limits, usage and balances. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/usage \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # Articles The content spine. Create, edit, organise, publish and schedule. Base URL: `https://api.writavo.com/v1` ### GET /articles List articles - **Operation id**: `listArticles` - **Scope**: `articles:read` - **Permission**: `articles.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes Cursor paginated, newest updated first. The default projection deliberately omits `content`. Article bodies are large, and a list endpoint that returns every body is the classic way to make a content API slow and expensive. Fetch bodies one at a time with `GET /articles/{id}`, or ask for them explicitly with `fields=id,title,content` and a small `limit`. There is no way to ask for every field. `fields` is an allow list, not a wildcard. A publishable key (`wv_pub_`) sees only articles at `status: published`. A secret key sees everything, including drafts. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `fields` | query | string | no | Comma separated field allow list. Any field of the Article schema may be named. Omit for the default projection, which is: `id, status, title, slug, excerpt, featured_image_url, category_id, author_id, format_id, published_at, scheduled_publish_at, created_at, updated_at`. `id` is always returned whether or not you name it. | | `status` | query | ArticleStatus[] | no | Filter by status. Repeat the parameter to match several. A publishable key may only ask for `published`, and any other value is rejected with `403 INSUFFICIENT_SCOPE`. | | `category_id` | query | uuid | no | | | `author_id` | query | uuid | no | | | `tag_id` | query | uuid | no | Return only articles carrying this tag. | | `slug` | query | string | no | Exact slug match. Slugs are unique within a Site, so this returns at most one article. | | `updated_since` | query | date-time | no | Return only articles updated at or after this instant. This is the incremental sync parameter: store the greatest `updated_at` you have seen and pass it back next time. | | `order` | query | string | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of articles. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/articles \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /articles Create an article - **Operation id**: `createArticle` - **Scope**: `articles:write` - **Permission**: `articles.write` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Creates an article at `status: draft`. Always. There is no request field that can make it public, and supplying `status` is a validation error rather than a silent ignore, so a client written against a different CMS fails loudly instead of quietly leaving content unpublished. Nothing here is required. An empty body creates an untitled, unslugged draft you can fill in later. `title`, `slug` and `content` do become required at publish time, and `POST /articles/{id}/publish` returns `422` with a field level breakdown if any is missing. If you supply a `title` and no `slug`, a slug is derived from the title. Supply `slug` explicitly if the URL matters to you, because a derived slug is not guaranteed stable across versions. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** | Field | Type | Required | Description | |---|---|---|---| | `title` | string | null | no | | | `slug` | string | null | no | Derived from `title` when omitted. Supply it if the URL matters. | | `content` | string | null | no | Markdown. | | `excerpt` | string | null | no | | | `featured_image_url` | uri | null | no | | | `seo_title` | string | null | no | | | `seo_description` | string | null | no | | | `seo_keywords` | array | null | no | | | `faqs` | array | null | no | | | `key_takeaways` | array | null | no | | | `howto_steps` | array | null | no | | | `comparison` | object | null | no | | | `category_id` | uuid | null | no | | | `author_id` | uuid | null | no | | | `format_id` | uuid | null | no | | | `tag_ids` | uuid[] | no | | **Responses** | Status | Data | Description | |---|---|---| | 201 | Article | The draft was created. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/articles \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "title": "How to choose a headless CMS", "slug": "how-to-choose-a-headless-cms", "content": "## Start with your delivery model\n\nThe first question is not which CMS...", "excerpt": "A practical framework for picking a headless CMS without regretting it.", "seo_title": "How to choose a headless CMS (2026 guide)", "seo_description": "A practical framework for picking a headless CMS.", "seo_keywords": [ "headless cms", "content api", "jamstack" ], "category_id": "0f5f1f4e-9c2a-4f7b-9a11-3b5c9d8e7a01", "author_id": "6a1c8b22-0d4e-4a9f-8c33-77e2f1a4b5c6", "tag_ids": [ "9d3e2c11-5b6a-4d8e-9f01-2a3b4c5d6e7f" ] }' ``` ### GET /articles/{id} Read one article - **Operation id**: `getArticle` - **Scope**: `articles:read` - **Permission**: `articles.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes Returns the full article including `content`. A publishable key may only read an article at `status: published`; anything else returns 404, for the same no disclosure reason that governs cross Site access. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `fields` | query | string | no | Comma separated field allow list. Omit to receive every readable field. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Article | The article. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### PATCH /articles/{id} Update an article - **Operation id**: `updateArticle` - **Scope**: `articles:write` - **Permission**: `articles.write` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no A partial update. Only the fields you send are touched. Send `null` to clear a nullable field; omit it to leave it alone. `status` is not updatable here. Use the lifecycle endpoints. Sending `status` returns `422 VALIDATION_FAILED`, which is what stops an API client from pushing an article into the generation engine. You may edit a published article. The edit goes live on your blog as soon as the CDN cache for that post is purged, which happens as part of this request. **Send `If-Match`.** Pass the `ETag` you received from your last read. If someone else changed the article since then you get `412 PRECONDITION_FAILED` instead of silently overwriting their work. `If-Match` is optional in v1 for compatibility, and omitting it means last write wins, which is almost never what you want on shared content. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `If-Match` | header | string | no | The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `title` | string | null | no | | | `slug` | string | null | no | Changing the slug of a published article changes its live URL and nothing is redirected for you. The old URL starts returning 404. | | `content` | string | null | no | Markdown. | | `excerpt` | string | null | no | | | `featured_image_url` | uri | null | no | | | `seo_title` | string | null | no | | | `seo_description` | string | null | no | | | `seo_keywords` | array | null | no | | | `faqs` | array | null | no | | | `key_takeaways` | array | null | no | | | `howto_steps` | array | null | no | | | `comparison` | object | null | no | | | `category_id` | uuid | null | no | | | `author_id` | uuid | null | no | | | `format_id` | uuid | null | no | | | `tag_ids` | uuid[] | no | Full replacement, not a merge. Send `[]` to clear. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Article | The updated article. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 412 | - | `PRECONDITION_FAILED`. Your `If-Match` did not match the current version, meaning somebody edited the object since you read it. Nothing was written. Re-read, merge, and retry with the new `ETag`. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X PATCH https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H 'If-Match: W/"1767225600000"' \ -H "Content-Type: application/json" \ -d '{}' ``` ### DELETE /articles/{id} Delete an article - **Operation id**: `deleteArticle` - **Scope**: `articles:write` - **Permission**: `articles.write` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Permanent. The row and its tag assignments are removed, and if the article was published its URL starts returning 404 on your blog once the cache is purged. There is no trash and no undo in v1. If you only want to take a post off the web, use `POST /articles/{id}/unpublish`, which keeps everything and is reversible. Deleting an article that does not exist returns 404 rather than succeeding, so a double delete is visible to you rather than silent. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `If-Match` | header | string | no | The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. | **Responses** | Status | Data | Description | |---|---|---| | 204 | - | Deleted. No body. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 412 | - | `PRECONDITION_FAILED`. Your `If-Match` did not match the current version, meaning somebody edited the object since you read it. Nothing was written. Re-read, merge, and retry with the new `ETag`. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X DELETE https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H 'If-Match: W/"1767225600000"' ``` ### POST /articles/{id}/publish Publish an article - **Operation id**: `publishArticle` - **Scope**: `articles:write` - **Permission**: `articles.write` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Makes the article public immediately, at `status: published`. Requires a non empty `title`, `slug` and `content`. If any is missing you get `422 VALIDATION_FAILED` with a `fields` map naming each one, so you can point a user at the exact problem rather than showing a generic failure. `published_at` is set to now only if it was not already set. It records when the article was **first** made public and is the ordering key for your blog, so republishing after an unpublish does not move the post to the top of the feed. Publishing clears any pending schedule. This is free. It makes no external call, so it passes no entitlement check, spends no credits and is unaffected by your spend cap. An editor who is not allowed to run the AI pipeline can still publish their own writing. Safe to repeat: publishing an already published article is a no-op that returns the current state. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | ArticleLifecycleState | The article is public. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/publish \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /articles/{id}/unpublish Unpublish an article - **Operation id**: `unpublishArticle` - **Scope**: `articles:write` - **Permission**: `articles.write` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Takes the article off the web and returns it to `status: draft`. The URL starts returning 404 on your blog once the cache is purged. `published_at` is deliberately left intact. It is the original publication date and your blog's ordering key, so a post that goes back up keeps its place in the archive. Nothing is deleted and the operation is fully reversible with `POST /articles/{id}/publish`. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | ArticleLifecycleState | The article is no longer public. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/unpublish \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /articles/{id}/schedule Schedule an article - **Operation id**: `scheduleArticle` - **Scope**: `articles:write` - **Permission**: `articles.write` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Moves the article to `status: scheduled` and records when it should go live. A cron publishes it within a few minutes of that time, whether or not the AI pipeline is switched on for your Site. `scheduled_publish_at` must be in the future. A past or present timestamp is rejected with `422 VALIDATION_FAILED`, because silently publishing immediately is the wrong answer to a clock skew bug. The same `title`, `slug` and `content` requirements as publishing apply, and are checked now rather than at the scheduled moment, so a scheduled post cannot fail silently at two in the morning. Rescheduling is just another call to this endpoint. Calling it on an already scheduled article replaces the time. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `scheduled_publish_at` | date-time | yes | ISO 8601. Include an offset. If you omit one it is read in the Site's timezone, which you can get from `GET /site`. | **Responses** | Status | Data | Description | |---|---|---| | 200 | ArticleLifecycleState | The article is scheduled. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/schedule \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Content-Type: application/json" \ -d '{ "scheduled_publish_at": "2026-09-01T09:00:00Z" }' ``` ### POST /articles/{id}/cancel-schedule Cancel a scheduled publish - **Operation id**: `cancelArticleSchedule` - **Scope**: `articles:write` - **Permission**: `articles.write` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Returns the article to `status: draft` and clears `scheduled_publish_at`. The content is untouched. Calling this on an article that is not scheduled is a no-op that returns the current state. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | ArticleLifecycleState | The schedule was cancelled. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/cancel-schedule \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # Categories The closed taxonomy. Exactly one category per article. Base URL: `https://api.writavo.com/v1` ### GET /categories List categories - **Operation id**: `listCategories` - **Scope**: `taxonomy:read` - **Permission**: `taxonomy.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes Every category on the Site, alphabetically. Categories are a closed taxonomy: an article has exactly one, or none. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `fields` | query | string | no | Comma separated field allow list. Default projection: `id, name, slug, article_count`. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of categories. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/categories \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /categories Create a category - **Operation id**: `createCategory` - **Scope**: `taxonomy:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no `slug` is unique within the Site. A collision returns `409 SLUG_CONFLICT` rather than silently appending a suffix, so your URLs are never a surprise. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `slug` | string | no | Derived from `name` when omitted. | **Responses** | Status | Data | Description | |---|---|---| | 201 | Category | The category was created. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/categories \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "" }' ``` ### GET /categories/{id} Read one category - **Operation id**: `getCategory` - **Scope**: `taxonomy:read` - **Permission**: `taxonomy.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Category | The category. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/categories/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### PATCH /categories/{id} Update a category - **Operation id**: `updateCategory` - **Scope**: `taxonomy:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Renaming is safe. Changing `slug` changes the category archive URL on your blog, and nothing is redirected for you, so change it only if you accept the broken link. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `If-Match` | header | string | no | The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | no | | | `slug` | string | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Category | The updated category. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 412 | - | `PRECONDITION_FAILED`. Your `If-Match` did not match the current version, meaning somebody edited the object since you read it. Nothing was written. Re-read, merge, and retry with the new `ETag`. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X PATCH https://api.writavo.com/v1/categories/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H 'If-Match: W/"1767225600000"' \ -H "Content-Type: application/json" \ -d '{}' ``` ### DELETE /categories/{id} Delete a category - **Operation id**: `deleteCategory` - **Scope**: `taxonomy:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Articles in this category are **not** deleted. Their `category_id` becomes `null`, so they stay published and simply lose their category. Removing a category never removes content. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 204 | - | Deleted. No body. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X DELETE https://api.writavo.com/v1/categories/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # Tags The cross cutting taxonomy. Many tags per article. Base URL: `https://api.writavo.com/v1` ### GET /tags List tags - **Operation id**: `listTags` - **Scope**: `taxonomy:read` - **Permission**: `taxonomy.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes Every tag on the Site, alphabetically. Tags are cross cutting: an article may carry many. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `fields` | query | string | no | Comma separated field allow list. Default projection: `id, name, slug, article_count`. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of tags. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/tags \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /tags Create a tag - **Operation id**: `createTag` - **Scope**: `taxonomy:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no `slug` is unique within the Site. A collision returns `409 SLUG_CONFLICT`. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `slug` | string | no | Derived from `name` when omitted. | **Responses** | Status | Data | Description | |---|---|---| | 201 | Tag | The tag was created. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/tags \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "" }' ``` ### GET /tags/{id} Read one tag - **Operation id**: `getTag` - **Scope**: `taxonomy:read` - **Permission**: `taxonomy.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Tag | The tag. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/tags/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### PATCH /tags/{id} Update a tag - **Operation id**: `updateTag` - **Scope**: `taxonomy:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `If-Match` | header | string | no | The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | no | | | `slug` | string | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Tag | The updated tag. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 412 | - | `PRECONDITION_FAILED`. Your `If-Match` did not match the current version, meaning somebody edited the object since you read it. Nothing was written. Re-read, merge, and retry with the new `ETag`. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X PATCH https://api.writavo.com/v1/tags/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H 'If-Match: W/"1767225600000"' \ -H "Content-Type: application/json" \ -d '{}' ``` ### DELETE /tags/{id} Delete a tag - **Operation id**: `deleteTag` - **Scope**: `taxonomy:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no The tag is removed from every article that carried it. No article is deleted. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 204 | - | Deleted. No body. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X DELETE https://api.writavo.com/v1/tags/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # Authors The byline roster for a Site. Base URL: `https://api.writavo.com/v1` ### GET /authors List authors - **Operation id**: `listAuthors` - **Scope**: `authors:read` - **Permission**: `taxonomy.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes The byline roster for the Site. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `fields` | query | string | no | Comma separated field allow list. Default projection: `id, name, bio, avatar_url, is_ai_generated, is_default, created_at`. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of authors. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/authors \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /authors Create an author - **Operation id**: `createAuthor` - **Scope**: `authors:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no `is_ai_generated` marks a persona rather than a real person. It defaults to `true` because that is what the generation pipeline creates. Set it to `false` for a human byline, and be accurate about it: it is what your disclosure copy keys off. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `bio` | string | null | no | | | `avatar_url` | uri | null | no | | | `is_ai_generated` | boolean | no | | | `is_default` | boolean | no | | **Responses** | Status | Data | Description | |---|---|---| | 201 | Author | The author was created. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/authors \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "" }' ``` ### GET /authors/{id} Read one author - **Operation id**: `getAuthor` - **Scope**: `authors:read` - **Permission**: `taxonomy.read` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: yes **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Author | The author. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/authors/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### PATCH /authors/{id} Update an author - **Operation id**: `updateAuthor` - **Scope**: `authors:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Setting `is_default: true` clears the flag on whichever author held it, because a Site has at most one default byline. Setting it to `false` on the current default leaves the Site with none. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `If-Match` | header | string | no | The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | no | | | `bio` | string | null | no | | | `avatar_url` | uri | null | no | | | `is_ai_generated` | boolean | no | | | `is_default` | boolean | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Author | The updated author. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 412 | - | `PRECONDITION_FAILED`. Your `If-Match` did not match the current version, meaning somebody edited the object since you read it. Nothing was written. Re-read, merge, and retry with the new `ETag`. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X PATCH https://api.writavo.com/v1/authors/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H 'If-Match: W/"1767225600000"' \ -H "Content-Type: application/json" \ -d '{}' ``` ### DELETE /authors/{id} Delete an author - **Operation id**: `deleteAuthor` - **Scope**: `authors:write` - **Permission**: `taxonomy.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Articles by this author are **not** deleted. Their `author_id` becomes `null`, so they stay published and lose their byline. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 204 | - | Deleted. No body. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X DELETE https://api.writavo.com/v1/authors/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # Media The media library. Two step upload, then registration. Base URL: `https://api.writavo.com/v1` ### GET /media List media assets - **Operation id**: `listMedia` - **Scope**: `media:read` - **Permission**: `media.manage` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no The media library, newest first. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `bucket` | query | MediaBucket | no | | | `fields` | query | string | no | Comma separated field allow list. Default projection: `id, bucket, url, file_name, mime_type, size_bytes, width, height, alt_text, created_at`. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of media assets. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/media \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /media Register an uploaded file - **Operation id**: `registerMedia` - **Scope**: `media:write` - **Permission**: `media.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Step three of the upload. Call this after your `PUT` to the presigned URL succeeds. You send the `upload_id` you were given, not a path. The server already knows where the file went, which is what stops a client naming an arbitrary storage location. The server inspects the stored bytes here: it reads the real content type from the file itself and checks the size and image dimensions. A file whose actual type is not an allowed image is rejected with `422 VALIDATION_FAILED` and deleted from storage, so a rejected upload leaves nothing behind. The declared type you sent to `/media/upload-url` is treated as a hint only and never trusted. If you never call this, the uploaded object is swept and the reservation expires. A file with no `media_assets` row is not part of your library. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `upload_id` | uuid | yes | From `POST /media/upload-url`. | | `alt_text` | string | null | no | Accessibility text. Worth sending. It is what screen readers announce and what search engines read, and there is no way to generate it for you. | **Responses** | Status | Data | Description | |---|---|---| | 201 | MediaAsset | The asset is registered and in your library. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/media \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "upload_id": "3f1b0c7a-0000-4000-8000-000000000001" }' ``` ### POST /media/upload-url Request an upload URL - **Operation id**: `createMediaUploadUrl` - **Scope**: `media:write` - **Permission**: `media.manage` - **Rate limit class**: upload - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Step one of the upload. Returns a short lived presigned URL and an `upload_id`. The upload is a three step handshake: 1. `POST /media/upload-url` with the filename and declared content type. 2. `PUT` the raw bytes to `upload_url`. Send no authorization header; the signature in the URL is the credential. 3. `POST /media` with the `upload_id` to register the asset. The storage location is chosen by the server and namespaced to your Site. You cannot influence it, and no other Site's key can produce a URL that writes into your namespace. The URL expires. If your `PUT` is slow or fails, request a new one; do not retry an expired signature. `upload_url` is a bearer credential, so it is **not replayed**: a retry with the same `Idempotency-Key` returns the same `upload_id` with `upload_url: null` and `upload_url_replayable: false`. Request a fresh reservation instead. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `file_name` | string | yes | The original filename. It is sanitised before use and never becomes a path on its own, so traversal sequences are stripped rather than rejected. | | `content_type` | string | yes | The MIME type you believe you are uploading. A hint only. The real type is read from the bytes at registration and it is that check, not this field, that decides whether the file is accepted. | | `size_bytes` | integer | no | Declared size, so an over limit upload can be refused before the bytes move. Also verified after the fact. | | `bucket` | MediaBucket | no | Which library the asset belongs to. `blog-images` for article imagery, `author-avatars` for byline portraits. | **Responses** | Status | Data | Description | |---|---|---| | 201 | object | The upload was reserved. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/media/upload-url \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "file_name": "hero.webp", "content_type": "image/webp" }' ``` ### GET /media/{id} Read one media asset - **Operation id**: `getMediaAsset` - **Scope**: `media:read` - **Permission**: `media.manage` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | MediaAsset | The media asset. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/media/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### PATCH /media/{id} Update a media asset - **Operation id**: `updateMediaAsset` - **Scope**: `media:write` - **Permission**: `media.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Only `alt_text` is editable. The bytes are immutable: to replace an image, upload a new one and repoint whatever referenced the old one. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `If-Match` | header | string | no | The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `alt_text` | string | null | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | MediaAsset | The updated media asset. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 412 | - | `PRECONDITION_FAILED`. Your `If-Match` did not match the current version, meaning somebody edited the object since you read it. Nothing was written. Re-read, merge, and retry with the new `ETag`. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X PATCH https://api.writavo.com/v1/media/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H 'If-Match: W/"1767225600000"' \ -H "Content-Type: application/json" \ -d '{}' ``` ### DELETE /media/{id} Delete a media asset - **Operation id**: `deleteMediaAsset` - **Scope**: `media:write` - **Permission**: `media.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Removes the catalog row and the stored bytes. **Articles are not rewritten.** If a published article embeds this image, or uses it as its `featured_image_url`, that reference becomes a broken image on your live blog. Check before you delete: `GET /articles?fields=id,featured_image_url` finds featured uses, and in body content the URL is plain text you can search for. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 204 | - | Deleted. No body. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X DELETE https://api.writavo.com/v1/media/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # Pipeline The AI generation engine. The only billable surface in this API. Base URL: `https://api.writavo.com/v1` ### GET /pipeline/runs List pipeline runs - **Operation id**: `listPipelineRuns` - **Scope**: `pipeline:read` - **Permission**: `pipeline.run` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Recent engine activity for the Site, newest first. One row per stage invocation, so a single logical run appears as several rows as work moves through the stages. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `status` | query | PipelineRunStatus | no | | | `stage` | query | PipelineStage | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of pipeline runs. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/pipeline/runs \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /pipeline/runs Request a pipeline run - **Operation id**: `createPipelineRun` - **Scope**: `pipeline:run` - **Permission**: `pipeline.run` - **Rate limit class**: pipeline - **Plan feature**: `ai.article_generation` - **Spends credits**: yes - **Publishable key may call it**: no **This is the only billable operation in this API.** It asks the engine to work on your Site at the next opportunity, rather than waiting for the normal cadence. It returns `202` immediately. It does not wait for articles to be written, and it does not tell you how many will be: what the engine does depends on what is in your queue and how far the credits go. Four gates, checked in this order, each with its own error code: | Gate | Failure | Meaning | |---|---|---| | Scope | `403 INSUFFICIENT_SCOPE` | This key does not carry `pipeline:run`. | | Entitlement | `402 NOT_ENTITLED` | Your plan does not include AI generation. Upgrade. | | Credits | `402 INSUFFICIENT_CREDITS` | The organisation cannot afford the next unit of work. Top up. | | Spend cap | `402 SPEND_CAP_REACHED` | This Site hit its own monthly ceiling. Raise it or wait for the reset. | The order matters: an entitlement failure is answered before a credits failure, so a caller who is not on the right plan never learns anything about the balance. Charging happens per unit of work, after that work succeeds, not up front. A run that exhausts the balance part way through stops at the next stage boundary and finishes as `partial`. Nothing is rolled back and no article is left half written. Poll `GET /pipeline/runs/{id}` for the outcome, or subscribe to `pipeline.run.completed` once webhooks are available. `Idempotency-Key` is required. Two identical requests within the retention window produce one run, which is what stops a retried network timeout from spending twice. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** | Field | Type | Required | Description | |---|---|---|---| | `max_articles` | integer | no | An upper bound on how many articles this run may produce. Your own safety valve on top of the platform spend cap. Omit to use the Site's configured batch size. | **Responses** | Status | Data | Description | |---|---|---| | 202 | object | The run was accepted and queued. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 402 | - | `NOT_ENTITLED`, `INSUFFICIENT_CREDITS` or `SPEND_CAP_REACHED`. The request was understood and you were permitted to make it, but it cannot be paid for. Three distinct codes because the fixes differ: upgrade the plan, top up credits, or raise the Site's cap. These are 402 rather than 403 on purpose. A 403 says "you may not"; these all say "not yet, and here is how to change that". | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/pipeline/runs \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{}' ``` ### GET /pipeline/runs/{id} Read one pipeline run - **Operation id**: `getPipelineRun` - **Scope**: `pipeline:read` - **Permission**: `pipeline.run` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no The outcome of a run. `status: partial` with an `error_summary` is what you see when a run stopped early, whether because credits ran out, the spend cap was reached, or a vendor call failed. `items_succeeded` tells you what you did get. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | PipelineRun | The pipeline run. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/pipeline/runs/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### GET /pipeline/queue Read the content queue - **Operation id**: `listPipelineQueue` - **Scope**: `pipeline:read` - **Permission**: `pipeline.run` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no What the engine plans to write, highest priority first. Each item is a topic or keyword with a source: `manual` if a person added it, `content_gap` if gap analysis found it, `competitor_seed` if it came from a competitor page. Read only in v1. Adding topics is a dashboard action. `signal` carries the structured research behind a `competitor_seed` item: the angle, the hook, the data points and the gaps. It is extracted signal, never copied prose, which is the firewall that keeps generated output original. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `status` | query | PlanItemStatus | no | | | `source` | query | PlanItemSource | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of queue items. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/pipeline/queue \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # API keys Key management. Specified here, implemented in API-2. Base URL: `https://api.writavo.com/v1` ### GET /keys List API keys - **Operation id**: `listApiKeys` - **Scope**: `keys:read` - **Permission**: `api_keys.manage` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Metadata only. The secret itself is shown once, at creation, and is never retrievable afterwards because only a hash is stored. `key_prefix` is the displayable fragment you use to tell keys apart. Requires a secret key carrying `keys:read`. A publishable key can never read this. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of keys. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/keys \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /keys Create an API key - **Operation id**: `createApiKey` - **Scope**: `keys:write` - **Permission**: `api_keys.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Returns the secret exactly once, in `data.secret`. Store it immediately. There is no way to recover it later, and support cannot retrieve it for you. A key can never be created with more authority than its creator has. The scopes you request are intersected with the creator's own permissions, and the granted set comes back in `data.scopes`. If you asked for something you do not hold, the key is still created, without it. Compare what you asked for against what you got. A secret key's authority is also re-evaluated on every request against its creator's live permissions, so revoking a person's access immediately narrows every key they made. **A retry does not give you the secret again.** `Idempotency-Key` guarantees you created one key rather than two, and a replayed response returns the key's `id`, `kind`, `key_prefix` and `scopes` with `secret: null` and `secret_replayable: false`. The secret is never stored anywhere, including in the idempotency record, because storing it would put a live credential in a database backup. If you lost it, rotate the key. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | A label for humans. Say where the key lives, so you know what breaks if you revoke it. | | `kind` | KeyKind | yes | `publishable` keys are prefixed `wv_pub_` and are safe in client code: read only, limited to published content, and admitted only to the operations marked `x-publishable: true`. Anything else returns `403 INSUFFICIENT_SCOPE`, whatever scopes the key carries. `secret` keys are prefixed `wv_sk_` and are server side only. | | `scopes` | Scope[] | yes | | | `expires_at` | date-time | null | no | Optional expiry. After it passes the key returns `401 API_KEY_EXPIRED`. | **Responses** | Status | Data | Description | |---|---|---| | 201 | ApiKey | The key was created. This is the only time the secret is returned. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/keys \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "", "kind": "publishable", "scopes": "" }' ``` ### POST /keys/{id}/rotate Rotate an API key - **Operation id**: `rotateApiKey` - **Scope**: `keys:write` - **Permission**: `api_keys.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Issues a new secret for the same key record, keeping its name and scopes, and returns it once. As with creation, a replayed retry returns `secret: null` and `secret_replayable: false` rather than handing out the credential a second time. Rotate again if you lost it. Set `grace_seconds` to keep the old secret working while you deploy the new one. During the grace window both work. After it, the old one returns `401 API_KEY_REVOKED`. A grace of `0` cuts the old secret off immediately, which is the right choice if you are rotating because it leaked. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** | Field | Type | Required | Description | |---|---|---|---| | `grace_seconds` | integer | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | ApiKey | Rotated. The new secret is returned once. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/keys/3f1b0c7a-0000-4000-8000-000000000001/rotate \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{}' ``` ### DELETE /keys/{id} Revoke an API key - **Operation id**: `revokeApiKey` - **Scope**: `keys:write` - **Permission**: `api_keys.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Immediate and permanent. The key record is kept, marked revoked, so the audit trail survives, but the secret stops working at once and returns `401 API_KEY_REVOKED`. Revoking the key you are calling with is allowed. It is the last request that key makes. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 204 | - | Revoked. No body. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X DELETE https://api.writavo.com/v1/keys/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` --- # Webhooks Outbound event delivery. Register an HTTPS endpoint, subscribe it to events, and receive a signed POST whenever your content changes, from this API or from the dashboard. Base URL: `https://api.writavo.com/v1` ### GET /webhooks List webhook endpoints - **Operation id**: `listWebhooks` - **Scope**: `webhooks:read` - **Permission**: `integrations.manage` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of webhook endpoints. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/webhooks \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /webhooks Create a webhook endpoint - **Operation id**: `createWebhook` - **Scope**: `webhooks:write` - **Permission**: `integrations.manage` - **Rate limit class**: write - **Plan feature**: `cms.webhooks` - **Spends credits**: no - **Publishable key may call it**: no Registers a URL to receive signed event deliveries. The signing secret is returned once, at creation, and never again: record it before you close the response. If you lose it, `POST /webhooks/{id}/rotate-secret` issues a new one. Verify every delivery, using the recipe at the top of this section, before you parse the body. The URL must be public HTTPS. Private, loopback, link-local and metadata addresses are refused, and so is a hostname that resolves to one, at save time and again at delivery time. A webhook cannot be pointed at internal infrastructure. Delivery is at least once. Retries use exponential backoff, and an endpoint that keeps failing is disabled automatically and reported to you. Deduplicate on the payload's `id` and make your handler idempotent. A Site may register at most ten endpoints. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `url` | uri | yes | | | `events` | WebhookEvent[] | yes | | | `description` | string | null | no | | **Responses** | Status | Data | Description | |---|---|---| | 201 | Webhook | The endpoint was created. The signing secret is returned once. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 402 | - | `NOT_ENTITLED`, `INSUFFICIENT_CREDITS` or `SPEND_CAP_REACHED`. The request was understood and you were permitted to make it, but it cannot be paid for. Three distinct codes because the fixes differ: upgrade the plan, top up credits, or raise the Site's cap. These are 402 rather than 403 on purpose. A 403 says "you may not"; these all say "not yet, and here is how to change that". | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/webhooks \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/writavo", "events": "" }' ``` ### GET /webhooks/{id} Read one webhook endpoint - **Operation id**: `getWebhook` - **Scope**: `webhooks:read` - **Permission**: `integrations.manage` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Webhook | The webhook endpoint. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### PATCH /webhooks/{id} Update a webhook endpoint - **Operation id**: `updateWebhook` - **Scope**: `webhooks:write` - **Permission**: `integrations.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Change the URL, the subscribed events, or re-enable an endpoint that was auto-disabled after repeated failures. Re-enabling does not replay what you missed. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `If-Match` | header | string | no | The `ETag` from your last read of this object. If it has changed since then you get `412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same article cannot silently overwrite each other. Optional in v1 for compatibility. Omitting it means last write wins. Send it. | **Request body** (required) | Field | Type | Required | Description | |---|---|---|---| | `url` | uri | no | | | `events` | WebhookEvent[] | no | | | `description` | string | null | no | | | `enabled` | boolean | no | | **Responses** | Status | Data | Description | |---|---|---| | 200 | Webhook | The updated webhook endpoint. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 412 | - | `PRECONDITION_FAILED`. Your `If-Match` did not match the current version, meaning somebody edited the object since you read it. Nothing was written. Re-read, merge, and retry with the new `ETag`. | | 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X PATCH https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H 'If-Match: W/"1767225600000"' \ -H "Content-Type: application/json" \ -d '{}' ``` ### DELETE /webhooks/{id} Delete a webhook endpoint - **Operation id**: `deleteWebhook` - **Scope**: `webhooks:write` - **Permission**: `integrations.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Deliveries stop at once. Queued retries for this endpoint are dropped. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 204 | - | Deleted. No body. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X DELETE https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000001 \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /webhooks/{id}/rotate-secret Rotate the signing secret - **Operation id**: `rotateWebhookSecret` - **Scope**: `webhooks:write` - **Permission**: `integrations.manage` - **Rate limit class**: write - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no Issues a new signing secret and returns it once. The old secret stops working immediately: there is no grace window, because a window in which two secrets both validate is a window in which a leaked secret still works. Deliveries sent between the rotation and your receiver being updated will fail verification. They are retried on the normal backoff, so update the receiver promptly and the queue drains itself. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. | **Responses** | Status | Data | Description | |---|---|---| | 200 | object | The new signing secret. Shown once. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000001/rotate-secret \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \ -H "Idempotency-Key: $(uuidgen)" ``` ### GET /webhooks/{id}/deliveries List deliveries for an endpoint - **Operation id**: `listWebhookDeliveries` - **Scope**: `webhooks:read` - **Permission**: `integrations.manage` - **Rate limit class**: read - **Plan feature**: `none` - **Spends credits**: no - **Publishable key may call it**: no The delivery log, newest first. Use it to answer "did you send it and did we accept it". `response_status` is what your server returned; `attempt` counts from 1 to 6. There is one row per ATTEMPT, and every attempt of one event shares its `event_id`. A row with `status: failed` and `exhausted: false` will be retried; `exhausted: true` means the ladder gave up. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. | | `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets a hundred and a `next_cursor`. | | `status` | query | string | no | | | `event` | query | WebhookEvent | no | Return only attempts for this event type. | **Responses** | Status | Data | Description | |---|---|---| | 200 | Page | A page of deliveries. | | 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000001/deliveries \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ``` ### POST /webhooks/{id}/deliveries/{delivery_id}/redeliver Send one delivery again - **Operation id**: `redeliverWebhookDelivery` - **Scope**: `webhooks:write` - **Permission**: `integrations.manage` - **Rate limit class**: write - **Plan feature**: `cms.webhooks` - **Spends credits**: no - **Publishable key may call it**: no Queues the same event for delivery again, with the same `event_id` and byte-identical body, so your handler sees it as the event it already knows how to deduplicate rather than as a second, different fact. Use it after fixing a receiver that was down. The attempt counter starts again at 1: this is a fresh ladder you asked for, not a continuation of the one that failed. The endpoint must be enabled. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | uuid | yes | | | `delivery_id` | path | uuid | yes | | **Responses** | Status | Data | Description | |---|---|---| | 202 | object | Queued. It will be attempted within the minute. | | 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. | | 402 | - | `NOT_ENTITLED`, `INSUFFICIENT_CREDITS` or `SPEND_CAP_REACHED`. The request was understood and you were permitted to make it, but it cannot be paid for. Three distinct codes because the fixes differ: upgrade the plan, top up credits, or raise the Site's cap. These are 402 rather than 403 on purpose. A 403 says "you may not"; these all say "not yet, and here is how to change that". | | 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. | | 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. | | 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. | | 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. | | 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. | | 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. | ```bash curl -X POST https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000001/deliveries/3f1b0c7a-0000-4000-8000-000000000001/redeliver \ -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" ```