← trentontompkins.com

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.

Verify it in one call. Before building anything, confirm the key works by asking the API who you are: 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:

  • tags max four, all lowercase, alphanumeric only — no spaces, no punctuation. ["c#", "web dev"] gets rejected; ["csharp", "webdev"] is fine.
  • canonical_url is the SEO-saving field from above. Set it to your original's URL, never to the dev.to copy.
  • published is the safety switch. false creates a draft you can eyeball in your dashboard first; true goes live immediately.
  • body_markdown is 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/published and /me/unpublished filter by state. Great for finding a draft's id.
  • GET /api/articles — public articles, filterable by tag, username, top (top of the last n days), and more.
  • GET /api/articles/{id} and GET /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.

To get more useful hooks, visit github.com/tibberous/hooks. The full dev.to hook lives there — alongside wrappers for Mastodon, NameSilo, and a pile of other services I'd rather drive from one line than a dozen clicks.

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.