Skip to content
NewThe humanizer lint now explains every flag and offers a one click fix
Reference

The API and the MCP server

Eleven operations, reachable two ways: REST routes under /v1 for scripts, and the same eleven as MCP tools for agents. One key with six scopes, and the humanizer on every piece.

A person patches a cable from a terminal into a panel, and digits travel along the cables to a row of outbox trays
One source, many outboxes: the pipeline that writes for the app writes for a script and an agent the same way.

The WriterDean API is a REST interface under /v1 and an MCP server at /api/mcp that run the same eleven operations: list templates and brands, create content and run the pipeline, read a run's trace, humanize and check text, list destinations and publish. One key with up to six scopes covers both. The API starts on the Regular plan.

This page is rendered from the catalog the routes run on. The discovery document at https://writerdean.com/v1 lists the same endpoints, and tools/list on the MCP server returns the same tools with the same schemas.

Quick start: a piece in four calls

From a key to finished markdown. The same four steps in curl, in the TypeScript client, and as MCP messages.

  1. 1Create a keyGo to API keys under Settings and create one with the scopes it needs. Owners and admins can do this. The key shows once and starts with wd_live_. It acts as the member who made it: what they can see in the app, the key can see.
  2. 2Find a brand and a templateGET /v1/brands and GET /v1/templates need no scope beyond a valid key. Every piece belongs to one brand, and the template lists the brief fields it expects and which ones it requires.
  3. 3Create the piecePOST /v1/content with the template slug and the brand id, plus a brief. The reply is 202 with a contentId and a runId, because the run has been accepted and is not finished yet.
  4. 4Poll the run, then read the pieceGET /v1/runs/{id} every two seconds until the status is done, needs_review or failed. Then GET /v1/content/{id} returns the markdown, the title and meta, the humanizer and SEO scores, and the sources. The client's waitForRun does the polling.
# 1. A brand and a template. No scope needed beyond a valid key.
curl https://writerdean.com/v1/brands -H "Authorization: Bearer wd_live_YOUR_KEY"
curl https://writerdean.com/v1/templates -H "Authorization: Bearer wd_live_YOUR_KEY"

# 2. Create the piece. Returns 202 with contentId and runId.
curl -X POST https://writerdean.com/v1/content \
  -H "Authorization: Bearer wd_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"templateSlug":"blog-post","brandId":"BRAND_ID","brief":{"primaryKeyword":"content calendar"}}'

# 3. Poll every two seconds until status is done, needs_review or failed.
curl https://writerdean.com/v1/runs/RUN_ID -H "Authorization: Bearer wd_live_YOUR_KEY"

# 4. Read the piece: markdown, title, meta, scores and sources.
curl https://writerdean.com/v1/content/CONTENT_ID -H "Authorization: Bearer wd_live_YOUR_KEY"

Keys and scopes

Send the key as a bearer token on every request: Authorization: Bearer wd_live_…. A key belongs to the member who created it and runs with that member's permissions under row level security, so it reads the brands and content that member reads, and removing the member from the workspace revokes the key. Keys are made and revoked in Settings under API keys, by owners and admins, and each one records when it was last used.

A key carries any set of the six scopes. Give a reporting script content:read and nothing else; give an agent that drafts and publishes the four it needs. The matrix shows which endpoints a set of scopes can call. Pick a different set and watch the list change.

Scopes on the key

The three checked here are the dialog's defaults.

7 of 11 endpoints

Templates and brands need no scope beyond a valid key.

The six scopes and what each one grants
ScopeGrants
content:readList content, read a piece with its current version, and read a run with its trace.
content:writeCreate content from a template and a brief, which also starts its first run.
pipelines:runStart a new run on existing content. Or create a piece and run it in one call.
humanizeRun the humanizer on any text, or score it without rewriting.
seoRun the on page SEO checklist against markdown.
publishList destinations and publish or schedule an approved piece.

Endpoints

Every route lives under /v1 and speaks JSON. Lists come wrapped in a named array (templates, brands, content, destinations). A call that starts work returns 202 with the ids to poll. Everything else returns 200. Ids are UUIDs, dates are ISO 8601 in UTC, and a failed call returns an error object with a code and a message, listed under Errors.

Each card shows the call three ways. Pick a tab once and every sample on the page follows.

Catalog

What a workspace can write and for whom. Both need a valid key and nothing more.

