Context
The brief was a complete platform letting users buy a membership and receive a set of on-chain perks. I built the entire backend: a FastAPI API handling authentication, payment, membership, the on-chain issuance of the NFTs, and their metadata.
The problem
Issuing NFTs as the consequence of a fiat payment means running two systems that must never disagree: the money and the chain.
- Minting is irreversible and costs gas: a double-mint or a lost transaction is unacceptable.
- Payments can be disputed or refunded after capture, so assets must not be issued until funds have actually settled.
- Some NFTs must be non-transferable (soulbound), others transferable.
- The frontend needs a clean, typed, predictable API to build on.
Objectives
- A complete backend: authentication, payment, membership, on-chain issuance and NFT metadata.
- Mint exactly the right bundle, exactly once, per paid membership.
- Support both soulbound and transferable NFTs.
- Be robust against double-mints, lost transactions, crashes and payment reversals.
- A clean, documented, fully typed API.
My approach
I built a layered FastAPI backend on PostgreSQL, with wallet-based identity, Stripe payments under a strict hold-until-settled rule, and an idempotent on-chain mint state machine over web3.py, all behind a chain client that can be swapped for a stub. The full flow:
The API (FastAPI)
The backend is a layered FastAPI app, fully typed: a Pydantic schema for every request and response, typed SQLAlchemy models, checked with mypy. Routers are split by concern (auth, membership, checkout, metadata, webhooks, admin and more) and wired through dependency injection, so the current user and the database session are injected rather than fetched ad hoc.
Identity is the wallet. Login exchanges a verified token (checked with ES256 against the provider's JWKS) for a session JWT, and the embedded wallet stays the on-chain identity; sensitive endpoints are rate-limited. Payments go through Stripe checkout with idempotent webhooks, and settlement is read live from Stripe as the source of truth: a payment becomes issuable only once its funds are actually settled and neither disputed nor refunded, with a daily digest flagging what is ready to issue. Finally, an ERC-721 metadata endpoint serves each NFT's metadata, with its status (upcoming, active, expired) derived on the fly from the membership's validity dates rather than stored.
On-chain issuance (NFT & soulbound)
Each paid membership mints a bundle of ERC-721 NFTs on BSC. NFT families are split into soulbound (non-transferable) and transferable, so an identity or membership NFT cannot be moved while perks can. Mints and burns are owner-only, sent from a dedicated mint wallet funded only with gas, its key kept as an encrypted secret and monitored; an admin-only force-majeure burn exists for emergencies.
The chain client sits behind a single protocol with two implementations: a stub (deterministic, instant, no chain) for tests and local development, and a real web3.py client that signs and broadcasts. One environment switch chooses between them, so the entire flow is testable without ever touching a chain.
At the centre is an idempotent mint state machine with no background worker: a single advance step drives the whole bundle and is safe to call repeatedly (a frontend poll, an admin retry). Its safety properties:
- NFT ids never collide: each membership carries a sequence-backed id (atomic in Postgres, never rewound), backed by a uniqueness constraint on (collection, on-chain id).
- No concurrent double-broadcast: advance takes a per-payment advisory lock; a second caller simply reports the current state instead of minting again.
- No lost transaction: every state transition is committed immediately, so a crash can never forget a transaction that was already sent.
- No duplicate broadcast after an error: a failed job re-checks its transaction before re-minting, so a transient confirmation error never mints twice.
- No double bundle: creating the jobs is idempotent and skips any collection the membership already holds.
Results
- A complete production backend powering the entire membership platform: auth, payment, membership, issuance and metadata.
- On-chain perks issued exactly once per paid membership, with no double-mints and no lost transactions.
- Both soulbound and transferable NFTs supported.
- Fiat only turns into on-chain assets once payments are truly settled, so disputes and refunds cannot leak value.
- Fully testable without a chain (stub client), typed (mypy), migrated (Alembic) and deployed.
Key takeaways
Bridging an irreversible blockchain to a reversible payment system is, more than anything, an exercise in idempotency and in treating settlement as the single source of truth. A clean, typed API and a chain client that can be swapped for a stub are what made something this stateful safe to operate and easy to test.