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
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
{"type_of":"user","id":4169381,"username":"...","name":"...","joined_at":"Oct 7, 2026"}
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}
What is public and what is not
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
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
}}
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:
-
tagsis documented as a comma-separated string, but a JSON array also works. I sent["api","webdev","python","tutorial"]and the response echoed the normalizedtag_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
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"]}
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
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
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
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)
3. Rate limits exist, and they do not announce themselves
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
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
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}
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
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
publishedkey at all, andpublished_at/published_timestampcome back as empty strings for a draft. Read the state fromGET /api/articles/me/unpublished, not from the write. -
An API update did not populate
edited_atin my tests, even though the content changed. Do not use it to detect drift. -
A draft is a
404onGET /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
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
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
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"))
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/articleswithpublished: falseis the safe create path;PUT /api/articles/{id}is the update path; ten writable fields cover both. - Send
published,tags,descriptionandai_disclosure_levelexplicitly rather than trusting defaults. - Set a
User-Agent; page withpage/per_pageand stop on a short page; expect unadvertised429s whose body is plain text; render422messages as strings. - Read every write back from
GET /api/articles/me/unpublishedbefore you trust it.











Top comments (0)