GET

/v1/templates

scope anyreturns 200MCP list_templates

The templates this workspace can use, with the brief fields each one expects.

curl https://writerdean.com/v1/templates \
  -H "Authorization: Bearer wd_live_YOUR_KEY"

Response

200 application/json
{
  "templates": [
    {
      "slug": "blog-post",
      "name": "Blog post",
      "family": "Blog and long-form",
      "contentType": "blog-post",
      "description": "A searchable article on one keyword with a direct answer up front.",
      "brief": [
        {
          "key": "primaryKeyword",
          "label": "Primary keyword",
          "type": "text",
          "required": true
        },
        {
          "key": "targetLength",
          "label": "Target length",
          "type": "number",
          "required": false
        }
      ]
    }
  ]
}
GET

/v1/brands

scope anyreturns 200MCP list_brands

The brands in the workspace. Every piece belongs to one brand and reads its context.

curl https://writerdean.com/v1/brands \
  -H "Authorization: Bearer wd_live_YOUR_KEY"

Response

200 application/json
{
  "brands": [
    {
      "id": "5f0d3d0e-2b0e-4a6f-9c1c-7d5b1f2a9e11",
      "name": "Northwind Analytics",
      "website": "https://northwind.example",
      "language": "en",
      "locale": "en-US",
      "hasContext": true
    }
  ]
}

Content and runs

Create a piece and start a run on it. Read the piece back, and read the trace the run left.

GET

/v1/content

scope content:readreturns 200MCP list_content

Content in the workspace, newest first, filtered by status or brand.

query
statusbrandIdlimit
curl https://writerdean.com/v1/content?status=approved&limit=20 \
  -H "Authorization: Bearer wd_live_YOUR_KEY"

Response

200 application/json
{
  "content": [
    {
      "id": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
      "title": "Why content calendars stall in month two: a practical guide",
      "type": "blog-post",
      "status": "approved",
      "brandId": "5f0d3d0e-2b0e-4a6f-9c1c-7d5b1f2a9e11",
      "primaryKeyword": "content calendar",
      "language": "en",
      "currentVersionId": "f3a9c1d2-4e5b-4a7c-8d9e-1b2c3d4e5f60",
      "scheduledAt": null,
      "publishedAt": null,
      "createdAt": "2026-09-25T09:12:04.000Z",
      "updatedAt": "2026-09-25T09:19:31.000Z"
    }
  ]
}
POST

/v1/content

scope content:writereturns 202MCP create_content

Create a piece from a template and a brief, and start its first run.

body
templateSlugbrandIdbriefapprovalAfterOutline?
curl -X POST https://writerdean.com/v1/content \
  -H "Authorization: Bearer wd_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateSlug": "blog-post",
    "brandId": "5f0d3d0e-2b0e-4a6f-9c1c-7d5b1f2a9e11",
    "brief": {
      "primaryKeyword": "content calendar",
      "audience": "Marketing leads at small software companies"
    }
  }'

Response

202 application/json
{
  "contentId": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
  "runId": "9d4e1a7b-6c2f-4e0a-8b1d-3f5a7c9e2d44",
  "status": "queued"
}
GET

/v1/content/{id}

scope content:readreturns 200MCP get_content

One piece: the current version with its humanizer and SEO scores, the sources it cites, and every place it went out to.

curl https://writerdean.com/v1/content/CONTENT_ID \
  -H "Authorization: Bearer wd_live_YOUR_KEY"

Response

200 application/json
{
  "id": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
  "title": "Why content calendars stall in month two: a practical guide",
  "type": "blog-post",
  "status": "approved",
  "brandId": "5f0d3d0e-2b0e-4a6f-9c1c-7d5b1f2a9e11",
  "version": {
    "number": 3,
    "title": "Why content calendars stall in month two: a practical guide",
    "markdown": "# Why content calendars stall in month two\n\nMost calendars are built for the first month...",
    "metaDescription": "Content calendars fail in month two for four reasons. Here is how to plan for each of them.",
    "slug": "why-content-calendars-stall-in-month-two",
    "wordCount": 1480,
    "humanizer": {
      "ok": true,
      "score": 1.98,
      "threshold": 3,
      "hardFails": 0
    },
    "seo": {
      "ok": true,
      "score": 92
    },
    "sources": [
      {
        "claim": "62 percent of teams abandon their calendar by month three",
        "url": "https://example.com/report",
        "status": "supported"
      }
    ]
  },
  "lastRunId": "9d4e1a7b-6c2f-4e0a-8b1d-3f5a7c9e2d44",
  "publications": []
}
POST

