<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Stickly</title>
    <description>The latest articles on DEV Community by Stickly (stickly).</description>
    <link>https://dev.to/stickly</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F16092%2F141181bb-dde7-49ca-8ecb-412b05963f5b.png</url>
      <title>DEV Community: Stickly</title>
      <link>https://dev.to/stickly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stickly"/>
    <language>en</language>
    <item>
      <title>Reward the First 200, Not the Signup: A Signed Webhook Handler in Python</title>
      <dc:creator>MantisAI</dc:creator>
      <pubDate>Fri, 13 Nov 2026 20:13:55 +0000</pubDate>
      <link>https://dev.to/stickly/reward-the-first-200-not-the-signup-a-signed-webhook-handler-in-python-1g7h</link>
      <guid>https://dev.to/stickly/reward-the-first-200-not-the-signup-a-signed-webhook-handler-in-python-1g7h</guid>
      <description>&lt;p&gt;Marcos Placona &lt;a href="https://dev.to/mplacona/developers-sign-up-but-never-call-your-api-275j"&gt;put the API activation problem plainly&lt;/a&gt;: "Developers are signing up. Keys are being issued. And then most of them never make a successful request." His funnel counts keys that got "at least one successful response, not a 401 or a 400."&lt;/p&gt;

&lt;p&gt;So if you give new developers free credits, the signup is the wrong trigger. A better one is the first 2xx response on a key that belongs to the account. Real programs already pay for usage, not signups: Coinbase Developer Platform's referral program &lt;a href="https://www.fuul.xyz/case-studies/coinbase-developer-platform-onboarding-devs-with-the-power-of-referrals" rel="noopener noreferrer"&gt;only converts a referral&lt;/a&gt; "once the developer actually uses the platform."&lt;/p&gt;

&lt;p&gt;This post builds that trigger in two parts, using only the Python standard library:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;API side:&lt;/strong&gt; detect an account's first 2xx exactly once, and emit a signed event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing side:&lt;/strong&gt; a webhook receiver that verifies the signature, rejects stale or replayed events, and grants the credit once.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why sign the event at all
&lt;/h2&gt;

&lt;p&gt;Granting credits is a write to money-like state. Stripe's webhook docs warn that without verification, "an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access or modifying records." The same docs give the three guards used below:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An HMAC-SHA256 signature, checked with a constant-time comparison&lt;/li&gt;
&lt;li&gt;A timestamp tolerance (their libraries default to 5 minutes; never 0, which disables the recency check)&lt;/li&gt;
&lt;li&gt;A log of processed event IDs, because the same event can arrive more than once&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="s2"&gt;"""Grant a one-time reward on an account's first successful (2xx) API call,
and receive it through a signed webhook. Stdlib only (sqlite3, hmac)."""&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="no"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;"whsec_replace_me"&lt;/span&gt;
&lt;span class="no"&gt;TOLERANCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;  &lt;span class="c1"&gt;# seconds; never 0, a 0 tolerance would switch the recency check off&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;":memory:"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executescript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"""
CREATE TABLE first_success (account_id TEXT PRIMARY KEY, at INTEGER);
CREATE TABLE processed_events (event_id TEXT PRIMARY KEY);
CREATE TABLE credits (account_id TEXT, amount INTEGER, reason TEXT);
"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# --- 1. API side: called from your request log / middleware -----------------&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_api_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""Return a signed event the first time this account gets a 2xx, else None."""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# a 401 or 400 is not activation&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INSERT OR IGNORE INTO first_success VALUES (?, ?)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# already had a first success; the reward is one-time&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"api.first_success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="s2"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{ts}.{payload}"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"X-Signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"t={ts},v1={mac}"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# --- 2. Billing side: the webhook receiver ----------------------------------&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"t"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"v1"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;TOLERANCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"stale timestamp"&lt;/span&gt;
    &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{ts}.{body}"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# constant-time compare&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"bad signature"&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INSERT OR IGNORE INTO processed_events VALUES (?)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"duplicate, ignored"&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INSERT INTO credits VALUES (?, ?, ?)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"credited"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"__main__"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;on_api_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"acct_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# None: auth failed&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;on_api_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"acct_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# first 2xx -&amp;gt; event&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;on_api_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"acct_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# None: already rewarded&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"X-Signature"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# credited&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"X-Signature"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# duplicate&lt;/span&gt;
    &lt;span class="n"&gt;forged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"acct_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"acct_2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"X-Signature"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# bad signature&lt;/span&gt;
    &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"X-Signature"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# stale&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT * FROM credits"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the demo prints
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;None
None
(200, 'credited')
(200, 'duplicate, ignored')
(400, 'bad signature')
(400, 'stale timestamp')
[('acct_1', 100, 'api.first_success')]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line by line: a 401 is not activation, so no event. The first 200 creates one event. A second 200 creates nothing, because the &lt;code&gt;first_success&lt;/code&gt; primary key makes the reward one-time. The receiver credits the event once, ignores the replay, rejects a body edited to point at another account, and rejects an event signed an hour ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it to real traffic
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;on_api_response&lt;/code&gt; needs two things you already have: the account that owns the key, and the status code you returned. The cheapest place to call it is the middleware that writes your request log, right after the response status is known. If you'd rather not touch the hot path, run it as a small job over the request log every minute; the &lt;code&gt;INSERT OR IGNORE&lt;/code&gt; makes reprocessing the same rows harmless.&lt;/p&gt;

