INSPIRATION
Every astrology app I tried wrote one horoscope and sent it to twelve million people. The traditions themselves are not vague. A BaZi practitioner reading your Four Pillars, or a palmist reading Wu Xing and Hast Samudrika against each other, gives you something specific enough to argue with. The apps had flattened all of that into "trust the universe".
I wanted to see what happened if an app took the traditions seriously and refused to hedge.
WHAT IT DOES
Mystic Codex reads your actual birth date, time and place, then writes for you specifically. 28 reading types across Western astrology, tarot, Chinese BaZi and Zi Wei Dou Shu, palmistry, face reading (Mian Xiang) and numerology.
The part I care about most is that it holds East and West in the same hand. Your tarot draw is interpreted against your BaZi Day Master. Your palm lines are cross-referenced across Chinese and Indian schools. The same card lands completely differently for two people, because the surrounding chart is different.
• Free every day, no account needed to try — daily horoscope, today's energy, a daily tarot pull • Photograph your palms for a cross-tradition reading • Face reading — a Three Palaces analysis from one photo • Photo Compatibility — two people's palms and faces read together • Birth chart, BaZi Four Pillars, Three Treasures, Year Ahead, Astrocartography • The Oracle — ask a real question, get an answer synthesised across traditions
The real differentiator is what the prompts forbid. No "trust the universe", no "hold space for", no fatalistic prediction, no vague comfort. Every reading has to end with something you can do this week. If your chart says you avoid conflict until it detonates, it says so, and then tells you what to do about it.
HOW I BUILT IT
Next.js 15 on Vercel. Supabase for auth and Postgres with row-level security. Anthropic Claude for generation — Sonnet for premium readings, Haiku for the free daily tier. Upstash Redis for caching, rate limits and concurrency slots. Shipped to Google Play as a Trusted Web Activity, so one deployment serves both the web and the Android app.
Subscriptions run on two payment rails: Google Play Billing inside the Android app, RevenueCat Web Billing over Stripe everywhere else, both resolving through a single entitlement writer so the rails cannot disagree about who is subscribed. The Supabase user id is the appUserId, so entitlements follow the account rather than a device.
Two decisions shaped everything else:
The browser never grants access. A webhook maintains subscription state in Postgres and the server authorises from there. A tampered client cannot give itself a paid tier. Entitlement is time-bounded with a renewal buffer and a grace window, so a late webhook never locks out someone who has paid.
Readings are cached aggressively per person. A birth chart does not change, so it is generated once and kept. Of 2,140 readings generated in production, only 582 needed a model call — the rest were served from cache. That is what makes a long reading affordable to give away, and it turns a per-call cost into a one-off.
Photos live in private storage buckets scoped to the user's own account, are served only through 60-second signed URLs, and are deleted after 30 days by a cron.
CHALLENGES I RAN INTO
One line in a security header silently disabled all Android payments.
Google Play Billing is not a separate API. It runs on the browser's Payment Request API, through a payment method identifier that Chrome recognises inside a Trusted Web Activity. My Permissions-Policy header read payment=(), denying that API for the whole origin.
The comment above it explained the reasoning, and the reasoning was correct when it was written — checkout used ordinary card fields and needed nothing. It stopped being correct the day Play Billing shipped, and nobody rereads a comment that already has an explanation attached. The button existed, looked right, and did nothing. No console error. No failed request. For a week, the store reported people arriving and nobody buying.
Nobody could subscribe on desktop either, for a different reason.
The same site serves the browser and the Android app, and it has to hide web checkout inside the app to respect Play's billing policy. My "are we inside the app?" check trusted window.getDigitalGoodsService, on the belief that it never appears in an ordinary browser tab. Chrome exposes it to installed desktop PWAs. So desktop visitors were told subscriptions were unavailable, on the one surface where web checkout was the only way to buy.
Both bugs looked identical from the outside: traffic arriving, nobody converting. Neither produced a single error.
"Cancel anytime" in four places, and a button that said "coming soon".
The paywall, the store listing, the subscription page and the terms of service all promised it. The control that should have honoured it had never been built. I found this the same night real cards started being charged.
A reading that had been broken for four months.
One call passed input: {} with a comment claiming the server would fill the fields from the database. It does — in the API route, which calling the generator directly bypasses. The model received an empty object and replied asking for birth details the user had already saved. Worse, the cache key was three undefined values, so every user shared one poisoned entry for a year.
ACCOMPLISHMENTS I'M PROUD OF
Prompts that will not let the model waffle. A banned-phrase list, a rule that every technical term gets a plain-English gloss in the same sentence, and a requirement that every reading ends with a concrete action. The clarity test is that a bright twelve-year-old should follow every sentence.
Two payment rails that agree. Play Billing and web checkout resolve through one entitlement writer, so a subscriber is a subscriber regardless of where they paid.
One deployment serving web and Android, including a scanning ritual that traces the actual lines of your hand in gold while the reading generates, so the wait becomes part of the divination rather than a spinner.
WHAT I LEARNED
Verify behaviour, not source. Reading a migration has never once found a bug for me. Creating a real account and looking at what it actually received has, twice. I now distrust any number I have only read.
Silent failures are the expensive ones. The header, the desktop check, the empty input and the missing cancel button were all invisible. Nothing errored. Everything returned 200. The app was quietly not doing its job, and the metrics looked exactly like a product nobody wanted.
A comment that explains itself stops being read. The payment=() line had a rationale attached, which is precisely why it survived the change that invalidated it. Comments justify the present; they do not notice the future.
WHAT'S NEXT
Voice readings, gated behind the subscription, with audio cached per reading so re-listening is free. The route and the voice already exist — they were pulled from v1 because an uncapped per-press cost was the one unbounded expense in the stack.
Beyond that: promoting the Content-Security-Policy from report-only to enforcing once the reports are clean, and retaking the store listing, which converts at 6.8% against a category norm nearer 25%.
Built With
- android
- anthropic-claude
- next.js
- node.js
- postgresql
- pwa
- react
- redis
- revenuecat
- sentry
- stripe
- supabase
- tailwindcss
- twa
- typescript
- upstash
- vercel
Log in or sign up for Devpost to join the conversation.