I’ve spent the last year on WealthRelay, a valuation API: portfolios, trades, instrument prices, returns, currency conversion, all behind GraphQL. It has the problem every B2B API has at the start. The first customer doesn’t exist yet, and until one does, every design decision is a guess about what the API is for.

So I became the first customer.

I collect things - sealed Pokémon product mostly, which I track in Collectr - and the collectibles that have genuinely behaved like an asset class these last couple of years are POP MART blind boxes. A set is twelve figures. One of them is the secret, which you can’t see through the box, and which sells on eBay for a multiple of its siblings. People buy whole cases to complete sets, trade their duplicates, and argue about what their shelf is worth. Every tracker I found answered that with a checklist. None of them treated the shelf as what it is: a portfolio, with a cost basis, a market value and a return.

SecretPull is the result. A collection tracker for POP MART - and since then Woolworths’ Disney Ooshies and The Wand Company’s Poké Ball replicas - where every figure is a custom instrument in WealthRelay, owning one is a trade, and a collection is a portfolio. Empty repo to production in four weeks and about three hundred commits, five thousand series indexed, and tracking is free.

The premise fit better than I expected. WealthRelay has no collectible instrument type, so a figure is registered as PROPERTY: a physical asset valued by market comparables, no dividends, no splits - which is already how it treats a house or a painting. Portfolio value, cost basis, per-holding return, conversion into whatever currency the collector picked: all of it came for free. What didn’t come for free was every place the analogy breaks, and that turned out to be the whole project.

The catalog has no API, and the data it has is wrong

A stock has a ticker, an exchange and a closing price. A figure has a photo on a storefront.

POP MART has no public API, but the storefront’s own one is reachable once you sign requests the way their Next.js bundle does - an MD5 over the parameters, a salt and a timestamp. One product endpoint returns the whole listing, and crucially its toys[] array: one entry per figure, with a name, a picture and a rarity marker. That array is the unit a collector actually checklists, and it’s the reason the catalog exists.

Product ids are dense enough that a straight sweep of the id space beats crawling the sitemap, and it picks up delisted products the sitemap has already dropped. Then the raw data gets two important things wrong.

The rarity marker isn’t a reliable secret flag. Secrets come back as type 2 and type 6, and one set returns type 1 for figures whose own name says “(Secret)”. The ingester now corroborates across three signals - the type, a (Secret) suffix in the name, and a secret_ marker POP MART bakes into the CDN filename - before it will call anything a chase. Getting this wrong isn’t cosmetic: whether a figure is the secret is the single most important input to pricing, for reasons below.

The same product is listed many times. Shop, secret-shop and store re-lists are grouped, but plenty of duplicates carry no grouping at all, so they’re collapsed by exact title within a region, keeping the richer row, because twins aren’t equally complete - one listing carries seven figures and its twin carries none. Deduplication removed about 40% of the raw US catalog.

Then there are three storefronts, and only the North American one publishes figure lists, so sets discovered through Europe or APAC arrive with sealed product and no checklist. (The European storefront also rejects .NET’s TLS handshake outright, so that client shells out to curl, and drives a real headless browser for the detail pages that now only answer one.) For those sets, checklists come from community catalogs, which raises the question of which figures on the list are the secrets when nobody says.

The answer is the one heuristic in the codebase I’m genuinely fond of. Community catalogs list a set’s figures alphabetically and append the secrets at the end, out of order - “Culprit” after “Toyama” in a list where C sorts third. That break is the tell, and it’s also the safety gate: the number of secrets comes from the storefront’s own arithmetic (figures minus what a case guarantees), the regular prefix has to actually be sorted, and every trailing candidate has to break the run. A last figure that continues the alphabet is just the last figure, and the heuristic says nothing rather than brand a regular as the chase.

The join is a string

Pricing is the hard half, and it’s hard for one reason: there is no shared identifier anywhere. POP MART calls a figure “Culprit (Secret)” inside a set. eBay calls the same object “Pop Mart Detective Conan-Super Mini Culprit (Secret Edition) Figure”. The join between a catalog and a marketplace is textual, and it has to be, so the matcher is a pure function that can be tuned against the whole catalog offline, without an API key or a paid lookup.

The scoring is simple on purpose. Tokenise everything, strip the words every listing carries (“pop”, “mart”, “figure”, “blind”, “box”, “authentic”…), and measure how much of the figure’s name the listing covers, weighted 70/30 against how much of the set’s title it covers. Then three gates, each of which multiplies the score down rather than merely subtracting from it:

  • The set has to gate, not contribute. Related sets share most of their title and most of their characters, so a figure from the wrong set scores high on the strength of its name alone. If the series coverage is under 75%, the score is cut to 40%.
  • Rarity has to agree in both directions. A secret sells for many times a common of the same name, so a listing that says “secret” matched to a figure that isn’t - or the reverse - isn’t a near miss. It’s the single most expensive mistake the matcher can make, and it takes 65% off the score.
  • Scale has to agree too. The standard figure and the MEGA 1000% share a name and nothing else.

Any bundle word - “lot of”, “full set”, “case of”, “12 boxes” - zeroes the listing outright, because a listing for twelve boxes prices twelve boxes. Anything under 0.75 is refused. The bar is deliberately high: a missing price shows as unknown, whereas a wrong one silently misvalues somebody’s collection with no way for them to tell.

The vocabulary later moved out of the matcher into a per-domain record, and writing the second domain is what proved the first one’s words weren’t universal. “Sealed” is boilerplate to strip from a POP MART listing and the single most valuable word on a Pokémon one. “Booster box” is a bundle; “blind box” is the product. The algorithm - tokenise, cover, weigh, gate - is the shared part. The words never were.

A median of one is a stranger’s opinion

The first version of the price job matched listings and wrote a price. Measured in production, every price it had written carried a sample size of one, and one of them valued a figure from a £22.99 set at 321.86 - a lot or a bundle that got past the matcher, which a real median would have stepped over without noticing.

So now a figure gets a market price when at least three comparable listings exist, the median of those is what’s written, and the eight cheapest comps are stored beside it so a subscriber can see the listings a price was built from. A figure that can’t reach three keeps its retail price and says so. An unpriced figure that admits it is honest; a figure priced from one hopeful listing is not.

Two more things fell out of measuring rather than assuming:

  • AliExpress answers in sealed boxes. Singles do exist there, but the search results for a set are dominated by the sealed blind box at $12-15, and the loose-figure matcher was correctly rejecting all of it - which meant the importer was throwing away the one thing that source reliably prices. The sealed box is a product in the catalog with its own instrument, so now it gets its own pass - and a blind box’s price is explicitly not its contents’ price. It’s a lottery ticket.
  • Counterfeits follow hype, not marketplaces. AliExpress carries a great deal of genuine POP MART at genuine prices, but faking concentrates on whichever line is currently valuable, and the counterfeit-rate studies that get quoted are all measuring Labubu. So the exclusion is per line, not per source: blocking the marketplace outright would discard accurate prices for ninety percent of the catalog to avoid a problem confined to the other ten, and pricing Labubu from it would drag a $200 chase toward the price of a copy. It’s a policy, it’s written down as one, and it will need revisiting when the next line gets hot.

Currency was the last lesson, and it was the lesson WealthRelay was built to teach. A marketplace answers in its own currency; every instrument holds USD; WealthRelay converts on read into the currency the collector picked. For a while the SPA had a second exchange-rate table, and a portfolio total disagreed with the sum of the figures inside it, because each was converted by a different set of rates. One set of rates now, and they’re WealthRelay’s. A price whose currency is decided by us rather than read back from the instrument is always a bug.

Whose number is it

Market prices are off in production while all of the above gets finished. The feature flag that hides them took two attempts to get right, and the rule that landed is worth stating.

The flag gates valuation and nothing else: market price on a card, market value, portfolio return, cost-to-complete, the chase premium, the charts. It does not gate money the collector entered - what they paid, what they sold for, their target prices - and it doesn’t gate retail, because a shelf price is a storefront’s claim, not ours. The test is whose number it is. Ours is hidden while it’s being built. Theirs is not ours to withhold.

The odds nobody publishes

The bit of the app that isn’t a portfolio is a box-opening simulator, and its maths is the part I’d defend hardest. POP MART’s pull rates aren’t in the API, so the secret rate is a number the collector types, not one the app invents. What is knowable is the case composition, and that’s where the interesting number lives.

A case holds one of each figure until the figures run out, so a case of twelve completes a twelve-figure set. Buying singles off a shelf is a different problem: it’s the coupon collector, and the expected number of boxes to see every one of twelve figures is 12 × H(12), about 37. That’s the number a collector actually wants before choosing between singles and a case, and unlike the secret rate it needs no assumption at all. The simulator models both - independent draws for shelf boxes, fixed composition for a case, with the secret displacing a common rather than adding a box, because a case is a fixed number of boxes - and it uses the same formula the UI prints, because a simulator whose outcomes disagree with the odds beside it is worse than no odds at all.

Thirteen schedules, then one

I wrote recently that cron describes when the system wakes up, and that’s still the right split. But it turned out even that was too much structure to keep in a console.

The pipeline has a real order: pull the catalog, fill in the checklists POP MART doesn’t publish, mirror the artwork, price it, publish to WealthRelay, then seed retail for whatever was just published. It started life as thirteen EventBridge schedules, each starting its own Fargate task at its own minute, which put the shape of the pipeline into thirteen cron expressions in a console where nothing in the repository could see it. The retail seeding job was simply never given a schedule and nobody found out, because a job that isn’t scheduled looks exactly like a job with nothing to do.

Now there’s one kick a day, and the sweep works out what it owes. Every job declares how stale its last success may get; a kick runs everything that’s due, in the pipeline’s order, and only success moves the mark - a job that throws is due again on the next kick, and the sweep carries on past it, because one dead third party shouldn’t hold the rest of the catalog hostage. Being kicked twice is safe and is the intended recovery. The window is twenty hours rather than twenty-four, so a run that finished five minutes late once doesn’t get skipped the next day and drift a full cycle. Safe isn’t free, though: a kick that finds nothing owing still pays for a container start and a public IPv4 address rounded up to the hour, which is why the schedule is daily and recovery is a human pressing a button.

Smaller things I’d do again

  • The client to WealthRelay is generated from its published schema (StrawberryShake), so a contract change breaks the build instead of production. It paid for itself on day one: a hand-written mutation had price instead of unitPrice and omitted a non-nullable field, which is valid-looking JSON that would only ever have failed at runtime.
  • The SPA’s route list is the source for a CloudFront Function that gives a single-page app real 404s and decides which of two hostnames serves a path. Two copies of a route list drift, and the direction they drift is nasty, so there’s one list and the edge function is generated from it on every deploy.
  • Feature flags are three JSON files at the root, picked by environment on the API and inlined by Vite at build time. One line in one place, and both halves have to be gated, because a flag only in the bundle leaves the API answering with data nothing renders.

WealthRelay got the customer it needed, and the customer got a shelf that’s worth something. The prices are the hard part and they’re still ahead - but every figure already has a symbol, a cost and a place to put a value, which is more than a spreadsheet ever gave a collector.