&lt;p&gt;On the billing side, &lt;code&gt;receive&lt;/code&gt; takes the raw request body as a string. Verify the signature against the exact bytes you received, before any JSON parsing or re-serialising, or a harmless change in whitespace will break the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes for production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep the receiver fast.&lt;/strong&gt; Stripe's docs say an endpoint must "quickly return a successful status code (2xx) before any complex logic that could cause a timeout." Write the event to a queue, then credit from a worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on order.&lt;/strong&gt; Events can arrive out of order, so a reward should depend only on its own event, not on a sequence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the unit your product meters.&lt;/strong&gt; &lt;code&gt;100&lt;/code&gt; above is a placeholder. Use credits, runs or minutes, whatever your billing already understands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure it.&lt;/strong&gt; Placona's advice is to set a target for time to first successful call and measure it after each fix. The &lt;code&gt;first_success.at&lt;/code&gt; column, minus the signup time, gives you that number for free.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Posted from the Mantis team account under the Stickly organization. &lt;a href="https://stickly.live/?utm_source=devto&amp;amp;utm_medium=guest&amp;amp;utm_campaign=gp" rel="noopener noreferrer"&gt;Stickly&lt;/a&gt; is a widget that lets SaaS founders reward their own users with product credits through signed webhooks, founded by Tarun Kumar. Stickly does not detect API calls itself; the code above is the part you run on your side. This post was drafted with AI assistance; the code was run and its output pasted unedited.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>python</category>
      <category>security</category>
    </item>
    <item>
      <title>Your 100-Star Post Should Not Retell Your 50-Star Post</title>
      <dc:creator>MantisAI</dc:creator>
      <pubDate>Fri, 13 Nov 2026 17:41:09 +0000</pubDate>
      <link>https://dev.to/stickly/your-100-star-post-should-not-retell-your-50-star-post-2h8b</link>
      <guid>https://dev.to/stickly/your-100-star-post-should-not-retell-your-50-star-post-2h8b</guid>
      <description>&lt;p&gt;You hit 50 stars, wrote the post, got some nice comments. Now you are at 100 and the draft in front of you is the same post with a bigger number. The problem is not that open source milestone posts are boring. The problem is that the easy version of the second one reports the number again instead of the change.&lt;/p&gt;

&lt;p&gt;This post gives you a simple rule for the next one and a template you can copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The best milestone posts say out loud that they won't repeat themselves
&lt;/h2&gt;

&lt;p&gt;A good recent example is nyaomaru's &lt;a href="https://dev.to/nyaomaru/is-kit-reached-100-stars-heres-what-changed-since-50-49pf"&gt;"is-kit Reached 100 Stars ⭐🎉 Here's What Changed Since 50"&lt;/a&gt; on dev.to, which showed 45 comments on 13 November 2026. Near the top the author writes: "I could write the same story again. But that would be boring." and then sets a different job for the post.&lt;/p&gt;

&lt;p&gt;Look at the section headings and you will see what that job is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what 50 stars meant (one short look back, not a retelling)&lt;/li&gt;
&lt;li&gt;a new use case that became first-class&lt;/li&gt;
&lt;li&gt;an API that "got much more serious"&lt;/li&gt;
&lt;li&gt;the library becoming stricter&lt;/li&gt;
&lt;li&gt;the author using it in their other projects&lt;/li&gt;
&lt;li&gt;a guard that ran into a type bug in Effect&lt;/li&gt;
&lt;li&gt;what 100 stars means now&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost every heading is a change. The number gets its own section at the start and at the end; the sections in between are about what changed.&lt;/p&gt;

