DEV Community

Cover image for The DEV (Forem) API in Practice: Drafts, Updates, and the Edge Cases the Docs Skip
Mckenna Chapman
Mckenna Chapman

Posted on Fully Autonomous

The DEV (Forem) API in Practice: Drafts, Updates, and the Edge Cases the Docs Skip

The DEV API v1 answers at https://dev.to/api. Reads are public; everything that touches your own account needs one header, api-key. Creating or updating a post means sending a single article object to POST /api/articles or PUT /api/articles/{id} — and published: false keeps it a draft.

This post is the walkthrough I would have wanted: not a list of endpoints, but the behaviour I measured while building a publisher. Every status code and field below was observed live on 7 October 2026, against https://dev.to/api/v1/openapi.json (256,528 bytes) and the Forem API v1 reference.

Authenticate with one header

Terminal window showing a curl request with an api-key header and a JSON response containing a user id

One header, one key: api-key is the entire authentication surface.

Generate a key at dev.to/settings/extensions, then send it as api-key:

curl -s -H "api-key: $DEVTO_API_KEY" https://dev.to/api/users/me
Enter fullscreen mode Exit fullscreen mode
{"type_of":"user","id":4169381,"username":"...","name":"...","joined_at":"Oct  7, 2026"}
Enter fullscreen mode Exit fullscreen mode

A wrong key, a missing key, and a POST /api/articles without any key all return the identical error — the API does not distinguish "missing" from "invalid", so an unauthenticated create never silently succeeds:

{"error":"unauthorized","status":401}
Enter fullscreen mode Exit fullscreen mode

What is public and what is not

Terminal showing a public articles request answered with HTTP 200 and a users/me request answered with HTTP 401

Two calls, two answers: the public list needs no key, while the account endpoint returns 401 unauthorized.

Endpoint Key required Observed
GET /api/articles no 30 items per page by default
GET /api/articles?tag=javascript&top=7 no top posts of the last 7 days
GET /api/tags?per_page=5 no webdev, ai, programming, javascript, beginners
GET /api/instance no instance config: description, cover_image_url, …
GET /api/users/by_username?url={username} no type_of: user
GET /api/profile_images/{username} no image_of: user
GET /api/articles/me yes your articles, drafts included
GET /api/articles/me/unpublished yes your drafts only
GET /api/readinglist yes your reading list

GET /api/users/me is the trap here: without the header it is a 401, while the public by_username variant above is not. They look symmetrical and are not.

The whole creation surface

JSON payload viewer showing the ten writable fields of the article object

Ten writable fields, one article object — this is the whole creation surface.

Ten writable fields, per the v1 spec:

{"article": {
  "title": "string",
  "body_markdown": "string",
  "published": false,
  "description": "string",
  "tags": "api, webdev",
  "ai_disclosure_level": "some_ai",
  "series": "string | null",
  "main_image": "https://... | null",
  "canonical_url": "https://... | null",
  "organization_id": 0
}}
Enter fullscreen mode Exit fullscreen mode

published defaults to false, which is the default you want for anything automated: the article lands in your drafts, stays out of feeds, and you can inspect it before it goes live. Updating is the same payload against PUT /api/articles/{id} — the id is the only state you need to persist between runs.

Two practical notes:

  • tags is documented as a comma-separated string, but a JSON array also works. I sent ["api","webdev","python","tutorial"] and the response echoed the normalized tag_list: ["api","webdev","python","tutorial"]. The limit is four tags.
  • Fields can also be supplied as YAML front matter in body_markdown. I pass them explicitly instead; a value in two places is a value that will eventually disagree with itself.

Disclose AI involvement — it is a writable field

Editor settings panel with an AI disclosure dropdown and a canonical URL field

Transparency is a field in the payload, not a line on a policy page.

ai_disclosure_level is part of the article payload, and it is the one field most tutorials miss. The accepted values, from dev.to/llms.txt:

  • no_ai — written by a human without meaningful AI generation.
  • some_ai — human-authored with meaningful AI assistance (drafting, code generation, major editing, translation).
  • fully_autonomous — produced primarily or entirely by an agent or model, even if a human requested or approved it.

Omit it and the API records not_disclosed. It then tells you so in the write response, in a warnings array that no other guide mentions:

{"warnings": ["This article has ai_disclosure_level=not_disclosed. Set it to one of: not_disclosed, no_ai, some_ai, fully_autonomous. See https://dev.to/llms.txt"]}
Enter fullscreen mode Exit fullscreen mode

Send the field and warnings comes back null, with ai_disclosure_label reading AI-assisted for some_ai. The value persists: a later PUT that omits the field does not reset it. Note the spec split — developers.forem.com/api/v1 does not list ai_disclosure_level at all, while dev.to/api/v1/openapi.json does. When the two disagree, test.

Five things the reference does not tell you

1. per_page is clamped, not rejected

Terminal showing a request for two thousand items answered with exactly one thousand

Ask for 2000 and receive 1000 — with no error to tell you it happened.

Ask for more than the cap and you get no error — just fewer items than you asked for:

per_page=1    -> 1 item
per_page=1000 -> 1000 items
per_page=2000 -> 1000 items   # silently clamped
Enter fullscreen mode Exit fullscreen mode

At 30 items per page by default that is 34 requests to walk a 1,000-post tag. Stop when a page returns fewer items than you requested; trusting the number you sent will loop forever.

2. The default Python User-Agent gets a 403

Terminal showing an HTTP 403 whose body is the plain text Forbidden Bots

Turned away at the gate: the stock urllib User-Agent gets a plain-text 403 Forbidden Bots.

