<?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>Forem: foxck016077</title>
    <description>The latest articles on Forem by foxck016077 (@foxck016077).</description>
    <link>https://forem.com/foxck016077</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3934070%2F719d233b-fccf-473b-ab80-941f00a2de88.png</url>
      <title>Forem: foxck016077</title>
      <link>https://forem.com/foxck016077</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/foxck016077"/>
    <language>en</language>
    <item>
      <title>Apify Actor pricing patterns: Free tier + Pro + Pay-per-result — designing for indie buyers</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sun, 17 May 2026 01:30:52 +0000</pubDate>
      <link>https://forem.com/foxck016077/apify-actor-pricing-patterns-free-tier-pro-pay-per-result-designing-for-indie-buyers-4e4l</link>
      <guid>https://forem.com/foxck016077/apify-actor-pricing-patterns-free-tier-pro-pay-per-result-designing-for-indie-buyers-4e4l</guid>
      <description>&lt;p&gt;Most pricing conversations start with "how much per month." For a small Actor product, that's the wrong starting question. The better one: &lt;strong&gt;what kind of risk is the buyer trading for money?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Indie buyers care about three risks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding risk&lt;/strong&gt; — will I lose an afternoon and still not get it working?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost risk&lt;/strong&gt; — will the monthly bill stay predictable, or surprise me?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switching risk&lt;/strong&gt; — do I have to rebuild a workflow I already trust?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So my default shape for an Actor is &lt;strong&gt;Free tier + Pro + Pay-per-result&lt;/strong&gt;. Not because three tiers look professional — because each one absorbs one of those risks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free tier is trust validation, not generosity
&lt;/h2&gt;

&lt;p&gt;Free is not "I'll give you a taste." Free is "I'll let you prove to yourself this thing works on your data, end-to-end, before you give me money."&lt;/p&gt;

&lt;p&gt;If your free tier doesn't support a real decision — if it only shows demo data, or caps so aggressively you can't reach the moment of insight — your upgrade rate will be terrible. People will assume the paid version is the same friction, with a price tag.&lt;/p&gt;

&lt;p&gt;Concrete signal: a buyer should be able to run the &lt;em&gt;actual&lt;/em&gt; core path on a small slice of their own real workload, and recognize "yes, this is the value."&lt;/p&gt;

&lt;h2&gt;
  
  
  Pro is predictability, not "more buttons"
&lt;/h2&gt;

&lt;p&gt;The least useful Pro page in the world is a feature-comparison table where the Pro column has more checkmarks. Small-team buyers don't pay for checkmarks. They pay to remove uncertainty.&lt;/p&gt;

&lt;p&gt;The sustainable Pro pitch is &lt;em&gt;stability&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Predictable monthly quota that won't get squeezed by a noisy neighbor&lt;/li&gt;
&lt;li&gt;Critical features (the ones their workflow now depends on) won't degrade under peak load&lt;/li&gt;
&lt;li&gt;Less friction when a teammate needs the same workflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's what "Pro" actually means to a 3-person agency or a solo freelancer running a real practice on top of your tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pay-per-result is for the elastic edge
&lt;/h2&gt;

&lt;p&gt;Subscription-only pricing punishes both ends of the curve. Heavy occasional users feel cheated by the cap. Light steady users feel they're prepaying for capacity they never use.&lt;/p&gt;

&lt;p&gt;A pay-per-result layer is the release valve: a buyer can sit on Pro for the predictable baseline, and spike on top with metered usage during a busy month. Both shapes get to convert.&lt;/p&gt;

&lt;p&gt;But this only works if &lt;strong&gt;what counts as one "result" is unambiguous&lt;/strong&gt;, and your system can identify it reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing is a product of architecture
&lt;/h2&gt;

&lt;p&gt;Here's the part I underestimated on v0: pricing and architecture cannot be designed independently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Routing layer&lt;/strong&gt; needs to know which feature was invoked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quota layer&lt;/strong&gt; needs tenant + month + feature granularity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result layer&lt;/strong&gt; has to emit countable, attributable events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability layer&lt;/strong&gt; has to support the inevitable billing dispute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those four layers is broken or ambiguous, the billing model collapses the first time someone disputes a charge. So design the meter first, then attach numbers.&lt;/p&gt;

