DEV Community

Cover image for Three Dev.to API behaviors I had to handle explicitly in a publish pipeline
MORINAGA
MORINAGA

Posted on

Three Dev.to API behaviors I had to handle explicitly in a publish pipeline

I've published 130+ articles to Dev.to through a cron-driven GitHub Actions pipeline. Most of it works exactly as the Forem API docs describe. Three behaviors weren't obvious from the docs and cost me time when the pipeline ran into them.

Retry-After sends seconds or an HTTP-date, not always one

When you hit the Dev.to rate limit, the API returns 429 with a Retry-After header. The docs say to respect this header and wait before retrying. What they don't make explicit: the value is sometimes an integer number of seconds, and sometimes a full RFC 7231 HTTP-date string like Fri, 31 Jul 2026 07:00:00 GMT.

My first implementation did parseInt(header, 10). That works fine when the header is "30". It returns NaN when the header is "Fri, 31 Jul 2026 07:00:00 GMT" — and NaN * 1000 produces NaN, so the retry logic fell through to a zero delay. The pipeline retried immediately, got another 429, and looped until the job timed out.

The fix handles both:

export function parseRetryAfterMs(header: string | null, fallbackMs = 30_000): number {
  if (!header) return fallbackMs;
  const secs = Number(header);
  if (Number.isFinite(secs) && secs > 0) return secs * 1000;
  const date = Date.parse(header);
  if (Number.isFinite(date)) {
    const ms = date - Date.now();
    if (ms > 0) return ms;
  }
  return fallbackMs;
}
Enter fullscreen mode Exit fullscreen mode

Try parsing as a number first. If that gives a finite positive value, treat it as seconds. Otherwise try Date.parse and compute milliseconds from now. If both fail — malformed header, already-past date — fall back to 30 seconds, which matches Dev.to's documented cool-off period.

In practice I've seen the seconds format on article creation rate limits and the HTTP-date format on bulk-operation limits. The exact boundary isn't documented.

Tag sanitization removes hyphens — silently

Dev.to stores tags as lowercase alphanumeric slugs. A tag like machine-learning gets stored as machinelearning. A tag like TypeScript gets stored as typescript. There's no error, no warning — the tag is just mutated before storage.

This matters if your article frontmatter specifies tags that look valid but contain hyphens or mixed case. You ship ["machine-learning", "GitHub", "open-source"] and the stored tags are ["machinelearning", "github", "opensource"]. The analytics you track by tag name won't match what you sent.

I sanitize before sending:

export function sanitizeTags(tags: string[]): string[] {
  return tags
    .map((t) => t.replace(/[^a-z0-9]/gi, "").toLowerCase())
    .filter((t) => t.length > 0)
    .slice(0, 4);
}
Enter fullscreen mode Exit fullscreen mode

The .filter((t) => t.length > 0) matters: a tag that is only special characters like c++ becomes an empty string after the replace, and you don't want to send empty strings to the API. .slice(0, 4) enforces Dev.to's four-tag limit before the request goes out — sending five tags doesn't error, it just silently truncates. I'd rather the truncation happen predictably in my code than unpredictably in the API.

The practical consequence: don't use hyphens in tags in article frontmatter. indiehackers not indie-hackers. machinelearning not machine-learning. Three Hashnode API behaviors that differ from Dev.to include tag handling — Hashnode is more permissive about tag names but applies its own transformations. Each platform normalizes independently.

Authentication uses api-key, not Authorization: Bearer

Dev.to uses a custom api-key header, not the Authorization: Bearer <token> pattern that most modern APIs follow. Most HTTP client wrappers and SDKs that default to Bearer will either break silently or return 401s.

const res = await fetch(`${API}/articles`, {
  method: "POST",
  headers: {
    "api-key": apiKey,
    "content-type": "application/json",
    accept: "application/vnd.forem.api-v1+json",
    "user-agent": "seo-farm-publisher/0.0.0",
  },
  body: JSON.stringify(payload),
});
Enter fullscreen mode Exit fullscreen mode

Two headers worth calling out beyond api-key:

accept: "application/vnd.forem.api-v1+json": The API returns different response shapes depending on the Accept header. Without this, you get the v0 shape which has different field names. The v1 shape is what the current docs describe.

user-agent: Not required but useful for debugging. If you're watching server logs or building something that might generate traffic, a named user-agent makes it easier to filter your own requests. Dev.to doesn't reject anonymous requests — it's just good practice.

The cross-publish pipeline token expiry incident I wrote about earlier involved a rotated api-key that wasn't updated in the GitHub Actions secret. The symptom was 401s on every create call, which looked identical to a bad header name. It's worth verifying the api-key value independently before debugging the header format.

What I watch for now

When I add a new publish target or change something in the article creation flow, I check three things:

  • Does the retry path actually wait? Add a log line before the sleep so I can confirm the delay fired.
  • Do the stored tags match what I sent? Pull the article back via GET /api/articles/:id and compare.
  • Is the API key fresh? The pipeline logs a 401 as a structured error so I can distinguish it from a 403 (wrong permissions) or a 429 (rate limit).

None of these behaviors are bugs — they're documented or at least consistent. The friction is that they aren't highlighted upfront, and each one only surfaces when the pipeline runs at volume.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)