/v1/pipelines

scope pipelines:runreturns 202MCP run_pipeline

Run the pipeline again on existing content, with a new brief if you send one.

body
contentId | templateSlug + brandId + briefbrief?approvalAfterOutline?
curl -X POST https://writerdean.com/v1/pipelines \
  -H "Authorization: Bearer wd_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contentId": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
    "brief": {
      "primaryKeyword": "content calendar",
      "notes": "Add a section on quarterly planning."
    }
  }'

Response

202 application/json
{
  "contentId": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
  "runId": "9d4e1a7b-6c2f-4e0a-8b1d-3f5a7c9e2d44",
  "status": "queued"
}
GET

/v1/runs/{id}

scope content:readreturns 200MCP get_run

A run and its trace: every stage with its skills, model, tokens, cost and gates, plus the output it produced.

curl https://writerdean.com/v1/runs/RUN_ID \
  -H "Authorization: Bearer wd_live_YOUR_KEY"

Response

200 application/json
{
  "id": "9d4e1a7b-6c2f-4e0a-8b1d-3f5a7c9e2d44",
  "contentId": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
  "contentType": "blog-post",
  "status": "done",
  "totalCostUsd": 0.0121,
  "inputTokens": 66102,
  "outputTokens": 3787,
  "flaggedForReview": false,
  "steps": [
    {
      "position": 8,
      "stage": "humanize",
      "label": "Humanize",
      "status": "done",
      "skills": [
        {
          "name": "humanizer",
          "version": "3.0.0",
          "hash": "7b1c…e9f2"
        }
      ],
      "costUsd": 0.002,
      "gates": [
        {
          "gate": "humanizer_lint",
          "pass": true,
          "required": true
        }
      ]
    }
  ]
}

Text tools

The humanizer and the on page checklist on any text, with or without a piece behind it.

POST

/v1/humanize

scope humanizereturns 200MCP humanize_text

Rewrite text with the humanizer and return the lint report, or score it without rewriting.

body
textbrandId?lintOnly?
curl -X POST https://writerdean.com/v1/humanize \
  -H "Authorization: Bearer wd_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "It's not just a tool, it's a mindset. Let's dive in.",
    "lintOnly": true
  }'

Response

200 application/json
{
  "text": "It's not just a tool, it's a mindset. Let's dive in.",
  "lint": {
    "ok": false,
    "score": 14.5,
    "threshold": 3,
    "hardFails": [],
    "findings": [
      {
        "tell": 1,
        "label": "Not X but Y (it's not X, it's Y)",
        "line": 1,
        "severity": "high"
      }
    ]
  },
  "attempts": 0,
  "changed": false
}
POST

/v1/seo/check

scope seoreturns 200MCP seo_check

The on page checklist: title and meta lengths, keyword placement, headings, answer block, links, images, readability.

body
markdownprimaryKeywordtitle?metaDescription?slug?secondaryKeywords?
curl -X POST https://writerdean.com/v1/seo/check \
  -H "Authorization: Bearer wd_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "markdown": "# Why content calendars stall in month two\n\nMost calendars are built for the first month...",
    "primaryKeyword": "content calendar",
    "title": "Content calendars: a practical guide"
  }'

Response

200 application/json
{
  "ok": false,
  "score": 78,
  "checks": [
    {
      "id": "title-length",
      "label": "Title between 50 and 60 characters",
      "pass": false,
      "severity": "warn",
      "detail": "36 characters"
    }
  ]
}

Publishing

Where a piece can go and the call that sends it, now or on a date.

GET

/v1/destinations

scope publishreturns 200MCP list_destinations

Connected publishing destinations with the ids that publish expects.

curl https://writerdean.com/v1/destinations \
  -H "Authorization: Bearer wd_live_YOUR_KEY"

Response

200 application/json
{
  "destinations": [
    {
      "id": "c2a7e6d1-9b3f-4c8e-a1d2-6e4f8b0c7a55",
      "name": "Northwind blog",
      "kind": "article",
      "integrationId": "e7c1d2b3-8a4f-4d6e-9b0c-5f6a7b8c9d10"
    }
  ]
}
POST