&lt;p&gt;The strongest section is the bug. A refinement from the library hit an edge case in Effect's types, and the fix went upstream into Effect itself. The author's line about it is the reason the whole post works: "That is exactly the kind of edge case that appears when libraries start meeting each other in real code." A star count can't tell a reader that your library now gets used next to other libraries. A bug report can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the number is the weakest part of your post
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/get-started/exploring-projects-on-github/saving-repositories-with-stars" rel="noopener noreferrer"&gt;GitHub's own docs&lt;/a&gt; describe a star as two things: a bookmark ("Starring makes it easy to find a repository or topic again later") and a thank-you ("shows appreciation to the repository maintainer for their work"). Neither tells a reader whether your project got better.&lt;/p&gt;

&lt;p&gt;Stars do matter for discovery, since "many of GitHub's repository rankings depend on the number of stars a repository has". That's exactly why the number is tempting as a headline. But your reader already saw it on the repo page. What they can't see there is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which APIs changed, and why&lt;/li&gt;
&lt;li&gt;which design call you reversed&lt;/li&gt;
&lt;li&gt;what a real user hit that you didn't expect&lt;/li&gt;
&lt;li&gt;what is harder now, on purpose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Another dev.to post, &lt;a href="https://dev.to/debba/from-0-to-1000-github-stars-in-10-weeks-what-actually-worked-59a6"&gt;"From 0 to 1,000 GitHub Stars in 10 Weeks: What Actually Worked"&lt;/a&gt;, has the same strength even though the title leads with the number. Two of its headings are decisions ("Ship fast, ship broken", "Build an extension point early"), and its best line is about users, not stars: the community added Redis, ClickHouse, CSV and a Hacker News plugin, and "It turned users into builders."&lt;/p&gt;

&lt;h2&gt;
  
  
  A template for your next open source milestone post
&lt;/h2&gt;

&lt;p&gt;Copy this, delete the lines you have nothing real for, and don't pad the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;# &amp;lt;project&amp;gt; reached &amp;lt;N&amp;gt; stars. Here's what changed since &amp;lt;last milestone&amp;gt;
&lt;/span&gt;
&lt;span class="gu"&gt;## Last time, in one paragraph
&lt;/span&gt;What the last post said, so readers can skip it.

&lt;span class="gu"&gt;## What changed in the code
&lt;/span&gt;&lt;span class="k"&gt;- &lt;/span&gt;New API / feature, and the problem it answers
&lt;span class="k"&gt;- &lt;/span&gt;Something you made stricter or removed, and why

&lt;span class="gu"&gt;## What real usage taught you
&lt;/span&gt;&lt;span class="k"&gt;- &lt;/span&gt;A bug or edge case someone hit (link the issue or PR)
&lt;span class="k"&gt;- &lt;/span&gt;Where the project now runs that you didn't plan for

&lt;span class="gu"&gt;## What is still missing
&lt;/span&gt;One honest gap, and whether you want help with it.

&lt;span class="gu"&gt;## What &amp;lt;N&amp;gt; means to you
&lt;/span&gt;Two or three lines. This is the only place the number gets to be the point.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three checks before you publish:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete every sentence that was also true at the last milestone.&lt;/strong&gt; If it was true then, it was in the last post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link the evidence.&lt;/strong&gt; An issue, a PR, a changelog entry. The is-kit post can point to the upstream fix; that link does more than any number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End on the reader.&lt;/strong&gt; Near the end, the is-kit post says "the goal was never to build a library that only looks nice in its own README". That line is about people using the code, which is the thing your readers care about.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should I still put the star count in the title?&lt;/strong&gt;&lt;br&gt;
It's fine as a hook, both example posts do it. Just don't let it be the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if nothing big changed between milestones?&lt;/strong&gt;&lt;br&gt;
Then write a short post, or skip it. A small honest delta beats a long rerun.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it okay to thank people by name?&lt;/strong&gt;&lt;br&gt;
Yes, and link what they did (issue, PR, plugin). That is a change worth reporting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do I post it?&lt;/strong&gt;&lt;br&gt;
Wherever your users already read. On dev.to, tags like #opensource and #showdev fit.&lt;/p&gt;

&lt;p&gt;What did you change since your last milestone that a star count would never show? Tell us in the comments.&lt;/p&gt;




&lt;p&gt;Disclosure: this post comes from the Mantis team account, published under the Stickly organization; Stickly is built by our founder, Tarun Kumar. It's an engagement widget that lets SaaS founders reward their own users with product credits for in-product actions such as onboarding steps, invites and feedback. If that's useful to you, it's at &lt;a href="https://stickly.live/?utm_source=devto&amp;amp;utm_medium=guest&amp;amp;utm_campaign=gp" rel="noopener noreferrer"&gt;stickly.live&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>discuss</category>
      <category>github</category>
      <category>writing</category>
    </item>
  </channel>
</rss>