&lt;p&gt;(I wrote up the quota layer specifically in &lt;a href="https://dev.to/foxck016077/per-feature-quota-in-apify-keyvaluestore-no-db-no-cron-no-drift-36p4"&gt;an earlier post in this series&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What "good pricing" actually means
&lt;/h2&gt;

&lt;p&gt;Good pricing isn't extracting one more dollar per transaction. Good pricing is &lt;strong&gt;losing one fewer buyer at each step of trust&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free tier — they don't bounce on the install&lt;/li&gt;
&lt;li&gt;Pro — they don't churn at month two when "cool" wears off and only "useful" remains&lt;/li&gt;
&lt;li&gt;Pay-per-result — they don't switch tools the first time their usage spikes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the indie-buyer growth path, and it's the one a 3-tier shape is designed for. The trick isn't the three boxes — it's making sure each box matches the risk that buyer is actually carrying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;p&gt;If you're productizing your own content / operations workflow and need a starting template that already separates the "metering" layer from the "delivery" layer, the &lt;strong&gt;&lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;AI Content Pipeline&lt;/a&gt;&lt;/strong&gt; bundle is built on the same per-feature counting pattern.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Series index: &lt;a href="https://dev.to/foxck016077/series/39719"&gt;https://dev.to/foxck016077/series/39719&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>saas</category>
      <category>productivity</category>
      <category>architecture</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Open-sourcing an MIT Apify Actor in 24 hours — a build log</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sat, 16 May 2026 07:59:09 +0000</pubDate>
      <link>https://forem.com/foxck016077/open-sourcing-an-mit-apify-actor-in-24-hours-a-build-log-53km</link>
      <guid>https://forem.com/foxck016077/open-sourcing-an-mit-apify-actor-in-24-hours-a-build-log-53km</guid>
      <description>&lt;p&gt;This is a 24-hour open-source log, not a myth. The goal was never "one-shot perfection." The goal was a &lt;strong&gt;maintainable v1&lt;/strong&gt; — something someone else could read, run, and extend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: cut scope
&lt;/h2&gt;

&lt;p&gt;I kept four things, and four only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A working main path&lt;/li&gt;
&lt;li&gt;Clear feature boundaries&lt;/li&gt;
&lt;li&gt;Basic async tests&lt;/li&gt;
&lt;li&gt;Readable docs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything that would slow delivery without affecting first-version usability — out. Not deprioritized, not "later." Out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hours 0–6: execution path first
&lt;/h2&gt;

&lt;p&gt;Make input → output traceable. Don't reach for clean abstractions yet. Early abstraction usually just hides bugs under nicer-looking surfaces. I'd rather have ugly, traceable code than tidy code I can't reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hours 6–10: OAuth timing
&lt;/h2&gt;

&lt;p&gt;This was the real puzzle. Which errors should &lt;code&gt;fail fast&lt;/code&gt;? Which should prompt the user toward a recovery action? Which states should I leave intact for a retry to pick up?&lt;/p&gt;

&lt;p&gt;Working through this told me something I wasn't expecting: &lt;strong&gt;authorization is not a side feature. It's the usability foundation.&lt;/strong&gt; If the auth flow is sloppy, nothing downstream feels reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hours 10–14: quota lives in the main flow
&lt;/h2&gt;

&lt;p&gt;Quota does not get bolted on at the end. If you add metering late, it almost always drags pricing logic and error semantics into the rewrite with it.&lt;/p&gt;

&lt;p&gt;So I put a small &lt;code&gt;KeyValueStore&lt;/code&gt;-backed per-feature counter directly in the request path. No DB, no cron. (Wrote it up separately as part of this same series.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Hours 14–18: the unglamorous cleanup
&lt;/h2&gt;

&lt;p&gt;Async test coverage on the happy path and the obvious failure modes. Exception paths. Naming boundaries that won't bite a future contributor. None of this is exciting. All of it prevents the first wave of post-launch issues from being existential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hours 18–21: README + MIT license
&lt;/h2&gt;

&lt;p&gt;MIT matters for indie builders in a very practical way: it removes the "can I actually use this?" friction. Collaboration is more valuable than control, especially when you have zero stars and need the door wide open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Last 3 hours: stranger's-eye review
&lt;/h2&gt;

&lt;p&gt;Read the repo as if I'd never seen it. Run the project from scratch following only the README. Check that nothing private leaked into commits.&lt;/p&gt;

&lt;p&gt;This is the easiest step to skip and the most reliable way to catch your own blind spots.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd say to a past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed isn't typing fast.&lt;/strong&gt; Speed is deciding fast what &lt;em&gt;not&lt;/em&gt; to build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-source quality isn't zero bugs.&lt;/strong&gt; It's "visible, reproducible, fixable."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The auth layer is the foundation, not a feature.&lt;/strong&gt; Get it right early or pay forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;p&gt;If you want to turn this kind of 24h sprint into a repeatable workflow, I bundled the scope-cutting, commit-cadence, and acceptance templates I used here: &lt;strong&gt;&lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;Claude Code Workflow Pack&lt;/a&gt;&lt;/strong&gt; (pay-what-you-want).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Series index: &lt;a href="https://dev.to/foxck016077/series/39719"&gt;https://dev.to/foxck016077/series/39719&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>buildinpublic</category>
      <category>python</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Per-feature quota in Apify KeyValueStore — no DB, no cron, no drift</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sat, 16 May 2026 07:21:29 +0000</pubDate>
      <link>https://forem.com/foxck016077/per-feature-quota-in-apify-keyvaluestore-no-db-no-cron-no-drift-36p4</link>
      <guid>https://forem.com/foxck016077/per-feature-quota-in-apify-keyvaluestore-no-db-no-cron-no-drift-36p4</guid>
      <description>&lt;p&gt;The reflex when you hear "quota" is to reach for a database plus a cron job. For a small, self-maintained Actor I care more about complexity: the heavier the system, the harder it is to keep stable for months.&lt;/p&gt;

&lt;p&gt;For this project I went with &lt;code&gt;KeyValueStore&lt;/code&gt; plus a month-key. The goals were clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Meter &lt;strong&gt;per feature&lt;/strong&gt;, not just total invocations&lt;/li&gt;
&lt;li&gt;Reset naturally each month — no scheduled job&lt;/li&gt;
&lt;li&gt;Keep the logic in one place so it doesn't drift&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trick is in the key layout. Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;quota:{tenant}:{feature}:2026-05
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a new month starts, you write to a new key. You don't wipe the old one. "Reset" becomes a naming switch, not a data migration. No month-end task can fail, and you don't have to think about timezone edges either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-feature matters&lt;/strong&gt; because feature cost varies wildly. If you only meter the total, heavy features crowd out light ones, and everyone ends up feeling like the limit is unfair.&lt;/p&gt;

&lt;p&gt;The discipline in &lt;code&gt;quota.py&lt;/code&gt; is small but strict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check the limit before doing the work&lt;/li&gt;
&lt;li&gt;Only record after the operation actually succeeds&lt;/li&gt;
&lt;li&gt;Keep the key format fixed — no variants&lt;/li&gt;
&lt;li&gt;On failure, do not write a guess&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"No drift" isn't an algorithmic property. It's a write-semantics property. The moment your counter writes are scattered across modules — each doing it slightly differently — you've already lost.&lt;/p&gt;

&lt;p&gt;This design is &lt;strong&gt;not&lt;/strong&gt; universal. It fits products with low-to-medium complexity, multi-tenant but with bounded concurrency. If you later outgrow it, swapping in a real metering pipeline is a clean migration, because the surface is small.&lt;/p&gt;

&lt;p&gt;My one piece of advice: get the quota schema right in the first version. Adding it later almost always drags pricing, routing, and error semantics into the rewrite with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;p&gt;If you're building SMB automation services, the &lt;strong&gt;n8n SMB Automation Pack ($29)&lt;/strong&gt; pairs nicely with this pattern — quota, retries, and notifications all live in one operable flow.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gumroad: &lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;https://foxck.gumroad.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Previous notes in this series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/foxck016077/an-apify-actor-for-gmail-inbox-analytics-a-refresh-token-only-oauth-async-router-per-feature-pi2"&gt;An Apify Actor for Gmail inbox analytics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/foxck016077/gmail-oauth-clientid-is-not-a-secret-a-design-notes-for-self-host-actors-19af"&gt;Gmail OAuth client_id is not a secret&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/foxck016077/why-i-picked-refresh-token-only-oauth-for-a-multi-tenant-apify-actor-265c"&gt;Why refresh-token-only OAuth for a multi-tenant Actor&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>python</category>
      <category>saas</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why I picked refresh-token-only OAuth for a multi-tenant Apify Actor</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sat, 16 May 2026 06:02:29 +0000</pubDate>
      <link>https://forem.com/foxck016077/why-i-picked-refresh-token-only-oauth-for-a-multi-tenant-apify-actor-265c</link>
      <guid>https://forem.com/foxck016077/why-i-picked-refresh-token-only-oauth-for-a-multi-tenant-apify-actor-265c</guid>
      <description>&lt;p&gt;Building a Gmail analytics Actor, the first real decision was not the report format. It was the authorization model. In a multi-tenant context, OAuth stops being purely a security question and becomes a product friction question.&lt;/p&gt;

&lt;p&gt;I ended up with refresh-token-only instead of running a full 3-legged flow on every invocation. The reasoning is mundane and practical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The operational reality of an Actor
&lt;/h2&gt;

&lt;p&gt;Actors mostly run from schedules or non-interactive pipelines. If every run requires a fresh human consent, the automation degenerates into manual labor. That single decision quietly eats the entire value of the tool.&lt;/p&gt;

&lt;p&gt;You can model this as a unit-economics question: every extra interactive consent step is a tax on every future run. With a refresh-token model, the human pays the consent cost once, then the workflow runs free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not "platform-managed" delegated access
&lt;/h2&gt;

&lt;p&gt;A natural shortcut is "let the platform manage it" — store user credentials centrally, never bother the user again. I avoided this on purpose.&lt;/p&gt;

&lt;p&gt;The category of data here (inbox content) is sensitive by default. I do not want the product to be designed around "trust me first." I prefer a verifiable model: explicit user authorization, controllable scope, revocable token, recoverable failure path. Anything that requires the user to extend trust beyond what they can audit is a future incident waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh-token-only as an observability win
&lt;/h2&gt;

&lt;p&gt;There is a less-obvious reason refresh-token-only works well in multi-tenant. Failure modes converge.&lt;/p&gt;

&lt;p&gt;Common errors collapse into a small, named set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;token expired&lt;/li&gt;
&lt;li&gt;scope insufficient&lt;/li&gt;
&lt;li&gt;API rate-limited&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of a tangle of callback/session/browser-state issues. As the maintainer, this kind of observability matters more to me than a flow that "looks more standard." Production debugging is dominated by the worst few failure modes; making those modes obvious is worth a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy posture
&lt;/h2&gt;

&lt;p&gt;Take the minimum usable data, not the maximum available data. Functionality can grow slowly. Authorization boundaries should be clean from day one. Once you have taken too much, pulling back is almost always painful — and visible to the customer in a way that reads as a downgrade rather than a fix.&lt;/p&gt;

&lt;p&gt;This is a small mindset thing that compounds. Every new feature decision starts from "what is the smallest scope that lets this work?" instead of "what scope unlocks the most options?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Three questions before you copy this
&lt;/h2&gt;

&lt;p&gt;If you are working on multi-tenant automation, the three useful questions are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is your flow interactive, or batch?&lt;/li&gt;
&lt;li&gt;Are you optimizing for first-run experience, or long-run stability?&lt;/li&gt;
&lt;li&gt;Are you building trust through marketing language, or through revocability and audit boundaries?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The answers reorder a lot of design choices. For my Actor — scheduled, multi-tenant, sensitive scope — refresh-token-only was not the flashiest choice. It was the one that matched how Actors actually run: schedulable, maintainable, explainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Refresh-token-only is not a clever pattern. It is a boring one. Boring is what you want from authorization code, because the interesting code is supposed to live downstream of the auth boundary, not inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;li&gt;If you are designing automations where authorization, retry, and alerting all need to live in one operable loop, the related toolkit page is: &lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;https://foxck.gumroad.com&lt;/a&gt; — &lt;code&gt;AI Automation Workflow Pack&lt;/code&gt; ($29) is the closest match on theme.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>oauth</category>
      <category>architecture</category>
      <category>python</category>
      <category>saas</category>
    </item>
    <item>
      <title>Gmail OAuth client_id is not a secret â design notes for self-host Actors</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sat, 16 May 2026 03:20:01 +0000</pubDate>
      <link>https://forem.com/foxck016077/gmail-oauth-clientid-is-not-a-secret-a-design-notes-for-self-host-actors-19af</link>
      <guid>https://forem.com/foxck016077/gmail-oauth-clientid-is-not-a-secret-a-design-notes-for-self-host-actors-19af</guid>
      <description>&lt;p&gt;This question keeps coming up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"My Gmail OAuth &lt;code&gt;client_id&lt;/code&gt; got leaked. Is the system compromised?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Short answer: &lt;code&gt;client_id&lt;/code&gt; was never a secret. What you actually need to protect is the &lt;strong&gt;token&lt;/strong&gt;, the &lt;code&gt;client_secret&lt;/code&gt; (if your flow uses one), and the overall authorization exchange boundary.&lt;/p&gt;

&lt;p&gt;This matters more in a self-host Actor scenario, because you are not shipping a single deployment â€” every user runs it their own way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public identifier, not sensitive credential
&lt;/h2&gt;

&lt;p&gt;The OAuth &lt;code&gt;client_id&lt;/code&gt; is closer to an application identifier than a password. It needs to be visible to the OAuth server and the front-end flow. In many scenarios it appears in places that are reasonable to see.&lt;/p&gt;

&lt;p&gt;Treating &lt;code&gt;client_id&lt;/code&gt; like a password and "hiding" it is usually a misunderstanding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You cannot hide it (front-end, request URLs, logs all expose it)&lt;/li&gt;
&lt;li&gt;Hiding it does not raise overall security&lt;/li&gt;
&lt;li&gt;Worse, it can distract you from the real attack surface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question worth asking is: &lt;strong&gt;if an attacker knows your &lt;code&gt;client_id&lt;/code&gt;, what can they still do?&lt;/strong&gt; If the answer is "nothing â€” they cannot complete an authorization exchange or obtain a valid token", your design is pointing in the right direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the security budget actually goes
&lt;/h2&gt;

&lt;p&gt;For a self-host Actor, security is about flow integrity, not a single magical value. I focus on four layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;redirect URI allowlist&lt;/strong&gt; â€” only registered and verifiable callbacks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;state / anti-CSRF&lt;/strong&gt; â€” every authorization round-trip is bound to the originating session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;token storage and rotation&lt;/strong&gt; â€” least privilege, shortest exposure, revocable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tenant isolation&lt;/strong&gt; â€” every token is namespaced to a tenant and never crosses boundaries&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If these four layers are solid, &lt;code&gt;client_id&lt;/code&gt; visibility is not a primary risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistake: budget in the wrong place
&lt;/h2&gt;

&lt;p&gt;I have seen projects spend real effort on "obfuscating &lt;code&gt;client_id&lt;/code&gt;" while ignoring problems that actually cause incidents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;weak callback endpoint validation&lt;/li&gt;
&lt;li&gt;tokens accidentally landing in logs with broad ACLs&lt;/li&gt;
&lt;li&gt;error handlers leaking internal state to the caller&lt;/li&gt;
&lt;li&gt;inconsistent multi-tenant key naming that lets one tenant trip into another's data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are the things that bite.&lt;/p&gt;

&lt;p&gt;Security is not about mystique. It is about staying recoverable in the worst case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for multi-tenant Actor design
&lt;/h2&gt;

&lt;p&gt;Once you accept "&lt;code&gt;client_id&lt;/code&gt; is visible by design", the architecture gets cleaner. Attention naturally shifts to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;scope minimization&lt;/strong&gt; â€” request only the permissions you truly need (in my Actor, &lt;code&gt;gmail.readonly&lt;/code&gt; and nothing else)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;explicit token lifecycle&lt;/strong&gt; â€” when it renews, when it expires, when it can be revoked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;auditable execution path&lt;/strong&gt; â€” which tenant triggered which run when&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These decisions directly affect product trust and how much firefighting future-you will be doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-host does not exempt you from threat modeling
&lt;/h2&gt;

&lt;p&gt;A tempting shortcut: "It's self-host, so the risk lives with the user."&lt;/p&gt;

&lt;p&gt;That is half right. Yes, deployment responsibility moves to the operator. But as the author you still owe &lt;strong&gt;secure defaults&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;safe defaults rather than risky defaults&lt;/li&gt;
&lt;li&gt;explicit documentation rather than verbal hints&lt;/li&gt;
&lt;li&gt;observable errors rather than silent failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise you are not reducing risk, you are just shifting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation pattern
&lt;/h2&gt;

&lt;p&gt;For OAuth-touching repos, I now write three things first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which values are public, which must stay private&lt;/li&gt;
&lt;li&gt;The lifecycle and revocation path for every token&lt;/li&gt;
&lt;li&gt;What a user can actually do when an authorization error happens&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;new users do not get blocked on a panic about the wrong thing&lt;/li&gt;
&lt;li&gt;experienced reviewers can audit your security logic quickly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clearer you are, the easier it is for the community to trust your project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open-source carries extra weight
&lt;/h2&gt;

&lt;p&gt;In open source, a misleading security narrative is more dangerous than in a private product, because it gets copied.&lt;/p&gt;

&lt;p&gt;If you frame &lt;code&gt;client_id&lt;/code&gt; as "top secret", others will copy that posture and ship the same broken model with real problems intact.&lt;/p&gt;

&lt;p&gt;I prefer to spell it out in the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;client_id&lt;/code&gt; is expected to be visible&lt;/li&gt;
&lt;li&gt;the real sensitive surface is token handling and flow protection&lt;/li&gt;
&lt;li&gt;multi-tenant isolation is enforced through data structure and routing logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That way, even a fork carries a less-wrong threat model forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing: stop asking "can I hide it"
&lt;/h2&gt;

&lt;p&gt;The question I keep on a sticky note for OAuth design is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If this value gets seen, is the system still safe?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a single exposed value collapses the system, the problem is not secret management â€” it is brittle architecture.&lt;/p&gt;

&lt;p&gt;For Gmail OAuth in a self-host Actor, &lt;code&gt;client_id&lt;/code&gt; is not the protagonist. The real protagonist is a verifiable, revocable, isolated authorization system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;li&gt;If you work on long-running agents and workflow systems, the related theme â€” namespacing state, permission, and context boundaries to keep an expanding system controllable â€” is something I think about a lot. Toolkit page: &lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;https://foxck.gumroad.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;📚 Part of the &lt;a href="https://dev.to/foxck016077/series/39719"&gt;Apify Gmail Inbox Actor — design notes&lt;/a&gt; series.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>google</category>
      <category>security</category>
    </item>
    <item>
      <title>An Apify Actor for Gmail inbox analytics â refresh-token-only OAuth, async router, per-feature quota</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sat, 16 May 2026 01:31:10 +0000</pubDate>
      <link>https://forem.com/foxck016077/an-apify-actor-for-gmail-inbox-analytics-a-refresh-token-only-oauth-async-router-per-feature-pi2</link>
      <guid>https://forem.com/foxck016077/an-apify-actor-for-gmail-inbox-analytics-a-refresh-token-only-oauth-async-router-per-feature-pi2</guid>
      <description>&lt;p&gt;I just open-sourced an Apify Actor for Gmail inbox workflow analytics: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;&lt;code&gt;apify-gmail-inbox-intel&lt;/code&gt;&lt;/a&gt;. It is &lt;strong&gt;not a scraper&lt;/strong&gt;, &lt;strong&gt;not a bulk sender&lt;/strong&gt; â€” it is an inbox analytics tool on &lt;code&gt;gmail.readonly&lt;/code&gt; scope. This post is a design tour, not a tutorial.&lt;/p&gt;

&lt;p&gt;If you have ever asked "which client thread did I forget to reply to?" or "what is my average reply turnaround?", this is the kind of workflow it covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an Apify Actor
&lt;/h2&gt;

&lt;p&gt;I needed three things at once: serverless runtime, pay-per-result billing, and a real input schema. Apify gives me all of them without writing a backend. I get a hosted endpoint, dataset storage, a key-value store for state, and a developer audience that is already paying for actors.&lt;/p&gt;

&lt;p&gt;The actor exposes four features through a single entrypoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;thread_search&lt;/code&gt; â€” query Gmail threads by &lt;code&gt;q&lt;/code&gt;, paginate, return metadata + message counts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reply_metrics&lt;/code&gt; â€” for each thread, compute reply-from-me, reply-from-others, last-reply age, SLA breach flag&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;summarizer&lt;/code&gt; â€” optional OpenAI LLM thread summary (BYO API key)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unread_digest&lt;/code&gt; â€” list unread threads in the last N hours, grouped by label&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Design decision 1: refresh-token-only OAuth
&lt;/h2&gt;

&lt;p&gt;The hardest call early on was OAuth. Two paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;3-legged OAuth on the Actor side&lt;/strong&gt; â€” Actor hosts callback URL, exchanges code, stores tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh-token-only&lt;/strong&gt; â€” user does the OAuth dance once on their own, hands me &lt;code&gt;{refresh_token, client_id, client_secret}&lt;/code&gt; as Actor input.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I picked option 2. Reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apify Actors do not have a stable HTTPS callback URL per user. Each run is a job, not a server.&lt;/li&gt;
&lt;li&gt;"We never store your Gmail tokens" is a far easier privacy story to defend.&lt;/li&gt;
&lt;li&gt;I do not want to be the holder-of-secrets for someone else's mailbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the Actor, the flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/gmail_client.py â€” sketch
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;httpx_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://oauth2.googleapis.com/token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&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;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Access token lives in memory only. Job end â†’ process tears down â†’ token gone. Best effort, but at least nothing persists in Apify storage with my code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision 2: one async router, not four actors
&lt;/h2&gt;

&lt;p&gt;Tempting to split into four actors. I did not, for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Marketing surface area. One actor with four &lt;code&gt;feature&lt;/code&gt; enum values gets one Store page, one rating, one review pile. Four actors split everything four ways.&lt;/li&gt;
&lt;li&gt;Shared OAuth + shared quota. The token exchange, error handling, mask helpers, KVS quota â€” all reusable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;src/main.py&lt;/code&gt; is just a router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FEATURES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;thread_search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reply_metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reply_metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarizer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summarizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unread_digest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;actor_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_input&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;feature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actor_input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature&lt;/span&gt;&lt;span class="sh"&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;feature&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FEATURES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown feature: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FEATURES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;actor_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each feature module owns its own &lt;code&gt;INPUT_SCHEMA.json&lt;/code&gt; semantics through the same shared file â€” the &lt;code&gt;feature&lt;/code&gt; enum drives validation downstream in each handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision 3: quota lives in Apify KVS
&lt;/h2&gt;

&lt;p&gt;Free tier is 100 threads / month. That counter has to survive across runs. Apify KeyValueStore is the obvious home â€” no extra DB, persistent, scoped to the Actor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/quota.py â€” sketch
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_and_increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;kvs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_key_value_store&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quota/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;month_key&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;kvs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FREE_LIMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;QuotaExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FREE_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;kvs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Month roll-over is a string key by year-month â€” no cron, no migration, no drift. Pro tier flips a flag and skips the check entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;Six pytest tests, &lt;code&gt;asyncio_mode = auto&lt;/code&gt; in &lt;code&gt;pytest.ini&lt;/code&gt;. Coverage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Router rejects unknown feature&lt;/li&gt;
&lt;li&gt;Each of 4 features short-circuits cleanly in &lt;code&gt;dry_run=True&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Quota raises after limit, allows under
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[pytest]&lt;/span&gt;
&lt;span class="py"&gt;asyncio_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tiny config line is the difference between "6 tests pass" and "6 tests error: missing event loop". Learned it the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Free: 100 threads / month&lt;/li&gt;
&lt;li&gt;Pro: $19 / month (5000 threads metadata + 100 LLM summaries)&lt;/li&gt;
&lt;li&gt;Pay-per-result add-on: $0.50 / 1,000 thread metadata, $0.005 / summary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apify handles billing. I handle code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhook trigger&lt;/strong&gt; â€” right now &lt;code&gt;unread_digest&lt;/code&gt; runs on demand. A scheduled trigger + Slack/Discord delivery is the obvious next product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label-level rules&lt;/strong&gt; â€” &lt;code&gt;reply_metrics&lt;/code&gt; is global. A per-label SLA matrix would be more useful for sales teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-account fan-out&lt;/strong&gt; â€” one run, multiple OAuth tokens, one combined dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;License: MIT&lt;/li&gt;
&lt;li&gt;Actor manifest: &lt;code&gt;.actor/actor.json&lt;/code&gt; + &lt;code&gt;INPUT_SCHEMA.json&lt;/code&gt; if you want to fork&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build automation workflows alongside this kind of inbox tooling, I keep a small Gumroad with practical n8n templates (lead auto-responder, content pipeline, competitor monitor): &lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;https://foxck.gumroad.com&lt;/a&gt;. Not required, just adjacent.&lt;/p&gt;

&lt;p&gt;Happy to take feedback on the OAuth-only design â€” was there a reason to go full 3-legged that I am missing?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;📚 Part of the &lt;a href="https://dev.to/foxck016077/series/39719"&gt;Apify Gmail Inbox Actor — design notes&lt;/a&gt; series.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>opensource</category>
      <category>serverless</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