/v1/publish

scope publishreturns 202MCP publish_content

Publish an approved piece now, or schedule it. Publishing again to the same destination updates the earlier post.

body
contentIdtargetIdscheduledFor?utm?
curl -X POST https://writerdean.com/v1/publish \
  -H "Authorization: Bearer wd_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contentId": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
    "targetId": "c2a7e6d1-9b3f-4c8e-a1d2-6e4f8b0c7a55",
    "scheduledFor": "2026-10-06T09:00:00Z",
    "utm": {
      "utm_source": "newsletter"
    }
  }'

Response

202 application/json
{
  "publicationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "status": "scheduled",
  "url": null,
  "scheduledFor": "2026-10-06T09:00:00.000Z"
}

Runs and polling

A run is accepted in milliseconds and finishes in minutes, so the API never blocks on one. You poll GET /v1/runs/{id} and read the status. Every two seconds is a fair interval; the client's waitForRun uses it and gives up after ten minutes with the code timeout. The reply carries the whole trace as it grows: each stage with its skills and their versions, the model, the tokens, the cost, the gate results and the output, so a script can show progress the way the run page does.

Run statuses
StatusMeaningKeep polling
queuedAccepted. A worker picks it up within seconds.Yes
runningStages are executing. The steps array shows which one.Yes
awaiting_approvalPaused after the outline for a reviewer, when approvalAfterOutline was set. Someone approves it in the app.No
doneEvery required gate passed. The piece has a new current version.No
needs_reviewFinished, but a required gate did not pass within its retries. A person decides in the editor.No
failedA stage failed after its retries. The error field says why.No
cancelledStopped from the run page.No

A replay of the captured run, as a client polls it

  1. POSThttps://writerdean.com/v1/content202 queued
  2. GEThttps://writerdean.com/v1/runs/{id}running · Brand Context
  3. GEThttps://writerdean.com/v1/runs/{id}running · Research
  4. GEThttps://writerdean.com/v1/runs/{id}running · Strategy
  5. GEThttps://writerdean.com/v1/runs/{id}running · Outline
  6. GEThttps://writerdean.com/v1/runs/{id}running · Draft
  7. GEThttps://writerdean.com/v1/runs/{id}running · SEO layer
  8. GEThttps://writerdean.com/v1/runs/{id}running · Copy edit
  9. GEThttps://writerdean.com/v1/runs/{id}running · Humanize
  10. GEThttps://writerdean.com/v1/runs/{id}running · Quality gates
  11. GEThttps://writerdean.com/v1/runs/{id}done
  12. GEThttps://writerdean.com/v1/content/{id}version 1 · 379 words
{
  "id": "9d4e1a7b-6c2f-4e0a-8b1d-3f5a7c9e2d44",
  "status": "queued",
  "totalCostUsd": 0,
  "steps": [
    { "stage": "brand_context", "status": "pending" },
    { "stage": "research", "status": "pending" },
    { "stage": "strategy", "status": "pending" },
    { "stage": "outline", "status": "pending" },
    { "stage": "draft", "status": "pending" },
    { "stage": "seo", "status": "pending" },
    { "stage": "copyedit", "status": "pending" },
    { "stage": "humanize", "status": "pending" },
    { "stage": "qa", "status": "pending" }
  ],
  "running": null
}

The replay uses the run the live demo captured, with its real stage costs. A production run takes a few minutes, not a few seconds.

The MCP server

The MCP server gives an agent the same eleven operations as tools. It speaks the streamable HTTP transport in its stateless mode: every message is one POST to /api/mcp with the key as a bearer token, and every reply is plain JSON. There are no sessions, no server sent events, and no resources or prompts, only tools. Batched messages are answered as a batch, and notifications get a 202 with no body.

URL
https://writerdean.com/api/mcp
Transport
Streamable HTTP, stateless
Protocol version
2025-06-18
Server
writerdean 1.0.0

Connect a client

Any client that speaks HTTP MCP and can send a header works. Three ways to point one at your workspace.

claude mcp add --transport http writerdean https://writerdean.com/api/mcp \
  --header "Authorization: Bearer wd_live_YOUR_KEY"

What a tool call returns

