{"id":1904,"date":"2026-09-24T05:11:00","date_gmt":"2026-09-24T12:11:00","guid":{"rendered":"https:\/\/www.kenwalger.com\/blog\/?p=1904"},"modified":"2026-09-17T10:22:50","modified_gmt":"2026-09-17T17:22:50","slug":"dev-to-api-author-analytics-python","status":"publish","type":"post","link":"https:\/\/www.kenwalger.com\/blog\/developer-relations\/dev-to-api-author-analytics-python\/","title":{"rendered":"I Pulled Nine Years of My Own Dev.to Data. The Numbers Were Not What I Expected."},"content":{"rendered":"<p><em>There is an API. It will tell you things about your writing that the dashboard will not.<\/em><\/p>\n<p>I have been publishing on Dev.to since April 2017. There is a four-year hole in the middle where I posted almost nothing, and then a return in March 2026 that has produced eighty-six posts in six months.<\/p>\n<p>That shape turns out to be useful. Two distinct bodies of work, on the same account, separated by a gap long enough that the platform itself changed underneath them. A natural experiment I did not set out to run.<\/p>\n<p>What I wanted was simple: a chart of follower growth with my article publication dates overlaid, to see whether particular posts moved the line. What I got instead was a fairly uncomfortable education in which of my numbers mean anything.<\/p>\n<p>The API made all of it possible, and almost nobody seems to use it.<\/p>\n<h2>Yes, There Is an API<\/h2>\n<p>Base URL is <code>https:\/\/dev.to\/api`. You generate a key at **Settings \u2192 Extensions \u2192 DEV Community API Keys**. Most read endpoints for your own data want that key in an<\/code>api-key` header.<\/p>\n<p>There is one detail that will silently ruin your afternoon:<\/p>\n<pre><code class=\"language-python\">headers = {\n    \"api-key\": key,\n    # Omit this and you get v0 responses. No error. No warning.\n    \"accept\": \"application\/vnd.forem.api-v1+json\",\n    \"user-agent\": \"my-analytics-script\/1.0\",\n}\n<\/code><\/pre>\n<p>Without the <code>accept<\/code> header you are served the older v0 serializer. Nothing fails. The shapes are just quietly different, and you will spend twenty minutes wondering why a documented field is missing.<\/p>\n<h2>Trick One: The Default Page Size Is Not the Maximum<\/h2>\n<p>The followers endpoint is documented as returning 80 per page. I have a little over eighteen thousand followers, which is 227 round trips.<\/p>\n<p>The v1 pagination range actually runs to 1000. The 80 is a default, not a ceiling:<\/p>\n<pre><code class=\"language-python\">batch = client.get(\n    f\"{API_ROOT}\/followers\/users\",\n    params={\"page\": page, \"per_page\": 1000},\n).json()\n<\/code><\/pre>\n<p>Nineteen requests instead of 227. Thirty-one seconds instead of however long 227 polite requests would have taken.<\/p>\n<p>A Forem instance can cap <code>per_page<\/code> lower through an environment variable, so do not hardcode the assumption. Ask for the maximum, then learn the real stride from what comes back:<\/p>\n<pre><code class=\"language-python\">if page == 1 and len(batch) &lt; requested:\n    # Either the server capped us or that is the entire list.\n    # Either way, this is the real page size.\n    page_size = len(batch)\n<\/code><\/pre>\n<p>Rate limiting is real and it is not gentle. Back off on 429, sleep between pages, and checkpoint your merged results to disk every few pages. A long first pull that dies on page 190 should not discard the previous 189.<\/p>\n<h2>Trick Two: Follower Dates Reconstruct History You Never Recorded<\/h2>\n<p>This is the single most useful thing in the API and it is easy to miss.<\/p>\n<p><code>\/api\/followers\/users<\/code> returns a <code>created_at<\/code> on every follower: the date that person started following you. You do not need to snapshot your follower count daily and wait six months to accumulate a time series. One pull reconstructs the entire curve retroactively, back to your first follower.<\/p>\n<pre><code class=\"language-python\">from collections import Counter\nfrom datetime import date, timedelta\n\ndates = sorted(\n    date.fromisoformat(f[\"created_at\"][:10]) for f in followers\n)\ncumulative = list(range(1, len(dates) + 1))  # the growth curve, free\n\nweekly = Counter(d - timedelta(days=d.weekday()) for d in dates)\n<\/code><\/pre>\n<p><img decoding=\"async\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/uploads\/2026\/09\/followers.png\" alt=\"Line chart of cumulative Dev.to followers from 2017 to 2026. The line is nearly flat until March 2026, then rises steeply to about 18,000 by September. Dotted vertical markers show article publication dates. A dashed second line excluding auto-generated usernames tracks noticeably lower\" \/><\/p>\n<p>There is a catch worth stating plainly, because it took me a moment to see it. You only receive people who <em>currently<\/em> follow you. Anyone who followed and later unfollowed has vanished from the dataset. So the curve is &#8220;current followers by acquisition date,&#8221; not your follower count as it stood on any given day. It increases monotonically by construction and can never show you a decline that actually happened.<\/p>\n<p>For working out which posts drove acquisition, fine. For anything about retention, actively misleading.<\/p>\n<h2>Trick Three: The Analytics Endpoints Exist and Are Barely Documented<\/h2>\n<p>There is a whole analytics family that most third-party tooling ignores:<\/p>\n<pre><code class=\"language-text\">\/api\/analytics\/totals               lifetime views, reactions, comments\n\/api\/analytics\/historical           daily series over a date range\n\/api\/analytics\/past_day             hourly, last 24 hours\n\/api\/analytics\/referrers            where the traffic came from\n\/api\/analytics\/follower_engagement  follower growth over time\n\/api\/analytics\/dashboard            totals + history + top posts, bundled\n<\/code><\/pre>\n<p>All accept <code>article_id<\/code> to scope to a single post. Two things to know.<\/p>\n<p>The responses nest. They are not flat integers:<\/p>\n<pre><code class=\"language-json\">{\"page_views\": {\"total\": 246454, \"average_read_time_in_seconds\": 306}}\n<\/code><\/pre>\n<p>And the historical data degrades as you go back. For my 2017 posts the endpoint returns weekly buckets rather than daily rows, and in aggregate accounts for only about 15% of those posts&#8217; lifetime views. For 2019 it covers 95%. For 2026, 100%.<\/p>\n<p>That matters more than it sounds. I computed a &#8220;half-life&#8221; for each post, meaning days from publication until it had earned half its views to date. My first attempt confidently reported that several 2017 posts had half-lives around 3,000 days. They do not. The endpoint simply does not remember most of what those posts earned, and dividing a remembered fraction produces a precise, authoritative, meaningless number.<\/p>\n<p>The fix is a coverage gate:<\/p>\n<pre><code class=\"language-python\">lifetime = article[\"page_views_count\"]\ntracked = sum(daily_series.values())\n\n# A series accounting for 15% of a post's views will still yield a\n# confident half-life. It will be an artifact of what the endpoint\n# retained, not of how the post aged.\nif lifetime and tracked &lt; lifetime * 0.8:\n    continue\n<\/code><\/pre>\n<p>Eighty-two of my 130 posts survive that gate. The median half-life among them is four days.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/uploads\/2026\/09\/post-half-life.png\" alt=\"Line chart showing cumulative percentage of views earned against days since publication, for twelve posts. Most curves rise almost vertically in the first few days and then flatten. A dotted horizontal line marks the fifty percent level, which most curves cross within a week.\" \/><\/p>\n<h2>Trick Four: Some Metadata Is in the Markdown, Not the JSON<\/h2>\n<p>I write multi-part series. None of my posts came back with a <code>collection_id<\/code>, which is the field you would reach for to group them.<\/p>\n<p>The series are right there in the Dev.to UI. The article serializer just does not include the field.<\/p>\n<p>But <code>\/api\/articles\/me\/published<\/code> returns <code>body_markdown<\/code>, front matter and all, and the series name is sitting in it:<\/p>\n<pre><code class=\"language-python\">FRONT_MATTER_SERIES = re.compile(r\"^series:\\s*(.+?)\\s*$\", re.M)\n\ndef series_name(article):\n    body = article.get(\"body_markdown\") or \"\"\n    if not body.lstrip().startswith(\"---\"):\n        return None\n    parts = body.split(\"---\", 2)\n    if len(parts) &lt; 3:\n        return None\n    match = FRONT_MATTER_SERIES.search(parts[1])\n    return match.group(1).strip().strip(\"\\\"'\") if match else None\n<\/code><\/pre>\n<p>General lesson: when a field you expect is absent from the JSON, check whether the source document came back too. It often did.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/uploads\/2026\/09\/series-dropoff.png\" alt=\"Line chart plotting lifetime views against part number for five article series. Every line except one descends from part one to part three, losing roughly half to two thirds of its readers.\" \/><\/p>\n<h2>Trick Five: Comments Arrive Pre-Threaded<\/h2>\n<p><code>\/api\/comments?a_id={id}<\/code> returns comments as a tree, with each comment&#8217;s replies nested in <code>children<\/code>. The structure is doing analytical work for free, and flattening it while preserving depth takes about eight lines:<\/p>\n<pre><code class=\"language-python\">def flatten(nodes, depth=0):\n    out = []\n    for node in nodes or []:\n        out.append({\n            \"depth\": depth,\n            \"username\": (node.get(\"user\") or {}).get(\"username\"),\n            \"created_at\": node.get(\"created_at\"),\n            \"text\": strip_html(node.get(\"body_html\")),\n        })\n        out.extend(flatten(node.get(\"children\") or [], depth + 1))\n    return out\n<\/code><\/pre>\n<p>Depth is the metric that matters. Comment <em>count<\/em> cannot distinguish eight people each saying &#8220;great article&#8221; from two people arguing with you for four rounds. Maximum thread depth can. One of my posts has a thread 35 levels deep. That is not a comment section, it is a sustained argument, and no count-based metric would have told me it happened.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/uploads\/2026\/09\/applause-vs-argument.png\" alt=\"Scatter plot with comment count on the horizontal axis and maximum thread depth on the vertical. Most points cluster low and left. A few outliers sit high on the vertical axis, indicating deep back-and-forth threads rather than many separate comments.\" \/><\/p>\n<p>There is no endpoint for <em>creating<\/em> comments, incidentally. Replying still requires the browser. Probably deliberate.<\/p>\n<h2>What the Data Actually Said<\/h2>\n<p>Here is where the exercise stopped being a programming problem.<\/p>\n<p><strong>My follower count is not a readership number.<\/strong> I gained roughly 18,000 followers in 2026. My 2026 posts have 14,300 total views between them. You cannot acquire eighteen thousand followers from fourteen thousand views. The daily rate sits at a median of 136 with no meaningful response to whether I published anything, and about 37% of the usernames carry auto-generated-looking hex or numeric tails. That is reciprocal-follow farming, it is endemic, and it has nothing to do with me. It does mean the chart I originally set out to build could never have answered the question I was asking.<\/p>\n<p><strong>My most-viewed work is nine years old and no longer being read.<\/strong> My 2017 output was MicroPython, NodeMCU, and MongoDB tutorials. Forty-four posts from 2017 to 2019 pulled 32,474 views. Eighty-six posts in 2026 have pulled 14,300. But the daily series tells the other half: my 5,472-view NodeMCU post has had zero views in the last ninety days. Nineteen of my 130 posts are at zero for the quarter. Lifetime counters never decrease, which makes an archive look alive long after it has stopped breathing.<\/p>\n<p><strong>The engagement numbers invert completely.<\/strong> Those big old tutorials run about 2 reactions per thousand views. My 2026 essays run 56 to 72, with one at 71.6 reactions and 63.8 comments per thousand. Across the eras: 44 old posts drew 26 comments total. 86 new posts have drawn 606.<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/uploads\/2026\/09\/reach-versus-resonance.png\" alt=\"Scatter plot with lifetime views on a logarithmic horizontal axis and reactions per thousand views on the vertical. Older high-traffic posts sit far right and near the bottom. Newer low-traffic posts sit left and high. Red markers indicate posts with no views in the last ninety days.\" \/><\/p>\n<p>So one body of work got found and skimmed. The other gets read and argued with. They are different products, and I had been evaluating both with the same number.<\/p>\n<p><strong>Four percent of my traffic is Google.<\/strong> Direct or unknown is 74.5%. Internal Dev.to is 19%. For someone whose best-performing historical content is evergreen reference material, that is the number I find hardest to look at.<\/p>\n<h2>What I Would Tell Someone Starting This<\/h2>\n<p>Pull the followers once and cache them, because the dates reconstruct years of history you never thought to record. Gate every analytics computation on data coverage, because a partial series will hand you a confident wrong answer rather than an error. Read thread depth instead of comment count. And check <code>body_markdown<\/code> before concluding a field does not exist.<\/p>\n<p>Mostly, though: separate the metrics that measure distribution from the metrics that measure whether anyone cared. Views, follower counts, and impressions are the first kind. Comment depth, reply rates, and the fact that the same four people keep showing up in your threads are the second.<\/p>\n<p>I spent nine years assuming the first kind was the scoreboard. The API took an evening to tell me otherwise.<\/p>\n<a class=\"synved-social-button synved-social-button-share synved-social-size-48 synved-social-resolution-single synved-social-provider-facebook nolightbox\" data-provider=\"facebook\" target=\"_blank\" rel=\"nofollow\" title=\"Share on Facebook\" href=\"https:\/\/www.facebook.com\/sharer.php?u=https%3A%2F%2Fwww.kenwalger.com%2Fblog%2Fwp-json%2Fwp%2Fv2%2Fposts%2F1904&amp;t=I%20Pulled%20Nine%20Years%20of%20My%20Own%20Dev.to%20Data.%20The%20Numbers%20Were%20Not%20What%20I%20Expected.&amp;s=100&amp;p[url]=https%3A%2F%2Fwww.kenwalger.com%2Fblog%2Fwp-json%2Fwp%2Fv2%2Fposts%2F1904&amp;p[images][0]=https%3A%2F%2Fwww.kenwalger.com%2Fblog%2Fwp-content%2Fuploads%2F2026%2F09%2Ffollowers.png&amp;p[title]=I%20Pulled%20Nine%20Years%20of%20My%20Own%20Dev.to%20Data.%20The%20Numbers%20Were%20Not%20What%20I%20Expected.\" style=\"font-size: 0px;width:48px;height:48px;margin:0;margin-bottom:5px;margin-right:5px\"><img loading=\"lazy\" decoding=\"async\" alt=\"Facebook\" title=\"Share on Facebook\" class=\"synved-share-image synved-social-image synved-social-image-share\" width=\"48\" height=\"48\" style=\"display: inline;width:48px;height:48px;margin: 0;padding: 0;border: none;box-shadow: none\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/plugins\/social-media-feather\/synved-social\/image\/social\/regular\/96x96\/facebook.png\" \/><\/a><a class=\"synved-social-button synved-social-button-share synved-social-size-48 synved-social-resolution-single synved-social-provider-twitter nolightbox\" data-provider=\"twitter\" target=\"_blank\" rel=\"nofollow\" title=\"Share on Twitter\" href=\"https:\/\/twitter.com\/intent\/tweet?url=https%3A%2F%2Fwww.kenwalger.com%2Fblog%2Fwp-json%2Fwp%2Fv2%2Fposts%2F1904&amp;text=Hey%20check%20this%20out\" style=\"font-size: 0px;width:48px;height:48px;margin:0;margin-bottom:5px;margin-right:5px\"><img loading=\"lazy\" decoding=\"async\" alt=\"twitter\" title=\"Share on Twitter\" class=\"synved-share-image synved-social-image synved-social-image-share\" width=\"48\" height=\"48\" style=\"display: inline;width:48px;height:48px;margin: 0;padding: 0;border: none;box-shadow: none\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/plugins\/social-media-feather\/synved-social\/image\/social\/regular\/96x96\/twitter.png\" \/><\/a><a class=\"synved-social-button synved-social-button-share synved-social-size-48 synved-social-resolution-single synved-social-provider-reddit nolightbox\" data-provider=\"reddit\" target=\"_blank\" rel=\"nofollow\" title=\"Share on Reddit\" href=\"https:\/\/www.reddit.com\/submit?url=https%3A%2F%2Fwww.kenwalger.com%2Fblog%2Fwp-json%2Fwp%2Fv2%2Fposts%2F1904&amp;title=I%20Pulled%20Nine%20Years%20of%20My%20Own%20Dev.to%20Data.%20The%20Numbers%20Were%20Not%20What%20I%20Expected.\" style=\"font-size: 0px;width:48px;height:48px;margin:0;margin-bottom:5px;margin-right:5px\"><img loading=\"lazy\" decoding=\"async\" alt=\"reddit\" title=\"Share on Reddit\" class=\"synved-share-image synved-social-image synved-social-image-share\" width=\"48\" height=\"48\" style=\"display: inline;width:48px;height:48px;margin: 0;padding: 0;border: none;box-shadow: none\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/plugins\/social-media-feather\/synved-social\/image\/social\/regular\/96x96\/reddit.png\" \/><\/a><a class=\"synved-social-button synved-social-button-share synved-social-size-48 synved-social-resolution-single synved-social-provider-linkedin nolightbox\" data-provider=\"linkedin\" target=\"_blank\" rel=\"nofollow\" title=\"Share on Linkedin\" href=\"https:\/\/www.linkedin.com\/shareArticle?mini=true&amp;url=https%3A%2F%2Fwww.kenwalger.com%2Fblog%2Fwp-json%2Fwp%2Fv2%2Fposts%2F1904&amp;title=I%20Pulled%20Nine%20Years%20of%20My%20Own%20Dev.to%20Data.%20The%20Numbers%20Were%20Not%20What%20I%20Expected.\" style=\"font-size: 0px;width:48px;height:48px;margin:0;margin-bottom:5px;margin-right:5px\"><img loading=\"lazy\" decoding=\"async\" alt=\"linkedin\" title=\"Share on Linkedin\" class=\"synved-share-image synved-social-image synved-social-image-share\" width=\"48\" height=\"48\" style=\"display: inline;width:48px;height:48px;margin: 0;padding: 0;border: none;box-shadow: none\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/plugins\/social-media-feather\/synved-social\/image\/social\/regular\/96x96\/linkedin.png\" \/><\/a><a class=\"synved-social-button synved-social-button-share synved-social-size-48 synved-social-resolution-single synved-social-provider-mail nolightbox\" data-provider=\"mail\" rel=\"nofollow\" title=\"Share by email\" href=\"mailto:?subject=I%20Pulled%20Nine%20Years%20of%20My%20Own%20Dev.to%20Data.%20The%20Numbers%20Were%20Not%20What%20I%20Expected.&amp;body=Hey%20check%20this%20out:%20https%3A%2F%2Fwww.kenwalger.com%2Fblog%2Fwp-json%2Fwp%2Fv2%2Fposts%2F1904\" style=\"font-size: 0px;width:48px;height:48px;margin:0;margin-bottom:5px\"><img loading=\"lazy\" decoding=\"async\" alt=\"mail\" title=\"Share by email\" class=\"synved-share-image synved-social-image synved-social-image-share\" width=\"48\" height=\"48\" style=\"display: inline;width:48px;height:48px;margin: 0;padding: 0;border: none;box-shadow: none\" src=\"https:\/\/www.kenwalger.com\/blog\/wp-content\/plugins\/social-media-feather\/synved-social\/image\/social\/regular\/96x96\/mail.png\" \/><\/a>","protected":false},"excerpt":{"rendered":"<p>There is an API. It will tell you things about your writing that the dashboard will not. I have been publishing on Dev.to since April 2017. There is a four-year hole in the middle where I posted almost nothing, and then a return in March 2026 that has produced eighty-six posts in six months. That &hellip; <a href=\"https:\/\/www.kenwalger.com\/blog\/developer-relations\/dev-to-api-author-analytics-python\/\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;I Pulled Nine Years of My Own Dev.to Data. The Numbers Were Not What I Expected.&#8221;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"pmpro_default_level":"","_monsterinsights_skip_tracking":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1950],"tags":[81,1951,1401,1885,78,1952],"yst_prominent_words":[],"class_list":["post-1904","post","type-post","status-publish","format-standard","hentry","category-developer-relations","tag-api","tag-content-analytics","tag-data-visualization","tag-developer-relations","tag-python","tag-technical-writing","pmpro-has-access"],"jetpack_sharing_enabled":true,"jetpack_shortlink":"https:\/\/wp.me\/p8lx70-uI","jetpack-related-posts":[],"jetpack_featured_media_url":"","_links":{"self":[{"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/posts\/1904","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/comments?post=1904"}],"version-history":[{"count":2,"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/posts\/1904\/revisions"}],"predecessor-version":[{"id":1907,"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/posts\/1904\/revisions\/1907"}],"wp:attachment":[{"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/media?parent=1904"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/categories?post=1904"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/tags?post=1904"},{"taxonomy":"yst_prominent_words","embeddable":true,"href":"https:\/\/www.kenwalger.com\/blog\/wp-json\/wp\/v2\/yst_prominent_words?post=1904"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}