I Gave dev.to a Command Line: scripting the Forem API end to end
I write build-logs on my own site, and I cross-post them to dev.to. For a single article you just paste it into the editor and move on. But once you're doing it on every post — same title, same body, same canonical link, same four tags — the paste-and-fiddle ritual is exactly the kind of repetitive thing a computer should be doing for you. So I stopped doing it by hand and wrote a command-line wrapper around dev.to's API instead.
This is the whole thing: what dev.to is, how its API works, how to get your key, the one call that actually publishes an article, and the little CLI hook I wrapped it all in. By the end you'll be able to publish to dev.to from a script — or from a one-line shell command.
What dev.to actually is (and why post there)
dev.to is a developer-writing community built on Forem, an open-source platform. It has a large, genuinely engaged audience, and cross-posting a tutorial or build-log there routinely pulls in readers who'd never have found your site on their own.
But the feature that makes it safe to cross-post — the one I care about most — is the
canonical URL. If you copy your article onto dev.to with no canonical tag, you've
just created duplicate content, and a search engine may decide dev.to's high-authority copy is the
“real” one and rank it above your own site for your own words. dev.to lets you
set a canonical_url per article; it then emits a
<link rel="canonical"> pointing back home. You get the reach and keep the
ranking. Every API call below sets it, every single time.
The API, in one paragraph
The base URL is https://dev.to/api. It's a normal JSON REST API. Version 1 wants two
headers: an Accept header pinned to the v1 media type, and — for anything that
reads or writes your data — an api-key header. Plenty of read endpoints
(public articles, tags, a user's posts) need no key at all; writes always do.
Accept: application/vnd.forem.api-v1+json
api-key: <your key>
That's the entire authentication story. No OAuth dance, no token refresh, no expiry to babysit. One header with one key.
Getting your API key
This is the part people get stuck on, and it's three clicks:
- Go to
dev.to/settings/extensions(Settings → Extensions). - Scroll to “DEV Community API Keys”.
- Type a description (e.g.
cli-hook) and click Generate API Key.
The key appears right there in the list. Copy it and treat it like a password — it can publish and edit posts as you. Stash it in an environment variable or a config file that never gets committed; don't paste it into your code. You can revoke it from the same page at any time.
GET /api/users/me. If it returns your username, you're done; if it
returns 401, the key or the headers are wrong. Always make the API tell you it's working before you
trust it.
The one call that matters: publishing an article
Creating an article is a POST to /api/articles. The body is a JSON object
with everything nested under an article key:
curl -X POST https://dev.to/api/articles \
-H "Accept: application/vnd.forem.api-v1+json" \
-H "api-key: $DEVTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"article": {
"title": "Your Post Title",
"body_markdown": "# Hello\n\nFull markdown body goes here.",
"published": false,
"canonical_url": "https://your-site.com/your-post",
"description": "A short, <=150-char SEO summary.",
"tags": ["python", "webdev", "api", "tutorial"]
}
}'
A few rules the API enforces that will bite you if you don't know them:
tagsmax four, all lowercase, alphanumeric only — no spaces, no punctuation.["c#", "web dev"]gets rejected;["csharp", "webdev"]is fine.canonical_urlis the SEO-saving field from above. Set it to your original's URL, never to the dev.to copy.publishedis the safety switch.falsecreates a draft you can eyeball in your dashboard first;truegoes live immediately.body_markdownis the full post in Markdown. Fenced code blocks with language hints (```python) render with syntax highlighting.
The draft-then-publish trick (no duplicates)
I never publish straight from a script on the first run — I want to see how the Markdown
rendered before it's public. So the flow is two calls. First, create it as a draft
(published: false); the response hands back the article's id. Eyeball it in
your dashboard. Then update that same article to published with a
PUT — which flips the draft live in place instead of creating a second
copy:
# publish an existing draft by id — updates, does not duplicate
curl -X PUT https://dev.to/api/articles/123456 \
-H "Accept: application/vnd.forem.api-v1+json" \
-H "api-key: $DEVTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"article": {"published": true}}'
That POST-to-draft, PUT-to-publish pattern is the single most useful thing
to internalize about this API. It's the difference between a clean pipeline and a profile littered
with half-finished duplicate posts.
The rest of the surface
Publishing is the headline, but the v1 API covers a lot, and most of it is a plain
GET:
GET /api/users/me— who the key belongs to (your verify-it call).GET /api/articles/me/all— your own posts;/me/publishedand/me/unpublishedfilter by state. Great for finding a draft's id.GET /api/articles— public articles, filterable bytag,username,top(top of the last n days), and more.GET /api/articles/{id}andGET /api/articles/{username}/{slug}— fetch one post.GET /api/tags— the taggable tags, in popularity order.GET /api/comments?a_id={id}— an article's comments as a threaded tree.POST /api/reactions/toggle— like/unicorn/fire a post programmatically.
Wrapping it in a hook
Raw curl is fine for one call, but I want a verb I can run from anywhere, so I wrapped
the whole API in a small stdlib-only Python CLI — no dependencies, key read from an env var or a
config file so it's never in the code. The core is just header assembly plus one request helper:
import json, os, urllib.request, urllib.error
BASE = "https://dev.to/api"
ACCEPT = "application/vnd.forem.api-v1+json"
def request(method, path, json_body=None, auth=True):
headers = {"Accept": ACCEPT, "User-Agent": "devto-hook/1.0"}
if auth:
headers["api-key"] = os.environ["DEVTO_API_KEY"] # or read from config
data = None
if json_body is not None:
data = json.dumps(json_body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=45) as r:
return json.loads(r.read().decode())
def create_article(title, body_markdown, canonical_url, tags, published=False):
article = {"title": title, "body_markdown": body_markdown,
"published": published, "canonical_url": canonical_url,
"tags": tags}
return request("POST", "/articles", {"article": article})
Hang a tiny argument parser and a dictionary of operations off that, and the API becomes a set of shell verbs:
python devto_hook.py whoami
python devto_hook.py createArticle --title="My Post" --file=post.md \
--canonical_url=https://my-site.com/my-post --tags=python,webdev
python devto_hook.py myArticles --state=unpublished
python devto_hook.py updateArticle --id=123456 --published=true
That last line is the publish step from earlier — the draft I created, eyeballed, and then
flipped live, all without leaving the terminal. Every endpoint here is verified against the live API,
not just copied from docs; the whoami call is what told me the key was good before I
trusted any of it.
The takeaway
dev.to's API is refreshingly small: one base URL, two headers, one key you grab in three clicks,
and a single POST that publishes. Wrap it once and cross-posting stops being a chore and
becomes a line in a script — with the canonical URL set correctly every time, so you're growing
your own site's authority while you're at it.
The code in this article is released under the MIT License — Copyright © 2026 Trent Tompkins. Keep your API key out of version control; it can publish as you.