A result carries the JSON twice: as text in content for clients that only read text, and as structuredContent for the ones that read objects. A tool that fails returns isError: true with the same message the REST error would carry, inside a normal 200 envelope. A request that fails before any tool runs, because the key is missing or the plan is too low, returns a JSON-RPC error with code -32001 and the HTTP status of the cause.

The eleven tools

Each tool runs the endpoint it links to, with the same scope check. Open a tool for its input schema, exactly as tools/list returns it.

  • list_templatesList the content templates available to this workspace, with the brief fields each one expects.Schema
    inputSchema
    {
      "type": "object",
      "properties": {},
      "additionalProperties": false
    }

    Runs GET /v1/templates.

  • list_brandsList the brands in this workspace. Every piece of content belongs to one brand and reads its Brand Context.Schema
    inputSchema
    {
      "type": "object",
      "properties": {},
      "additionalProperties": false
    }

    Runs GET /v1/brands.

  • create_contentCreate a piece of content from a template and brief, and start the generation pipeline. Returns contentId and runId; poll get_run until status is done, then get_content for the text. The humanizer always runs last and cannot be turned off.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "templateSlug": {
          "type": "string",
          "description": "A slug from list_templates."
        },
        "brandId": {
          "type": "string",
          "format": "uuid",
          "description": "A brand id from list_brands."
        },
        "brief": {
          "type": "object",
          "description": "Brief fields for the template, for example primaryKeyword, secondaryKeywords, audience, cta, notes.",
          "additionalProperties": true
        },
        "approvalAfterOutline": {
          "type": "boolean",
          "description": "Pause after the outline for human approval."
        }
      },
      "required": [
        "templateSlug",
        "brandId",
        "brief"
      ],
      "additionalProperties": false
    }

    Runs POST /v1/content and needs the content:write scope.

  • get_contentFetch a piece of content with its current version (markdown, title, meta description) plus humanizer and SEO scores, its sources, and where it has been published.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "contentId": {
          "type": "string",
          "format": "uuid",
          "description": "The content id."
        }
      },
      "required": [
        "contentId"
      ],
      "additionalProperties": false
    }

    Runs GET /v1/content/{id} and needs the content:read scope.

  • list_contentList content in the workspace, newest first. Filter by status (idea, brief, draft, in_review, approved, scheduled, published, needs_refresh) or brandId.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string"
        },
        "brandId": {
          "type": "string",
          "format": "uuid",
          "description": "Only this brand."
        },
        "limit": {
          "type": "integer",
          "minimum": 1,
          "maximum": 200
        }
      },
      "additionalProperties": false
    }

    Runs GET /v1/content and needs the content:read scope.

  • run_pipelineStart a new pipeline run. Pass contentId to regenerate an existing piece (optionally with a new brief), or templateSlug plus brandId and brief to create and generate in one step. Returns a runId to poll with get_run.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "contentId": {
          "type": "string",
          "format": "uuid",
          "description": "Existing content to regenerate."
        },
        "templateSlug": {
          "type": "string"
        },
        "brandId": {
          "type": "string",
          "format": "uuid",
          "description": "Brand for new content."
        },
        "brief": {
          "type": "object",
          "additionalProperties": true
        },
        "approvalAfterOutline": {
          "type": "boolean"
        }
      },
      "additionalProperties": false
    }

    Runs POST /v1/pipelines and needs the pipelines:run scope.

  • get_runFetch a pipeline run and its trace: every stage with the skills and versions it used, the model, tokens, cost, gate results, output. Possible statuses: queued, running, awaiting_approval, done, needs_review, failed, cancelled.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "runId": {
          "type": "string",
          "format": "uuid",
          "description": "The run id."
        }
      },
      "required": [
        "runId"
      ],
      "additionalProperties": false
    }

    Runs GET /v1/runs/{id} and needs the content:read scope.

  • humanize_textRun the humanizer skill on text and return the rewritten text with its lint report. Pass brandId to apply that brand's voice settings (dash policy, quote style, writing samples). Set lintOnly to score without rewriting.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "text": {
          "type": "string",
          "minLength": 20
        },
        "brandId": {
          "type": "string",
          "format": "uuid",
          "description": "Optional brand for voice settings."
        },
        "lintOnly": {
          "type": "boolean"
        }
      },
      "required": [
        "text"
      ],
      "additionalProperties": false
    }

    Runs POST /v1/humanize and needs the humanize scope.

  • seo_checkRun the on page SEO checklist against markdown: title and meta lengths, keyword placement, headings, answer block, links, images, readability.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "markdown": {
          "type": "string"
        },
        "primaryKeyword": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "metaDescription": {
          "type": "string"
        },
        "slug": {
          "type": "string"
        },
        "secondaryKeywords": {
          "type": "array",
          "items": {
            "type": "string"
          }
        }
      },
      "required": [
        "markdown",
        "primaryKeyword"
      ],
      "additionalProperties": false
    }

    Runs POST /v1/seo/check and needs the seo scope.

  • list_destinationsList connected publishing destinations (CMS collections, blogs, social accounts) with their ids for publish_content.Schema
    inputSchema
    {
      "type": "object",
      "properties": {},
      "additionalProperties": false
    }

    Runs GET /v1/destinations and needs the publish scope.

  • publish_contentPublish approved content to a destination now, or schedule it with an ISO 8601 scheduledFor. Publishing the same piece to the same destination again updates the earlier post.Schema
    inputSchema
    {
      "type": "object",
      "properties": {
        "contentId": {
          "type": "string",
          "format": "uuid",
          "description": "Approved content."
        },
        "targetId": {
          "type": "string",
          "format": "uuid",
          "description": "A destination id from list_destinations."
        },
        "scheduledFor": {
          "type": "string",
          "format": "date-time"
        },
        "utm": {
          "type": "object",
          "additionalProperties": {
            "type": "string"
          }
        }
      },
      "required": [
        "contentId",
        "targetId"
      ],
      "additionalProperties": false
    }

    Runs POST /v1/publish and needs the publish scope.

