Skip to content

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_…"
ScopeAllows
content:readRead projects, templates, statuses, folders and items (and export them).
content:writeCreate items, save content, change status and assignees.
workflow:readRead project workflows (statuses).
webhooks:manageList, 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-Limit and RateLimit-Remaining; a 429 includes Retry-After (seconds).

Endpoints

Account

  • GET/api/v1/meany

    The calling key: workspace, scopes, effective role and remaining rate limit.

Projects

  • GET/api/v1/projectscontent:read

    Projects the key can see. ?archived=true includes archived projects.

  • GET/api/v1/projects/{project_id}content:read

    One project with item/template counts and its workflow statuses.

  • GET/api/v1/projects/{project_id}/statusesworkflow:read

    Workflow statuses in order (id, name, color, is_initial, is_complete, gate).

  • GET/api/v1/projects/{project_id}/templatescontent:read

    Templates usable in the project, with tabs and field definitions.

  • GET/api/v1/projects/{project_id}/folderscontent:read

    Folder tree (id, name, parent_id, position).

Templates

  • GET/api/v1/templates/{template_id}content:read

    One template: guidelines, tabs and fields (key, label, type, required, limits, options).

Items

  • GET/api/v1/projects/{project_id}/itemscontent:read

    Items, 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:write

    Create 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:read

    One item with structured field values. ?format=wordpress returns a WordPress post body.

  • PATCH/api/v1/items/{item_id}content:write

    Update 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:write

    Move 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:write

    Replace the assignee list (members who can see the project).

    { "user_ids": ["…"] }
  • GET/api/v1/attachments/{attachment_id}content:read

    Download a file attached to an item (URLs appear in attachment field values).

Export

  • GET/api/v1/items/{item_id}/export?format=docxcontent:read

    One item as docx, markdown, html, json or csv.

  • POST/api/v1/projects/{project_id}/exportcontent:read

    Bulk 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:manage

    List endpoints (owner/admin keys that cover all projects).

  • POST/api/v1/webhookswebhooks:manage

    Create 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:manage

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

EventWhen
item.createdA content item was created.
item.updatedItem title or field content was saved (autosaves within a few minutes are coalesced).
item.status_changedAn item moved to another workflow status.
item.assignees_changedPeople were assigned to or removed from an item.
item.movedAn item moved to another folder.
item.deletedAn item was deleted.
workflow.updatedA project's workflow statuses were changed.
pingSent 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 (also id in the body) to ignore duplicates. Ordering is not guaranteed; compare created_at or fetch the item for current state.
  • Payloads carry ids and a summary. Fetch data.item.api_url with 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.