← Afonso Martins, back to portfolio Case study  ·  Product & full-stack engineering  ·  2025-2026

Wexxim

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.

Role
Design & full-stack build
Timeline
Oct 2025-Feb 2026
150 commits
Stack
Next.js 16 · React 19
TypeScript · Payments
Scale
18 pages · 15 API routes
11,210 lines of TS
Outcome
≈€35k annual run-rate
Reached before the product paused
01

The brief

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.

02

Outcome

Where the business stood when operations paused in February 2026.

Annual run-rate ≈€35,000 Annualised from the active membership mix, not a full year of booked revenue.
Price points €4.99 /mo €34.99/year, a 41.6% discount that pulls cash forward and cuts churn exposure.
Live market indexes 7 Cases by release era, agents, charms, playskins, and a whole-market aggregate.
Paid API keys 0 Four external data sources, all free tier or public feed. Marginal data cost: nothing.

How the pricing works

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.

Monthly €4.99 per member, per month
Annual €34.99 41.6% below twelve monthly payments
Membership Close to 1,000 members at the run-rate above
03

System design

Four external feeds in, one subscription product out, and a rendering strategy chosen per route rather than globally.

Data sources
Index provider7 category indexes · one bulk call
Market-cap feedwhole-market valuation
Volume feedunboxing volume & events
Channel RSSpublic feed, no API key, no quota
Application
Server components/indexes, ISR, revalidate 3600
Route handlersproxy layer, keeps origins server-side
Client chartshand-built, zero chart dependency
Edge middlewarepasses webhooks through untouched
Commerce & ops
hosted checkouthosted, no card data touches the app
Signed webhooksraw-body signature verification
Managed Postgresadmin console · row-level security
Self-hosted storefunnel & click analytics

Three decisions worth defending

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.

04

The subscription system

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.

Step 1

Plan selection

Monthly/annual toggle with the saving shown as a percentage, not a number to work out.

app/discord/page.tsx
Step 2

Session creation

Server-side checkout.sessions.create in subscription mode. Price IDs live in environment config; promotion codes enabled.

api/checkout/create-session
Step 3

Hosted checkout

The provider's own page handles cards, SCA, and receipts. No card data ever reaches the application; PCI scope stays at the minimum.

hosted checkout
Step 4

Signed webhook

Raw 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 · middleware

The detail most webhook implementations get wrong

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

// api/checkout/webhook/route.ts, raw body first, parse second // ✗ const body = await request.json() // signature will never verify const body = await request.text() const signature = request.headers.get('provider-signature') // throws on tampering, replay, or a mismatched secret const event = provider.webhooks.constructEvent(body, signature, webhookSecret)
// middleware.ts, keep the webhook path out of every rewrite export function middleware(request: NextRequest) { if (request.nextUrl.pathname === '/api/checkout/webhook') { return NextResponse.next() // untouched bytes } return NextResponse.next() }
05

Engineering deep dive: the charts were lying

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.

Server

One call, seven indexes

A single bulk request for all seven slugs, cached for an hour with ISR.

next: { revalidate: 3600 }
Server

Normalise

Labels arrive as epoch-millisecond strings and values as strings. Both are coerced into a { date, value } shape once, on the server.

parseInt · parseFloat
Client

Reduce to 51 bars

Stride sampling: take every n/50-th point. Cheap, obvious, and the source of the bug.

index-chart.tsx:53
Client

Scale & draw

Bar height is normalised against the min and max of the full series, not the sample.

index-chart.tsx:109

The mismatch

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

Cases Index 2016-2019
10,588 points · 3-hour resolution · Sep 2022 → Jul 2026 · live data
rendered bars (51) full series (10,588 points) all-time high 108.36

The fix

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.

// components/charts/index-chart.tsx // ✗ before, every 211th point; peaks survive only by luck const step = Math.max(1, Math.floor(data.length / 50)) const displayData = data.filter((_, i) => i % step === 0) // ✓ after, bucket decimation, extremes preserved, last point pinned const BARS = 51 const width = data.length / BARS const displayData = Array.from({ length: BARS }, (_, b) => { const slice = data.slice(Math.floor(b * width), Math.floor((b + 1) * width)) if (!slice.length) return null const mean = slice.reduce((a, p) => a + p.value, 0) / slice.length // keep whichever extreme deviates further, preserves spikes AND troughs return slice.reduce((best, p) => Math.abs(p.value - mean) > Math.abs(best.value - mean) ? p : best) }).filter(Boolean) // the right edge of a market chart must be the latest reading displayData[displayData.length - 1] = data[data.length - 1]

Impact across the platform

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.

IndexPointsStride True highPeak drawn, beforeAfter
Cases 2016-201910,588211108.36missedshown
Cases 2020-202210,58721130.58missedshown
Cases pre-201510,588211443.54missedshown
Agents9,2211842,715.86missedshown
Charms4,6009212,566.03missedshown
All items10,00920028,473,085.94keptshown
Playskin inventory3,943781,307.72missedshown

Verified against live API responses on 30 July 2026. Stride column is floor(points / 50) as computed by the shipped component.

06

Where does the index go next?

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.

Daily observations1,347 Sep 2022 → Jul 2026, resampled from 10,588 three-hour readings.
Annualised volatility47.9% σ = 0.0251 daily. Roughly three times a broad equity index.
Annualised drift34.5% μ = 0.000945 daily. Measured over a period that was mostly a bull market.
Excess kurtosis4.92 Materially fat-tailed, which is why the primary model resamples history instead of assuming a bell curve.
Cases Index 2016-2019, 365-day forecast distribution
2,000 paths · simulated live in your browser
actual, last 365 days 5th-95th percentile 25th-75th percentile median path all-time high 108.36
ModelAssumptionP5Median P95P(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.

What the model showed

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.

What this model does not know

  • Valve controls supply. A single game update that changes drop rates or opens a new case invalidates the entire return history the model is sampling from.
  • Returns are assumed independent across days. Real markets cluster volatility; calm follows calm, and crashes follow crashes.
  • Four years is a short window for an asset class this young, and it contains no full bear cycle to learn from.
  • This is a distribution of outcomes under stated assumptions. It is not a forecast, and nothing on this page is investment advice.
07

What I'd build next

The roadmap the current architecture earns.

  1. Entitlement service on top of the webhook

    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.

  2. Prices sourced from the provider, not from JSX

    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.

  3. Cohort retention against the index data

    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?

  4. Extract the chart into a standalone package

    The decimation logic is dependency-free, framework-agnostic, and solves a problem every dashboard hits eventually. It deserves to be reusable.

  5. Ship the simulation as a member feature

    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.