Type a company domain, open a trading-card pack. Every card holds one real insight bought through the AnyAPI data API: subreddits worth posting in, the ads a rival is running right now, the roles they just opened. One pack per domain per UTC day, and the pack is free to open.
Lives at getanyapi.com/cards. The main site proxies /cards/* to this Worker
with the path preserved.
src/app- the Vite React single-page app.src/worker- a Cloudflare Worker (Hono) with a D1 database.src/shared/contract.ts- the one contract all three share.
cp .dev.vars.example .dev.vars # then fill in the two keys
npm install
npm run db:migrate:local # creates the local D1 tables
npm run dev # http://localhost:5173/cards/.dev.vars needs an ANYAPI_API_KEY (from the AnyAPI dashboard) and an
OPENROUTER_API_KEY. Leave TURNSTILE_SECRET empty locally: the Worker skips
the human check when it is not set, so curl works with no token.
Vite picks the next free port when 5173 is taken, so read the port it prints.
curl -s -X POST localhost:5173/cards/api/packs \
-H 'content-type: application/json' -d '{"domain":"stripe.com"}'That answers with {"pack": {...}, "created": true}. A pack starts at status
printing with no cards: the five cards are rolled once the company profile
exists. Opening the same domain again the same UTC day returns the same pack
with created: false, and never buys the data twice.
Generation happens on the event stream. Take the pack.id from above and:
curl -N localhost:5173/cards/api/packs/<id>/eventsFrames are Server-Sent Events with no event name, so the browser's
EventSource.onmessage sees all of them. Each data: line is one PackEvent
from the contract: the current pack, then profile, then one card per card
as it finishes, then the final pack. A pack that could not be read sends
failed instead.
The first request to open the stream generates; anyone else who opens it tails the same rows, so a second viewer and a reconnect both see every card. Closing the connection does not stop generation.
| Method | Path | Answers |
|---|---|---|
| POST | /cards/api/packs |
OpenPackResponse - open or return today's pack |
| GET | /cards/api/packs/:id |
Pack |
| GET | /cards/api/packs/:id/events |
SSE stream of PackEvent |
| GET | /cards/api/domains/:domain |
DomainResponse - today plus history |
| GET | /cards/api/recent?limit=24 |
RecentResponse - newest packs, max 60 |
| GET | /cards/api/status |
StatusResponse - spend against today's cap |
Everything else is the app: the Worker strips the /cards prefix and hands the
request to the static assets, which fall back to index.html. The same routes
also answer without the prefix, so wrangler dev at the root works.
Errors are always { "error": "..." } in plain words. A bad domain is a 400, a
domain that failed earlier today is a 422 with the cached reason, and a day that
has spent its budget is a 503.
DAILY_SPEND_CAP_USD (a var in wrangler.jsonc) is the kill switch. The Worker
checks it before opening a pack and again before each card starts, and adds
every AnyAPI and model charge to spend_days.spent_usd with a single SQL
UPDATE. A value it cannot read counts as zero, which closes the shop rather
than opening it. GET /cards/api/status reports where the day stands.
Eleven card types, one generator each in src/worker/generators/. Every card is
built the same way: walk a ladder of AnyAPI calls until one rung returns
something usable, hand the model a numbered list of what came back, and let it
pick indexes. The model writes the headline and the one-line reasons. It never
writes a URL, a name or a number: those are mapped back from the data by index,
so nothing on a card can be invented.
Each tier has a hard ceiling on paid calls, enforced by CallBudget rather than
by each generator remembering: common 3, uncommon 5, rare 8, ultra 10. Every
successful call records the gateway's own costUsd, and that is what the card's
receipt shows.
| Card | Tier | AnyAPI SKUs | The ladder, in order |
|---|---|---|---|
subreddits |
common | reddit.search, reddit.subreddit_details |
the domain, then the brand name with its category words, then a buying phrase. Whatever budget is left buys the weekly active count for the two busiest subreddits |
x_openings |
common | twitter.search |
brand name plus category words, then the buying phrases ORed together, then the domain. Replies and the company's own support posts are filtered out |
search_questions |
common | google.autocomplete, google.search |
autocomplete on the category, then on a buying phrase, then the brand. One search checks who ranks for the most specific suggestion. If autocomplete is silent, the card shows what already ranks for the category |
competitor_ads |
common | facebook.company_ads, google_ads.company_ads, google_ads.advertiser_search |
the top rival on Meta (the only library that returns ad copy), then on Google, then the company's own ads, then whoever is bidding on the category |
hn_mentions |
common | hackernews.search |
the domain, then the brand with category words, then a buying phrase |
ugc_creators |
uncommon | tiktok.search_users, tiktok.profile_contact, instagram.search_profiles, instagram.profile_contact, youtube.search |
TikTok accounts, then Instagram accounts, then YouTube videos. Up to three contact lookups for a published email |
competitor_engagers |
uncommon | google.search, linkedin.company_posts, linkedin.post_reactions, linkedin.post_comments |
one Google search buys the rival's LinkedIn slug, then their most engaged recent post, then who reacted, then who commented. Needs a named rival |
hiring_signals |
uncommon | linkedin.jobs, indeed.jobs |
the rival on LinkedIn, the rival on Indeed, then the category on both |
review_gap |
uncommon | google.search, g2.reviews, capterra.reviews, trustpilot.reviews |
software sellers go G2, Capterra, Trustpilot; everyone else goes Trustpilot first. Needs a named rival |
seo_audit |
rare | ahrefs.overview, ahrefs.keywords, ahrefs.backlinks |
the domain (retried on the www. spelling), two profile keywords, the backlink profile, then the rival's rating for contrast |
playbook |
ultra | reddit.search, twitter.search, google.autocomplete, ahrefs.overview |
buys nothing new: the cheapest rung of three common cards plus one domain lookup, then one model call turns them into five ordered steps, each pointing at a real URL from the data |
Two SKUs the cards deliberately do not use: youtube.channel_contact costs more
than every other call on the creator card put together and YouTube search does
not return a channel handle to feed it; linkedin.company costs a call to
confirm a slug the Google search already proved.
Three things the API returns that the cards report honestly rather than around:
reddit.subreddit_details gives weekly active users, not total members, so the
card says "active this week". ahrefs.overview has no traffic figure, so the
card shows referring domains. ahrefs.keywords has no search volume, so the
easiest keyword is chosen on difficulty and the number of domains needed to rank.
buildProfile in src/worker/profile.ts is one web.scrape (markdown for the
model, HTML for the logo and the social links) plus one model call. A bot wall or
a near-empty body is retried once on the other spelling of the host, and then it
is a ProfileError with words a visitor can act on. The model's answer is
grounded before it is trusted: a competitor domain has to look like a domain, and
a social handle has to be one the page actually linked.
npx tsx --tsconfig tsconfig.worker.json scripts/run-card.ts linear.app subreddits
npx tsx --tsconfig tsconfig.worker.json scripts/run-card.ts allbirds.com profileIt reads .dev.vars, prints the card as JSON and the run's cost on stderr. Add
--record <dir> to save every response verbatim; the fixtures the generator
tests run against in src/worker/generators/fixtures.ts were captured that way.
npm run test # vitest
npm run typecheck # tsc -bThe Worker's tests run against a hand-rolled D1 stand-in in
src/worker/testing/d1.ts that recognises the exact statements the Worker
issues and throws on anything else.
Migrations are plain SQL in migrations/, applied in filename order. Add a new
file rather than editing one that has shipped.
npm run db:migrate:local
npm run db:migrate:remoteThe Worker deploys with wrangler. Create a D1 database, put its id in
wrangler.jsonc, then:
npm run db:migrate:remote
npm run deploy
npx wrangler secret put ANYAPI_API_KEY
npx wrangler secret put OPENROUTER_API_KEY
npx wrangler secret put TURNSTILE_SECRET # optional.github/workflows/deploy.yml does the same on every push to main from the
repository secrets it names. DAILY_SPEND_CAP_USD in wrangler.jsonc is the
most you will spend in one UTC day, whatever happens.
The pack and every card are real 3D objects (three.js) with one foil shader: matte, foil, holo, and prism finishes that move with the pointer on a desktop and with the gyroscope on a phone. The card faces are painted to a canvas from the card's data, so the image you share is the exact card you saw. Every sound is synthesized in the browser; the repository ships no audio files.