Developers
WriteStuff REST API & webhooks
Read and update structured content, export it in bulk, and get signed events when items change, so your CMS always has the approved version.
Overview
- Base URL:
https://writestuff.app/api/v1. Versioned in the path; breaking changes will ship as/api/v2. - JSON everywhere (
Content-Type: application/json), snake_case keys, ISO 8601 UTC timestamps, opaque string ids. - Keys belong to a workspace and act with the permissions of the member who created them, narrowed by scopes and an optional project list.
- Rich text is sanitized HTML (paragraphs, headings, lists, links, emphasis). Unsafe markup is removed on every write.
Authentication
Owners and admins create keys in Settings → Integrations. A key is shown once; WriteStuff stores only a SHA-256 hash. Send it as a bearer token:
curl https://writestuff.app/api/v1/me \
-H "Authorization: Bearer ws_live_xxxxxxxxxxxx_…"| Scope | Allows |
|---|---|
| content:read | Read projects, templates, statuses, folders and items (and export them). |
| content:write | Create items, save content, change status and assignees. |
| workflow:read | Read project workflows (statuses). |
| webhooks:manage | List, create and delete webhook endpoints. |
Revoked or expired keys, keys whose creator left the workspace, and keys used outside their projects all get 401/404. Keys never work on the web app itself, and API routes ignore browser cookies.
Errors & rate limits
Errors use HTTP status codes and a stable body:
{ "error": { "code": "insufficient_scope", "message": "This API key needs the \"content:write\" scope." } }400 invalid_request,401 unauthorized,403 forbidden | insufficient_scope,404 not_found(also used for resources in other workspaces),409 conflict(stale expected_version / status),415,422 unprocessable(e.g. required fields missing for a status),429 rate_limited.- Each key may make 120 requests per minute. Responses include
RateLimit-LimitandRateLimit-Remaining; a 429 includesRetry-After(seconds).
Endpoints
Account
- GET
/api/v1/meanyThe calling key: workspace, scopes, effective role and remaining rate limit.
Projects
- GET
/api/v1/projectscontent:readProjects the key can see. ?archived=true includes archived projects.
- GET
/api/v1/projects/{project_id}content:readOne project with item/template counts and its workflow statuses.
- GET
/api/v1/projects/{project_id}/statusesworkflow:readWorkflow statuses in order (id, name, color, is_initial, is_complete, gate).
- GET
/api/v1/projects/{project_id}/templatescontent:readTemplates usable in the project, with tabs and field definitions.
- GET
/api/v1/projects/{project_id}/folderscontent:readFolder tree (id, name, parent_id, position).
Templates
- GET
/api/v1/templates/{template_id}content:readOne template: guidelines, tabs and fields (key, label, type, required, limits, options).
Items
- GET
/api/v1/projects/{project_id}/itemscontent:readItems, newest change first. Filters: status, template, folder (id or root), updated_since (ISO 8601), q (title). Pagination: limit (1–100, default 50) and cursor=next_cursor. format=wordpress returns WordPress post bodies.
- POST
/api/v1/projects/{project_id}/itemscontent:writeCreate an item in the project's first status.
{ "template_id": "…", "title": "Spring launch", "slug": "spring-launch", "folder_id": null, "fields": { "summary": "…", "body": "<p>Rich text as HTML</p>" } } - GET
/api/v1/items/{item_id}content:readOne item with structured field values. ?format=wordpress returns a WordPress post body.
- PATCH
/api/v1/items/{item_id}content:writeUpdate the title and/or fields (omitted fields keep their value). Send expected_version (= content_version) to get 409 instead of overwriting someone else's edit.
{ "title": "New title", "fields": { "summary": "Updated" }, "expected_version": 4 } - POST
/api/v1/items/{item_id}/statuscontent:writeMove the item to another status. Status gates and required fields apply exactly as in the app.
{ "status_id": "…", "expected_status_id": "…" } - PUT
/api/v1/items/{item_id}/assigneescontent:writeReplace the assignee list (members who can see the project).
{ "user_ids": ["…"] } - GET
/api/v1/attachments/{attachment_id}content:readDownload a file attached to an item (URLs appear in attachment field values).
Export
- GET
/api/v1/items/{item_id}/export?format=docxcontent:readOne item as docx, markdown, html, json or csv.
- POST
/api/v1/projects/{project_id}/exportcontent:readBulk export up to 500 items. layout=zip gives one file per item; combined gives one document. All-or-nothing: an id outside the project rejects the whole request with 403.
{ "item_ids": ["…", "…"], "format": "docx", "layout": "zip" }
Webhooks
- GET
/api/v1/webhookswebhooks:manageList endpoints (owner/admin keys that cover all projects).
- POST
/api/v1/webhookswebhooks:manageCreate an endpoint. The response contains the signing secret once.
{ "url": "https://example.com/hooks/writestuff", "events": ["item.status_changed"], "project_ids": [] } - DELETE
/api/v1/webhooks/{endpoint_id}webhooks:manageDelete an endpoint and its delivery log.
Item shape
Field values are typed by template field type: text is a string, rich text is { html, text }, single choice is { value, label }, multiple choice and files are arrays, dates are YYYY-MM-DD, repeatable groups are arrays of objects keyed by sub-field. When writing, send plain values (a string, an option value, an array of option values, or group rows).
{
"data": {
"id": "cm1x…",
"title": "Spring launch",
"slug": "spring-launch",
"project": { "id": "cm1p…", "name": "Website" },
"template": { "id": "cm1t…", "name": "Blog post", "version": 3 },
"status": { "id": "cm1s…", "name": "Approved", "color": "#10b981", "is_complete": false },
"folder": null,
"assignees": [{ "id": "cm1u…", "name": "Ada", "email": "ada@example.com" }],
"due_date": "2026-10-01T00:00:00.000Z",
"content_version": 4,
"fields": [
{ "key": "summary", "label": "Summary", "type": "shortText", "tab": "content", "value": "Our spring range is here." },
{ "key": "body", "label": "Body", "type": "richText", "tab": "content", "value": { "html": "<p>…</p>", "text": "…" } },
{ "key": "channel", "label": "Channel", "type": "select", "tab": "content", "value": { "value": "blog", "label": "Blog" } }
],
"values": { "summary": "Our spring range is here.", "body": { "html": "<p>…</p>", "text": "…" }, "channel": { "value": "blog", "label": "Blog" } }
}
}WordPress
Add ?format=wordpress to an item or item list request to get bodies accepted by the WordPress REST API (POST /wp-json/wp/v2/posts): title, slug, status (publish when the item is in a complete status, otherwise draft), content (HTML of every field), excerpt (from a summary/excerpt field), and meta.writestuff_<field_key> plain-text values (WordPress only stores meta keys registered with show_in_rest).
# 1. Fetch the item in WordPress shape
curl -s https://writestuff.app/api/v1/items/ITEM_ID?format=wordpress \
-H "Authorization: Bearer $WRITESTUFF_API_KEY" > post.json
# 2. Create the post (WordPress application password)
jq '.data | del(.id)' post.json | curl -s -X POST https://your-site.example/wp-json/wp/v2/posts \
-u "editor:APPLICATION_PASSWORD" -H "Content-Type: application/json" --data @-Pair it with an item.status_changed webhook to publish automatically when an item reaches your final status.
Webhooks
Owners and admins add endpoints in Settings → Integrations (or via the API). Each endpoint subscribes to events and optionally to specific projects. Events are recorded in the same database transaction as the change, so an event is never sent for a change that rolled back.
| Event | When |
|---|---|
| item.created | A content item was created. |
| item.updated | Item title or field content was saved (autosaves within a few minutes are coalesced). |
| item.status_changed | An item moved to another workflow status. |
| item.assignees_changed | People were assigned to or removed from an item. |
| item.moved | An item moved to another folder. |
| item.deleted | An item was deleted. |
| workflow.updated | A project's workflow statuses were changed. |
| ping | Sent when you click “Send test event”. |
POST /hooks/writestuff HTTP/1.1
Content-Type: application/json
User-Agent: WriteStuff-Webhooks/1.0
Webhook-Id: evt_5f0c2b9a1d3e4f6a7b8c9d0e
Webhook-Timestamp: 1790000000
Webhook-Signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
X-WriteStuff-Event: item.status_changed
{
"id": "evt_5f0c2b9a1d3e4f6a7b8c9d0e",
"type": "item.status_changed",
"api_version": "2026-09-25",
"created_at": "2026-09-25T14:03:11.000Z",
"workspace_id": "cm1w…",
"data": {
"item": { "id": "cm1x…", "title": "Spring launch", "project_id": "cm1p…", "template_id": "cm1t…", "folder_id": null,
"status": { "id": "cm1s…", "name": "Approved" }, "api_url": "/api/v1/items/cm1x…" },
"changes": { "from_status": { "id": "…", "name": "In review" }, "to_status": { "id": "cm1s…", "name": "Approved" }, "is_complete": false },
"actor": { "id": "cm1u…" },
"via": "app"
}
}- Respond with any 2xx within 10 seconds. Redirects are not followed and count as failures.
- Failures are retried up to 8 attempts in total, after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h. Endpoints that keep failing are paused automatically and can be resumed from the settings page.
- Delivery is at-least-once: use
Webhook-Id(alsoidin the body) to ignore duplicates. Ordering is not guaranteed; comparecreated_ator fetch the item for current state. - Payloads carry ids and a summary. Fetch
data.item.api_urlwith an API key for the full content. - Endpoint URLs must be public https URLs; private, loopback and link-local addresses are refused, including after DNS resolution.
Verifying signatures
WriteStuff signs every delivery using the Standard Webhooks scheme. The signature is an HMAC-SHA256, keyed with the base64-decoded part of your whsec_… secret, over {Webhook-Id}.{Webhook-Timestamp}.{raw body}. Webhook-Signature holds one or more space-separated v1,<base64> values. Reject timestamps more than 5 minutes old. Standard Webhooks libraries work unchanged.
import crypto from "node:crypto";
// secret = "whsec_…" from WriteStuff; body = the raw request body string
export function verifyWriteStuffWebhook(secret, headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replay window
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
return headers["webhook-signature"].split(" ").some((part) => {
const [version, signature] = part.split(",");
const given = Buffer.from(signature ?? "", "base64");
return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}Exports
Every item can be exported from the app or the API as Word (.docx), Markdown, HTML, JSON or CSV. Bulk export in the app (select items → Export) and the bulk export endpoint handle up to 500 items. Word, Markdown and HTML come as a zip with one file per item or as one combined document with a page break between items. JSON includes both the WriteStuff shape and the WordPress shape. CSV has one row per item and one column per field, with spreadsheet formula injection neutralized. Each export is recorded in the item's audit log.
Questions or missing endpoints? Create a workspace and generate a key under Settings → Integrations to try it.