A CS2 market-intelligence platform and subscription business. Close to 1,000 members at an annual run-rate near €35,000, with seven live market indexes and 10,588 data points behind the charts.
Counter-Strike 2 has a real economy. Weapon skins and cases trade on open markets with genuine price discovery, and a serious community treats them as an asset class. The client (a Scandinavian YouTuber with the largest CS2 investing Discord) needed a platform that turned that audience into a business.
The product had to do three things at once. Sell a membership without friction. Prove its own credibility with real market data, not marketing claims. And run unattended, because a creator-led business has no operations team behind it.
The hard constraint shaped everything: no paid data vendor. A €4.99/month product cannot carry a four-figure market-data licence. Every number on the site had to come from a free, legitimate, publicly documented source; and had to stay fresh without anyone touching it.
Where the business stood when operations paused in February 2026.
Two billing periods against one product. The annual plan is priced at a 41.6% discount, which pulls cash forward and cuts churn exposure at the cost of per-head revenue.
Four external feeds in, one subscription product out, and a rendering strategy chosen per route rather than globally.
Rendering strategy is per-route, not global. The index page fetches on the
server and caches for an hour, because index data moves slowly and SEO matters. The market
page is explicitly no-store, with cache headers set in
next.config.ts for both the page and its API route, because a stale market
capitalisation is worse than a slow one.
Every external feed goes through a route handler. Third-party origins, user agents, and failure modes stay server-side. The browser only ever talks to same-origin endpoints, which sidesteps CORS entirely and means a vendor changing their response shape is a one-file fix.
No charting library. Recharts would have added roughly 500 KB to the
bundle to draw bars and a line. Both chart components were written by hand in
145 and 220 lines respectively, statistics computed in a single pass,
memoised with useMemo, and wrapped in memo so a parent
re-render never recomputes them.
The revenue path, and the deliberate decision to keep it boring.
The most valuable engineering decision in the whole project was choosing not to build something.
The first version of the payment flow required Discord OAuth before checkout, wrote subscription records to a database, and drove a bot that assigned server roles. It worked, and it was the single largest source of support tickets: OAuth redirects that lost their return path, role assignments that silently failed, and a database that could disagree with the payment provider about who was a paying member.
Every component in that chain was a place where a customer could pay and not get what they paid for.
So the flow was cut back to what actually converts. Pick a plan, enter a Discord handle for the receipt, go to hosted checkout, come back to access. The payment provider stays the single source of truth on who is subscribed, because it already is one and it is better at it than anything worth building here.
Monthly/annual toggle with the saving shown as a percentage, not a number to work out.
app/discord/page.tsxServer-side checkout.sessions.create in subscription mode. Price IDs live in
environment config; promotion codes enabled.
The provider's own page handles cards, SCA, and receipts. No card data ever reaches the application; PCI scope stays at the minimum.
hosted checkoutRaw request body read as text before parsing, then verified against the signing secret. Middleware exempts the route so nothing rewrites the payload.
api/checkout/webhook · middlewareThe provider signs the exact bytes it sent. If a framework parses the JSON first and the handler re-serialises it, key order and whitespace change, the computed signature no longer matches, and every event is rejected, usually discovered in production, at the worst possible moment.
How a one-line downsampling shortcut hid the peak in six of seven market indexes, and the fix.
The index charts render 10,588 data points as 51 bars. Something has to be thrown away. Which points get thrown away turns out to decide whether the chart tells the truth.
A single bulk request for all seven slugs, cached for an hour with ISR.
next: { revalidate: 3600 }Labels arrive as epoch-millisecond strings and values as strings. Both are
coerced into a { date, value } shape once, on the server.
Stride sampling: take every n/50-th point. Cheap, obvious, and the source of the bug.
Bar height is normalised against the min and max of the full series, not the sample.
index-chart.tsx:109Step four scales against all 10,588 points. Step three only draws 51 of them, taken every 211th position. Nothing guarantees the all-time high lands on a multiple of 211; and in this index it doesn't. The true peak of 108.36 is never drawn, so the tallest bar tops out at 83.6% of the plot height while the stat card above it confidently reports the peak the chart is not showing.
The right-hand edge had the same problem. The last sampled bar sat 111 hours behind the latest reading, on a market chart, the one value everybody looks at first.
Replace stride sampling with bucket decimation. Split the series into 51 equal intervals and, from each, keep the point that departs furthest from that bucket's own mean, the local extreme. Then force the final bucket to the latest reading, so the right edge is always current. Same linear cost, same 51 bars.
Running the comparison against all seven indexes showed the problem was systemic, not a quirk of one dataset. Six of seven dropped their all-time high. The seventh kept it by arithmetic coincidence, its peak happened to land on a multiple of the stride.
| Index | Points | Stride | True high | Peak drawn, before | After |
|---|---|---|---|---|---|
| Cases 2016-2019 | 10,588 | 211 | 108.36 | missed | shown |
| Cases 2020-2022 | 10,587 | 211 | 30.58 | missed | shown |
| Cases pre-2015 | 10,588 | 211 | 443.54 | missed | shown |
| Agents | 9,221 | 184 | 2,715.86 | missed | shown |
| Charms | 4,600 | 92 | 12,566.03 | missed | shown |
| All items | 10,009 | 200 | 28,473,085.94 | kept | shown |
| Playskin inventory | 3,943 | 78 | 1,307.72 | missed | shown |
Verified against live API responses on 30 July 2026. Stride column is
floor(points / 50) as computed by the shipped component.
A 2,000-path Monte Carlo built on the same data the charts render; and an honest account of what it can and cannot tell you.
Once you have four years of clean index history, the obvious question follows: what does the distribution of outcomes look like a year from here?
The 3-hour series was resampled to 1,347 daily closes and converted to log returns. Thirteen observations, single bad ticks from the upstream feed, the kind that show up as a 30% move that reverses in the next reading; were winsorised at the 0.5 and 99.5 percentiles rather than deleted, so the sample size stays intact and the tails stay heavy without being wrong.
| Model | Assumption | P5 | Median | P95 | P(above today) |
|---|
Values regenerate on every run; Monte Carlo output is stochastic, and a table that never moves would be a table of hard-coded numbers.
Fat tails stop mattering at this horizon. The daily returns have an excess kurtosis of 4.92, so the bootstrap should beat the Gaussian handily. Over 365 compounding steps it barely does, the two models land within a couple of points of each other at every percentile. That is the central limit theorem doing its job: per-step tail risk aggregates away. Kurtosis is what you model for a one-day value-at-risk number, not for an annual fan chart. Reaching for the sophisticated model here buys almost nothing, and knowing that is worth more than the model.
Nearly all of the bullish median is the drift assumption, not the simulation. Set drift to zero and the median lands back on today's price.
The drift term is doing all the work. Both fitted models put the median around 97 (a 43% gain) because they extrapolate a measurement window that happened to be mostly a bull market. The zero-drift variant resamples exactly the same returns with the mean removed, and the median collapses to roughly today's value with a coin-flip chance of finishing higher. That third button is the one that keeps the chart honest.
The roadmap the current architecture earns.
The signature verification is already correct. Persisting the subscription lifecycle behind it turns access into something that can be granted and revoked automatically, and makes churn measurable rather than inferred.
Reading the price list at build time removes the class of bug where a promotion goes live in one place and not another, and makes regional pricing a config change.
The platform already stores every market index it displays. Joining subscription cohorts to market conditions would answer the question the business actually cares about: does a rising market bring members in, or does a falling one?
The decimation logic is dependency-free, framework-agnostic, and solves a problem every dashboard hits eventually. It deserves to be reusable.
The Monte Carlo above runs in under a tenth of a second on 1,346 real returns. Run per index, with the drift assumption exposed as a control rather than buried in the model; it becomes the kind of tool a €4.99 membership is actually bought for.