CBAM certificate costs are directly tied to EU ETS allowance prices — for the 2026 compliance year it's a quarterly average, from 2027 it switches to a weekly average of EU ETS auction closing prices. That sounds like a simple "call an API, store a number" problem. It isn't, and the reasons why taught me more about building reliable data pipelines than anything else in this project.
I'm building CBAMTrack, a CBAM compliance platform for EU importers and non-EU exporters, solo, on Next.js App Router + TypeScript + Prisma/PostgreSQL, hosted on Northflank behind Cloudflare. Here's how the ETS price tracker actually works under the hood.
Why "just call an API" doesn't hold up
A compliance tool can't have an "unlucky day." If our price feed goes down and a customer generates a CBAM cost estimate off a stale or missing value, that's not a cosmetic bug — it's a number someone might actually budget against. So the design constraint from day one was: the system must never silently serve a wrong or missing price. It either serves a verified price or it visibly tells you it couldn't.
That single constraint is what turned this from a fetch-and-cache script into a fallback chain.
The fallback chain
1. Primary source: [YOUR PRIMARY EU ETS PRICE SOURCE]
2. Secondary source: [YOUR FALLBACK SOURCE, if primary fails or is stale]
3. Last verified value: cached in Postgres, used with an explicit
"stale as of [date]" flag surfaced in the UI
4. Manual override: an internal admin path to enter a verified price
by hand if both automated sources fail for more than N hours
Each tier only activates if the one above it fails or returns data outside a sanity-check range (a >15% single-day swing gets flagged for review rather than trusted blindly — carbon markets are volatile, but not usually that volatile).
Storing prices the way CBAM actually needs them
The tricky part isn't fetching a price — it's storing it in a shape that supports both compliance-year rules at once, since 2026 uses a quarterly average and 2027 onward uses a weekly average.
// Simplified schema
model EtsPrice {
id String @id @default(cuid())
date DateTime @unique
priceEurPerTonne Decimal
source String // which tier of the fallback chain produced this
isStale Boolean @default(false)
fetchedAt DateTime @default(now())
}
Storing raw daily prices — not pre-computed averages — turned out to be the right call. It meant when the 2026→2027 rule change landed, I didn't have to backfill anything. The averaging logic just reads a different window.
Postgres, not Redis
The obvious instinct for a "fetch a price, cache it, serve it fast" problem is Redis. I didn't use it, and I don't think I will.
Here's the reasoning: the read pattern for ETS prices isn't "give me the latest value as fast as possible" — it's "give me the volume-weighted average over this specific window, where the window definition itself depends on which compliance year we're calculating for." That's a query with a WHERE date BETWEEN and a GROUP BY, not a key lookup. Redis is excellent at the thing I don't need (sub-millisecond single-key reads) and clumsy at the thing I do need (windowed aggregation over a time series with a variable boundary rule).
Postgres gives me that aggregation for free, in SQL, against data that's already sitting next to every other table the calculation engine touches — no cross-service joins, no second system to keep in sync, no cache-invalidation bugs when a price gets corrected after the fact (which does happen; auction data occasionally gets revised after publication).
There's also a boring but real reason: I'm a solo founder. Every extra piece of infrastructure is something I get paged for at 2am. A Redis instance earns its keep when you have a genuine hot-path latency problem. A handful of daily price rows, queried a few hundred times a day, does not have that problem. Postgres with an index on date handles it without me thinking about it again.
If CBAMTrack ever needs sub-second price lookups at real scale, I'll revisit. I'd rather add that complexity when the data forces my hand than because a blog post told me Redis is what "real" caching looks like.
normalizePeriod() and displayPeriod()
CBAM reporting periods don't map cleanly onto calendar quarters once you factor in the transition between the quarterly-average rule (2026) and weekly-average rule (2027+). I ended up writing two small helpers that get used everywhere in the reporting engine, not just the price tracker:
// normalizePeriod: takes a raw date range and returns the
// canonical CBAM reporting period it belongs to, accounting
// for the 2026/2027 averaging-rule boundary
function normalizePeriod(date: Date): CbamPeriod { ... }
// displayPeriod: takes a normalized period and returns the
// human-readable label used across the UI and PDF reports
function displayPeriod(period: CbamPeriod): string { ... }
Having a single source of truth for "what period does this date belong to, and what do we call it" saved me from a category of bug I hit early on: a report and its underlying price data disagreeing about which quarter they were describing because one code path used raw dates and another used the period label. Small helper, disproportionate bug reduction.
What I'd do differently
If I were starting over, I'd build the sanity-check/anomaly-flagging logic before the happy-path fetch-and-store logic, not after. I added it reactively once I saw what a bad data day actually looked like in staging — a source returning a cached value from three weeks earlier without any indication it was stale. In a compliance product, "silently wrong" is a worse failure mode than "loudly broken," and I'd design for that assumption from the start next time.
Why this matters beyond CBAM
If you're building anything that depends on an external price or regulatory feed — carbon markets, FX rates, commodity indices — the same three questions are worth asking before you write the fetch logic:
- What happens the day your source is down?
- What happens the day your source returns a number that's technically valid but wrong?
- Does your storage layer assume today's aggregation rule will still be true next year?
Getting those three answers right up front is cheaper than retrofitting them after a customer asks why their number changed.
I'm building CBAMTrack as a solo founder — EU CBAM compliance reporting for SME importers and Gulf/MENA exporters. Live ETS prices and the certificate cost calculator this pipeline feeds are at cbamtrack.com/calculator. If you're dealing with CBAM data pipelines yourself, I'd genuinely like to compare notes in the comments.
Top comments (0)