A homeowner in Lille wants to insulate their roof and install a heat pump. They could be eligible for MaPrimeRenov, CEE certificates, a zero-interest Eco-PTZ loan, and reduced VAT — four separate schemes, each with its own income thresholds, work-type restrictions, and ceiling amounts. Cross the border into Belgium and it's a different set of regional premiums. Go to Germany and you're dealing with KfW loans and BAFA grants.
We built GreenCalc to turn all of that into a single POST request. Here's what we learned.
The problem: 15 schemes, 5 countries, rules that change every January
The EU renovation subsidy landscape is a mess — by design. Each country runs its own programs with its own eligibility rules. Here's what we're dealing with:
| Country | Main schemes | Income-based? | Climate zones? | Update frequency |
|---|---|---|---|---|
| France | MaPrimeRenov, CEE, Eco-PTZ, TVA 5.5% | Yes (4 categories) | Yes (H1/H2/H3) | January + July |
| Germany | KfW 262, BEG EM, BAFA audit | No (flat rates) | No | Variable |
| Italy | Superbonus, Ecobonus, Bonus Casa | No | No | When the government changes |
| Belgium | Primes Wallonie, Renolution Bruxelles, Mijn VerbouwPremie | Yes (per region) | No | January |
| Spain | PREE, MOVES III | Partially | No | Variable |
A fintech developer who wants to show users "how much subsidy can you get?" would need to become a regulatory expert in five countries. Or call one API.
Decision #1: Config-driven, not code-driven
The most important architecture decision was this: subsidy rates live in the database, not in Java code.
When France updates MaPrimeRenov thresholds every January, we run one SQL statement:
UPDATE greencalc.subsidy_scheme_rates
SET rate_value = 30.00,
updated_at = NOW()
WHERE country_code = 'FR'
AND scheme_code = 'ma_prime_renov'
AND work_type = 'ROOF_INSULATION'
AND income_category = 'MODEST';
No code change. No JAR rebuild. No deployment. The rate is read from PostgreSQL on every request, so changes are live instantly.
The subsidy_scheme_rates table has this shape:
country_code | scheme_code | work_type | income_category | rate_type | rate_value | max_amount | unit
FR | ma_prime_renov | ROOF_INSULATION | VERY_MODEST | FIXED | 75.00 | 6000.00 | PER_M2
FR | ma_prime_renov | ROOF_INSULATION | MODEST | FIXED | 60.00 | 4800.00 | PER_M2
FR | cee | ROOF_INSULATION | ALL | FIXED | 12.00 | NULL | PER_M2
DE | kfw_beg | HEAT_PUMP | ALL | PERCENTAGE | 25.00 | 15000.00 | FIXED
This means non-technical staff can update rates with a SQL client. The code only contains the calculation logic — how to combine rates with household income, surface area, and work types.
Decision #2: The Strategy pattern for multi-country logic
Each country has wildly different rules. France has income-based categories that depend on climate zone. Germany has flat percentage grants. Italy has cascading bonuses that interact with each other.
We needed each country to be its own self-contained module, while the simulation engine stays generic. Classic Strategy pattern:
public interface ICountryRulesProvider {
String getCountryCode();
String determineIncomeCategory(
BigDecimal fiscalIncome, int householdSize, String zone);
List<EligibleSubsidy> calculateSubsidies(
List<PlannedWork> plannedWorks,
String incomeCategory,
String climateZone,
BigDecimal totalEstimatedCost);
}
Each country implements this. Here's a simplified view of the France provider:
@Service
public class FranceRulesProvider implements ICountryRulesProvider {
// 2026 income thresholds for zones H1/H2
// Index: [household_size - 1][very_modest, modest, intermediate]
private static final int[][] THRESHOLDS_H1_H2 = {
{17009, 21805, 30549}, // 1 person
{24875, 32000, 44907}, // 2 persons
{29917, 38442, 54071}, // 3 persons
{34948, 44884, 63235}, // 4 persons
{40002, 51326, 72400} // 5+ persons
};
@Override
public String getCountryCode() { return "FR"; }
@Override
public String determineIncomeCategory(
BigDecimal fiscalIncome, int householdSize, String zone) {
int[][] thresholds = "H3".equalsIgnoreCase(zone)
? THRESHOLDS_H3 : THRESHOLDS_H1_H2;
int index = Math.min(householdSize, 5) - 1;
int[] bracket = thresholds[index];
int income = fiscalIncome.intValue();
if (income <= bracket[0]) return "VERY_MODEST";
if (income <= bracket[1]) return "MODEST";
if (income <= bracket[2]) return "INTERMEDIATE";
return "HIGH";
}
@Override
public List<EligibleSubsidy> calculateSubsidies(
List<PlannedWork> works, String incomeCategory,
String climateZone, BigDecimal totalCost) {
List<EligibleSubsidy> subsidies = new ArrayList<>();
for (PlannedWork work : works) {
// Per-work schemes: rates loaded from DB
calculateMaPrimeRenov(work, incomeCategory)
.ifPresent(subsidies::add);
calculateCee(work, climateZone)
.ifPresent(subsidies::add);
}
// Cross-work schemes
calculateEcoPtz(works, totalCost).ifPresent(subsidies::add);
calculateTvaReduite(totalCost).ifPresent(subsidies::add);
return subsidies;
}
}
The registry auto-discovers all providers via Spring DI:
@Service
public class CountryRulesRegistry {
private final Map<String, ICountryRulesProvider> providers;
public CountryRulesRegistry(List<ICountryRulesProvider> providerList) {
this.providers = providerList.stream()
.collect(Collectors.toMap(
p -> p.getCountryCode().toUpperCase(),
p -> p
));
log.info("Initialized with {} providers: {}",
providers.size(), providers.keySet());
}
public ICountryRulesProvider getProvider(String countryCode) {
ICountryRulesProvider provider = providers.get(
countryCode.toUpperCase());
if (provider == null)
throw new BusinessValidationException(
"UNSUPPORTED_COUNTRY",
"Country not supported: " + countryCode);
return provider;
}
}
Adding a sixth country? Create a new @Service that implements ICountryRulesProvider. The registry picks it up automatically. Zero changes to the simulation engine.
Normalizing work types across languages
"Isolation des combles" in France is "Dachdammung" in Germany and "Isolamento del tetto" in Italy. If your API accepts free-text work descriptions, you need NLP in five languages. We went with normalized codes instead:
Code | FR | DE | IT
ROOF_INSULATION | isolation_combles | dachdaemmung | isolamento_tetto
HEAT_PUMP_AIR_WATER | pac_air_eau | waermepumpe_luft | pompa_calore_aria
WALL_INSULATION_EXTERIOR | ite | wdvs | cappotto_termico
WINDOWS_DOUBLE_GLAZING | fenetres_double_vitrage | fenster_2fach | finestre_doppio_vetro
SOLAR_PANELS_PHOTOVOLTAIC | panneaux_pv | photovoltaik | pannelli_fotovoltaici
The API consumer always sends ROOF_INSULATION regardless of which country they're querying. Our mapping table converts it to the local code before looking up rates.
This solved an annoying integration problem: developers who integrate GreenCalc don't need five separate dropdown menus with localized work types. One universal set of 11 codes works everywhere.
Automated regulatory monitoring
The scariest part of running a subsidy API isn't building it — it's keeping it current. When France publishes new MaPrimeRenov thresholds on January 1st, how quickly do you notice?
We built a watcher that hashes the HTML of 10 official government URLs daily:
@Service
@ConditionalOnProperty(
name = "greencalc.regulatory.enabled",
havingValue = "true")
public class RegulatoryWatcherService {
@Scheduled(cron = "${greencalc.regulatory.cron:0 0 6 * * *}")
public void scheduledCheck() {
List<RegulatorySource> sources =
sourceRepository.findByActiveTrue();
for (RegulatorySource source : sources) {
HttpResponse<String> response = httpClient.send(
HttpRequest.newBuilder()
.uri(URI.create(source.getSourceUrl()))
.GET().build(),
HttpResponse.BodyHandlers.ofString());
String newHash = sha256(response.body());
if (!newHash.equals(source.getLastContentHash())) {
// Content changed — create alert for review
createAlert(source, "CONTENT_CHANGED",
source.getLastContentHash(), newHash);
}
source.setLastContentHash(newHash);
sourceRepository.save(source);
}
}
static String sha256(String content) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(
content.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
}
}
When a hash changes, an alert appears in our admin panel. A human reviews it, updates the rates in the database, and logs the change to a public changelog at GET /api/v1/eligibility/changelog.
It's not perfect — some government sites are SPAs where the hash changes on every load due to CSRF tokens. We're considering diffing the text content instead. But for static pages (which most official subsidy pages are), SHA-256 comparison catches 100% of real changes.
The result: one POST, all subsidies
Here's a real request to the sandbox (no API key needed):
curl -X POST https://greencalc.io/api/v1/eligibility/simulate \
-H "X-Api-Key: gc_sandbox_000000000000000000000000000000000" \
-H "Content-Type: application/json" \
-d '{
"country_code": "FR",
"household": {
"annual_income": 25000,
"household_size": 3,
"is_owner": true
},
"property": {
"type": "HOUSE",
"construction_year": 1975,
"energy_rating": "E",
"postal_code": "59000",
"surface_m2": 100
},
"planned_works": [
{"work_type": "ROOF_INSULATION",
"estimated_cost_eur": 8000, "surface_m2": 80},
{"work_type": "HEAT_PUMP_AIR_WATER",
"estimated_cost_eur": 12000}
]
}'
Response (sandbox mode — amounts are illustrative):
{
"simulation_id": "ef2d5406-7216-4802-853d-116d32494fa4",
"country": {
"code": "FR",
"name": "France",
"schemes_last_updated_at": "2026-01-01"
},
"household_category": "SANDBOX",
"eligible_subsidies": [
{
"scheme_code": "ma_prime_renov",
"scheme_name": "MaPrimeRenov",
"provider": "ANAH",
"type": "GRANT",
"amount_eur": 5000.0,
"conditions": [
"Primary residence",
"Property at least 15 years old"
],
"cumulative": true
},
{
"scheme_code": "cee",
"scheme_name": "Certificats d'Economies d'Energie",
"type": "GRANT",
"amount_eur": 3200.0
},
{
"scheme_code": "eco_ptz",
"scheme_name": "Eco-PTZ (zero-interest loan)",
"type": "LOAN",
"amount_eur": 20000.0
},
{
"scheme_code": "tva_reduite",
"scheme_name": "TVA 5.5% (reduced VAT)",
"type": "VAT_REDUCTION",
"amount_eur": 2900.0
}
],
"summary": {
"total_estimated_cost_eur": 20000.0,
"total_grants_eur": 8200.0,
"total_loans_eur": 20000.0,
"total_tax_savings_eur": 2900.0,
"estimated_out_of_pocket_eur": 8900.0,
"estimated_new_energy_rating": "C",
"estimated_energy_saving_percent": 35.0
}
}
For a household spending 20,000 EUR on renovations: 8,200 EUR in grants, 20,000 EUR in zero-interest loans, 2,900 EUR in tax savings. Estimated out-of-pocket: 8,900 EUR. All from one API call.
Try it yourself
The sandbox requires no signup — just a fixed API key:
# Swap "FR" for "DE", "IT", "BE", or "ES"
curl -X POST https://greencalc.io/api/v1/eligibility/simulate \
-H "X-Api-Key: gc_sandbox_000000000000000000000000000000000" \
-H "Content-Type: application/json" \
-d '{"country_code":"DE","household":{"annual_income":50000,"household_size":2,"is_owner":true},"property":{"type":"HOUSE","energy_rating":"F","postal_code":"10115","surface_m2":120},"planned_works":[{"work_type":"WALL_INSULATION_EXTERIOR","estimated_cost_eur":35000,"surface_m2":150}]}'
Interactive docs: greencalc.io/docs
Regulatory changelog: greencalc.io/api/v1/eligibility/changelog
GreenCalc is built by AZMORIS Group. The stack is Java 21, Spring Boot 3.3, PostgreSQL 16, and too many government PDFs.
Top comments (0)