Building an ATProto-based membership and feature gating system for Anisota

Note: you can also read this Leaflet doc within Anisota thanks to ATProto's interoperability.

Anisota is one of the few ATProto/Bluesky projects currently offering a paid premium membership. The app has been on the market for over a month now and is bringing in around $300/month in MRR. This isn't anywhere near enough for it to be my full-time vocation, but it's an encouraging start and has the potential to be part of my path to an economically sustainable independent creative practice.

The ATProto community has been experiencing a lot of growth over the past 12 months with new projects popping up every week now, so I want to begin offering regular behind-the-scenes updates and data about my experience thus far in case it is helpful to other creators in the ecosystem.

I first started making ATProto-based creative projects around a year ago, and in that time I've learned a lot about what to do and what not to do. I've also seen a lot of unexplored concepts that I've begun testing and experimenting with to see what's possible. An example of this would be the membership gating functionality I built for Anisota. As far as I know this hasn't been done before, but I think it's worth having more folks tinkering on.

Let me explain how it works...

TL;DR

The app checks public ATProto records contained in the @anisota.net personal data server (PDS) to see if an account is a paid member or not and configures the app experience accordingly. When someone changes their membership level (upgrading, downgrading, etc), my payment processor sends this data to my backend which automatically updates the ATProto record.

Slightly Longer Explainer

Anisota has several paid membership levels that people can subscribe to in order to unlock all of the app's features and remove the limitations that are set on guest accounts (free users). Under the hood, this entire gating mechanism and process is powered by the AT Protocol. The personal data server (PDS) for the @anisota.net brand account is the source of truth rather than a centralized private database.

The membership system is rooted in a series of Bluesky Account Lists that were created under the @anisota.net brand account.

Caterpillar (Free Guests)

Cocoon (Paid Level 1)

Moth (Paid Level 2)

Lepidopterist (Paid Level 3)

When someone tries to access Anisota, the system checks to see if they are in any of the account lists and then configures the app experience based on their membership level. If the person's account doesn't appear in any of the lists, they get automatically added to the Caterpillar list which is the list of free guests.

When someone chooses to upgrade and support the app financially via membership, my payment processing platform (Stripe) communicates with my app's backend infrastructure so that the person's account gets moved from the Caterpillar list to the appropriate Member List (or gets removed from a Member List if they cancel or their plan expires).

By looking at the ATProto accounts referenced by these lists, anyone can see a complete picture of every user who has ever tried Anisota, as well as who is supporting it financially.

There are periodic membership checks under the hood as people use the app to ensure they're seeing what they should see... but ultimately the system isn't meant to be unhackable or like a fortress. That's by design.

If an enterprising tinkerer or hacker chooses to bypass my system and use the app's premium features long-term for free, then that's probably just bad karma for them cause I'm an independent artist just trying to make a living. Support my work instead! It's not expensive, helps ensure the app continues to live, and gives you a much better user experience. I might add additional security down the road if it becomes a problem, but for now it's fine.

And that's basically it. It's a relatively simple system and didn't take long to setup, and it's incredibly "ATProto-native". If you want to get an even clearer picture of the entire flow, keep reading for the granular details.

If you enjoyed this blog post, try Anisota for yourself to experience social media in a dramatically different way. If you want to support my creative practice and see more of my work, consider upgrading to a paid membership tier for Anisota within the app's settings. <3

P.S. I could have created a custom lexicon for the membership records, and I might in the future, but why complicate things unnecessarily? Also, by using Bluesky's list lexicon (app.bsky.graph.list), I could more easily take advantage of Bluesky's ecosystem and let people see these lists within the Bluesky app too.

Technical Deep Dive

The following is a more technical explainer for the nerds who are curious what's happening deeper below the surface. It was written by Claude who reviewed the codebase in Cursor and accurately describe the entire system in less than 2 minutes. I then reviewed and edited Claude's writing to ensure accuracy and contextual clarity.

1. First-Time Login (Backend)

When a user logs in for the first time:

Backend (anisota-cocoon):

OAuth callback receives the user's authentication

Immediately checks: "Is this user in any tier list?" via getUserCurrentTier()

If they return 'none' (not in any list), the backend automatically adds them to the Caterpillar list using addUserToDefaultTier()

This happens via AT Protocol's app.bsky.graph.listitem record creation

The user now has basic free-tier access

2. Tier Verification (Frontend)

Every time the app loads or needs to check permissions:

Frontend (anisota):

subscriptionChecker.checkSubscriptionStatus(userDid) runs in the browser

It queries all the Bluesky tier lists in parallel using AT Protocol's public API

Checks if the user's DID appears in any of the lists: team, lepidopterist, moth, cocoon, legacy allowlists, and caterpillar

Returns the highest tier the user is in (team > lepidopterist > moth > cocoon > caterpillar)

Results are cached for a period of time to avoid unneeded API calls

3. Feature Gating (Frontend)

Once the tier is known, the system might enforces limit:

Frontend services:

usageLimitService - enforces daily post limits based on tier

featureLockService - determines which features are locked/unlocked

These check the cached tier and apply restrictions locally in the browser

4. Payment Flow (Backend)

When someone subscribes via Stripe:

Backend (anisota-cocoon):

User completes Stripe checkout

Stripe webhook fires to /api/stripe/webhooks

Webhook handler:

The move happens via AT Protocol record operations (create/delete listitem records)

5. Tier Changes & Downgrades (Backend)

When subscriptions change:

Stripe webhook events:

subscription.updated - tier changes (upgrade/downgrade)

subscription.deleted - cancellation

System moves user between lists accordingly

If payment fails, user gets moved back to Caterpillar tier

Manual sync endpoint:

/api/stripe/sync-user-tier - fallback for when webhooks fail

Queries Stripe to find active subscriptions, then syncs list membership