The order an agent follows

The server's initialize reply carries instructions, so an agent knows where to start without reading this page. On a fresh workspace the order is the one below. The instructions also state the rule the humanizer section explains.

  1. list_brands
  2. list_templates
  3. create_content
  4. get_run
  5. get_content
  6. publish_content
instructions, from the initialize reply
This server is the WriterDean content factory for the workspace "Northwind Analytics". Start with list_brands and list_templates, create content with create_content, poll get_run until it is done, read it with get_content, and publish with publish_content. The humanizer is the mandatory final pass on every piece and cannot be disabled.

Errors

A failed call returns { "error": { "code", "message" } } with the status below. The message is written for a person and says what to change. Match on the code, not the message.

Error codes
CodeStatusWhen
unauthorized401No bearer token, or a key that does not exist or that an admin revoked.
forbidden403The member who created the key left the workspace, or the key's owner lacks the editor role that publishing needs.
missing_scope403The key lacks the scope this endpoint needs. The message names the scope.
plan_required402The workspace is on the free plan. The API and the MCP server start at Regular.
credits_exhausted402The workspace has used this month's credits and its plan does not meter overage.
rate_limited429Too many requests from this key or from this address. The message says how many seconds to wait.
bad_request400The body is not valid JSON, a required field is missing, or an id is not a UUID.
humanizer_required400The request tried to turn the humanizer off. Nothing leaves the pipeline without it.
not_found404No content, run, brand, or destination with that id exists in this workspace.
not_ready409The piece has no finished version to publish yet.
not_approved409The piece is not approved. Owners and admins can publish anyway.
internal500Something failed on our side. Retry once, and write to us if it repeats.

Rate limits and plans

The API and the MCP server are on the Regular plan and above. A key from a workspace below that is refused with plan_required before anything else is checked. Two limits apply to every request: one per address, counted before the key is read, and one per key, set by the plan. Both are token buckets that refill continuously, so a burst of the full allowance drains the bucket and it fills back at the per minute rate divided by sixty.

A refused request returns 429 with rate_limited, and the message says how many seconds to wait. Back off for that long; retrying sooner spends the tokens as they arrive.

Requests a minute by plan
LimitRequests a minuteSustained
One address, any key1202 a second
One key, Regular plan1202 a second
One key, Pro plan2404 a second
One key, Agency plan60010 a second

Agency limits are per workspace and can be raised on request. Write to hello@writerdean.com with the key's name and the rate you need.

The humanizer rule

Every piece the pipeline produces passes through the humanizer last, and no caller can turn that off. The check runs at the API boundary before anything is written, so a request that tries is refused whole with 400 and the code humanizer_required. The MCP server applies the same check inside a tool call.