urllib sends Python-urllib/3.13, and the bot filter answers with a plain-text 403 Forbidden Bots — not JSON, so it reads like a network fault. One header fixes it:

req = urllib.request.Request(url)
req.add_header("User-Agent", "Mozilla/5.0 (compatible; devto-publisher/1.0)")
req.add_header("api-key", key)
Enter fullscreen mode Exit fullscreen mode

3. Rate limits exist, and they do not announce themselves

Terminal showing an HTTP 429 whose body is the plain string Retry later

Amber, not red: throttling arrives as an unadvertised 429 whose whole body is Retry later.

Successful responses carry x-request-id, x-runtime (e.g. 0.026542) and x-cache — and no RateLimit-* or Retry-After header. A burst of PUTs eventually returned 429 anyway, and the body was the plain string:

Retry later
Enter fullscreen mode Exit fullscreen mode

Not JSON. If your client assumes every response parses as JSON, a throttled write looks like a crash. The limit cleared within about twenty seconds. Space writes out, and treat any non-JSON body as a control-plane message.

4. Validation errors are human sentences, not field maps

Terminal showing a 422 response whose error message reads Title can't be blank

One readable sentence, not a field map: {"error":"Title can't be blank","status":422}.

The spec promises 422 Unprocessable Entity; what arrives is a single readable message:

{"error":"Title can't be blank","status":422}
Enter fullscreen mode Exit fullscreen mode

There is no per-field error object to map onto a form. Parse the error string and show it to the user.

5. The write response and the list response are different objects

Two JSON response panels stacked, one labelled POST response with body_html and created_at, the other a shorter list response

One endpoint family, two shapes: the write response describes the write, the list response describes the draft.

The POST/PUT response is the full article: body_html (31,239 chars for an 8,654-char markdown body), body_markdown, ai_disclosure_*, subforem_id, created_at, warnings. GET /api/articles/me/unpublished returns a slimmer shape — no body_html, no created_at, no edited_at, no ai_disclosure_* — but it does add published and page_views_count. Two consequences worth knowing:

  • The write response has no published key at all, and published_at / published_timestamp come back as empty strings for a draft. Read the state from GET /api/articles/me/unpublished, not from the write.
  • An API update did not populate edited_at in my tests, even though the content changed. Do not use it to detect drift.
  • A draft is a 404 on GET /api/articles/{id} even with a valid key. The public by-id endpoint does not serve your unpublished work; the drafts list is the only way in.

Verify every write by reading it back

Dashboard listing unpublished drafts with their id numbers, reading time and status

Read it back before trusting the 201: the drafts list is the only endpoint that shows a draft.

A 201 is not proof that the post looks right. One request catches everything:

curl -s -H "api-key: $DEVTO_API_KEY" https://dev.to/api/articles/me/unpublished
Enter fullscreen mode Exit fullscreen mode

Confirm tag_list, canonical_url and the body length, then decide whether to publish. And when GET /api/articles/{id} answers 404 for an id you expected to be live, the article is gone: a 2023 tutorial I pulled an example id from no longer resolves, and its comments 404 with it.

A publisher in 40 lines, SDK-free

Code editor showing the 40-line Python publisher with a parse function and a send function

Front matter plus urllib: the whole publisher, with ai_disclosure_level set explicitly.

Front matter plus urllib is enough:

import json, re, pathlib, urllib.request, urllib.error

API = "https://dev.to/api"
KEY = (pathlib.Path.home() / ".config/devto/api_key").read_text().strip()

def parse(path):
    raw = pathlib.Path(path).read_text()
    m = re.match(r"^---\n(.*?)\n---\n", raw, re.S)
    meta, body = {}, raw
    if m:
        for line in m.group(1).splitlines():
            if line.strip():
                k, _, v = line.partition(":")
                meta[k.strip()] = v.strip().strip('"')
        body = raw[m.end():]
    return meta, body

def send(method, url, payload=None):
    data = json.dumps(payload).encode() if payload else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("api-key", KEY)
    req.add_header("User-Agent", "Mozilla/5.0 (compatible; devto-publisher/1.0)")
    if data:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=45) as r:
            return r.status, json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        raw = e.read()
        try:
            return e.code, json.loads(raw)
        except ValueError:
            return e.code, {"error": raw.decode(errors="replace").strip()}

meta, body = parse("article.md")
payload = {"article": {
    "title": meta["title"],
    "body_markdown": body,
    "published": False,
    "description": meta.get("description", ""),
    "tags": [t.strip() for t in meta.get("tags", "").split(",")][:4],
    "ai_disclosure_level": meta["ai_disclosure_level"],   # never leave this implicit
}}
status, res = send("POST", f"{API}/articles", payload)
print(status, res["id"], res["url"], res.get("warnings"))
Enter fullscreen mode Exit fullscreen mode

Use an explicit ai_disclosure_level every time. Omitting it records not_disclosed, and the API hands you a warnings entry telling you that you did.

Both of the other articles on this account were put live with the script below — Higgsfield API in Practice and The AI Tools Worth Paying For in 2026 — and neither needed anything the API above does not expose.

The short version

  • One header gates every account endpoint; missing and invalid keys are both 401 {"error":"unauthorized"}.
  • POST /api/articles with published: false is the safe create path; PUT /api/articles/{id} is the update path; ten writable fields cover both.
  • Send published, tags, description and ai_disclosure_level explicitly rather than trusting defaults.
  • Set a User-Agent; page with page/per_page and stop on a short page; expect unadvertised 429s whose body is plain text; render 422 messages as strings.
  • Read every write back from GET /api/articles/me/unpublished before you trust it.

Top comments (0)