Skip to content

Repository files navigation

AnyAPI Cards

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.

Shape

  • 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.

Running it

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.

Trying the API by hand

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>/events

Frames 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.

Endpoints

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.

The daily budget

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.

Cards and their sources

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.

The company profile

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.

Running one card against the real APIs

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 profile

It 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.

Tests and types

npm run test        # vitest
npm run typecheck   # tsc -b

The 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.

Database

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:remote

Deploying your own

The 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.

How the cards look

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.

About

Open a trading-card pack for any company. Every card is real data, bought through AnyAPI.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages