@atmopay/client
v0.1.0
Published
Sell subscriptions and read gated content in an ATProto app, without touching money, cards or KYC.
Maintainers
Readme
Integrating AtmoPay
How an ATProto app sells subscriptions and shows gated content — what Flashes does, written down.
You add subscriptions to your app without touching money, cards, or KYC — and you earn a share of what you sell and serve. There is no API key and nothing to configure: every authenticated call carries a service-auth token minted by the viewer's own PDS, so your app is saying "I am acting for this user, and their server vouches for it".
npm install @atmopay/clientThe minimum: a subscribe button
Four calls. This is the whole integration if you only want to sell a subscription and unlock your own features.
import { AtmoPayClient } from '@atmopay/client'
const atmopay = new AtmoPayClient({
baseUrl: 'https://atmopay.birdsongapps.com',
serviceDid: 'did:web:atmopay.birdsongapps.com',
agent, // the viewer's @atproto/api Agent
appId: 'https://yourapp.example/oauth-client-metadata.json',
})
const offer = await atmopay.getOffer(creatorDid) // null ⇒ sells nothing; no session needed
if (offer) {
const { active } = await atmopay.checkEntitlement(creatorDid)
if (!active) await atmopay.subscribe(creatorDid, offer.tiers[0].id)
}| Call | Auth | Purpose |
|---|---|---|
| getOffer(did) | none — public | does this account sell? render the card |
| checkEntitlement(did) | viewer's token | is this viewer subscribed? |
| subscribe(did, tier) | viewer's token | the button |
| unsubscribe(did) | viewer's token | cancel |
Money never touches your app: subscribe posts, and AtmoPay charges on the
rail server-side.
Gating your own features
The entitlement check is not creator-specific — it answers "does DID A hold a subscription to DID B". Register your own account as a merchant, add a tier, and check entitlement against your own DID. Same rail, no extra work.
Two things to know: on iOS, Apple requires IAP for in-app feature purchases; and to verify entitlement in your backend, without the user present, use the portable receipt rather than this endpoint, which only answers about its caller.
Showing gated content
This half does not talk to AtmoPay. A creator's gated posts live in their own space on their own PDS, and AtmoPay is only the doorman that PDS consults. So showing gated content is ATProto, not an integration.
import { readSpacePosts, releaseSpacePosts } from '@atmopay/client'
let posts = null
if (offer.space) posts = await readSpacePosts(agent, offer.space, creatorDid)
if (!posts) posts = await atmopay.vaultPhotos(creatorDid) // fall backreadSpacePosts returns null — rather than throwing — whenever the viewer's
PDS cannot do this, which is most of them today. Always keep the fallback:
vaultPhotos reads AtmoPay's mirror, which exists precisely so that viewers
without spaces still see something. The mirror goes away when spaces ship
broadly; the space copy is canonical.
Under the hood it is three calls: the viewer's PDS mints a delegation token, the creator's PDS exchanges it for a DPoP-bound space credential — the moment AtmoPay's doorman is consulted — and the records and blobs follow.
Authoring gated content
For an app whose users are creators:
import { writeSpacePost } from '@atmopay/client'
const uri = await writeSpacePost(agent, offer.space, agent.did, { text, image })
await atmopay.relayGatedPhoto(image, text) // mirror; failure is not fatalWriting needs no credential dance — it is the creator's own PDS and their own repo, so their session carries it.
OAuth scopes
import { BASE_SCOPE, scopesFor } from '@atmopay/client'
const scope = [BASE_SCOPE, ...scopesFor(pdsUrl, { author: true })].join(' ')Selling and checking entitlement need no special scope — atproto is
enough, because the service-auth token is minted for AtmoPay specifically.
Space scopes are another matter, and the vocabulary is exact. It is enforced only by a real PDS, so a wrong value compiles, passes tests, and fails at sign-in:
- actions are
read_self | read | create | update | delete— there is nowrite collectionandmanageboth default to empty: what you do not name is not grantedauthoritydefaults toself, so reading someone else's space needsauthority=*
Ask for space scopes only on a PDS that implements spaces — a general
provider rejects an unknown scope and the whole sign-in fails. scopesFor
does that check.
Declare every scope you might request in your client metadata: the authorization server enforces requested ⊆ declared. And pass the scope explicitly on each sign-in rather than letting the client default to your metadata, or a spaces-only scope will follow your users to providers that cannot grant it.
Getting paid
A share of each payment is pooled for the apps that sold and served it, split between origination (who converted the sale) and delivery (who actually put the content in front of the supporter, metered per period).
- Origination comes from the
appIdyou pass — self-asserted, because a browser app has no secret to sign with. - Delivery is attested by the viewer's PDS, which tells AtmoPay's doorman which client is asking. You do nothing for this; it is measured.
Accrual needs only a client_id, so you earn from your first integration with no paperwork. Withdrawal needs onboarding as a payee — business verification and a bank account — because paying an unidentified party is not something a regulated payment provider may do.