The fields that trigger it, at the top level of the body or nested under options or pipeline:

  • humanizer: false or humanize: false
  • skipHumanizer or skip_humanizer: true
  • disableHumanizer or disable_humanizer: true
  • skipStages or skip_stages containing humanize
  • a stages list that leaves humanize out

If the output reads too plain for a brand, the answer is in the brand document: the dash policy, the quote style and the writing samples all shape the humanizer's rewrite. The humanizer guide explains what the lint measures and how to read its findings.

Webhooks

An outbound webhook sends a signed POST to a URL you add under Integrations, Webhooks. The signing secret shows once, when the endpoint is created. Each delivery has ten seconds to get a reply, the last HTTP status is recorded on the endpoint, and a failed delivery never blocks the run or the publish that caused it. There is no retry today, so treat a delivery as at most once and reconcile with GET /v1/content/{id} when it matters.

Events that fire today

Webhook events
EventWhenData
content.publishedA publish job finished and the destination confirmed it.contentId, title, provider, url, externalId
publication.failedA publish job failed after the destination refused it.contentId, provider, error

The subscription form lists four more names: content approved, run completed, run failed, batch completed. Those are reserved and do not fire yet. This table changes when they do.

Headers and payload

Every delivery carries content-type: application/json, the event name in x-writerdean-event, and a signature in x-writerdean-signature of the form t=<unix seconds>,v1=<hex>, where v1 is an HMAC with SHA-256 over the string t.body using your secret.

POST body, content.published
{
  "event": "content.published",
  "workspaceId": "3c1f9a2e-7d4b-4e8a-9b6c-1a2b3c4d5e6f",
  "at": "2026-09-25T09:41:07.000Z",
  "data": {
    "contentId": "0b6f2c94-3a1d-4b8e-9f2a-2c9d4e7a1b33",
    "title": "Why content calendars stall in month two: a practical guide",
    "provider": "wordpress",
    "url": "https://northwind.example/blog/why-content-calendars-stall-in-month-two",
    "externalId": "1287"
  }
}

Verify a signature

Compute the same HMAC over the raw body and compare in constant time. Reject a timestamp older than five minutes, which is the window the sender uses. This is the function the platform itself uses, with the names changed.

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

// body is the raw request body, before any JSON parsing.
export function verifyWriterDean(body: string, header: string, secret: string, toleranceSeconds = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

TypeScript client

The client lives in the repository under packages/sdk and is not on npm yet. It has no dependencies and runs anywhere fetch does: Node 18 and later, Deno, Bun, edge workers and the browser. Copy src/index.ts into your project, or add the package from the workspace if you build inside the monorepo. The base URL defaults to https://writerdean.com; self hosters pass their own.

Every method maps to one endpoint and returns the unwrapped value, so listBrands() resolves to the array, not to { brands }. A failed call throws WriterDeanError with the HTTP status and the error code from the table above, so a script can branch on err.code.

Client methods and the endpoints they call
MethodCalls
listTemplates()GET /v1/templates
listBrands()GET /v1/brands
listContent({ status, brandId, limit })GET /v1/content
createContent({ templateSlug, brandId, brief })POST /v1/content
getContent(contentId)GET /v1/content/{id}
runPipeline({ contentId, brief })POST /v1/pipelines
getRun(runId) or waitForRun(runId)GET /v1/runs/{id}
humanize({ text, brandId, lintOnly })POST /v1/humanize
seoCheck({ markdown, primaryKeyword })POST /v1/seo/check
listDestinations()GET /v1/destinations
publish({ contentId, targetId, scheduledFor })POST /v1/publish
errors.ts
import { WriterDean, WriterDeanError } from "@writerdean/sdk";

const wd = new WriterDean({ apiKey: process.env.WRITERDEAN_API_KEY!, baseUrl: "https://writerdean.com" });

try {
  const report = await wd.humanize({ text: draft, brandId, lintOnly: true });
  console.log(report.lint.score, report.lint.threshold);
} catch (err) {
  if (err instanceof WriterDeanError && err.code === "rate_limited") {
    // err.message says how many seconds to wait.
  } else throw err;
}

Questions about the API

Something missing or wrong on this page? Write to hello@writerdean.com and say which section.

Your first run through the API is on the same credits

Create a workspace, upgrade to Regular, make a key with the scopes you need, and the first call works the way this page says it does.

Write your first article free
  • No card needed
  • Free plan included
  • Full trace on every run