Build Story

Real-Time Electricity Prices: Building Wattora with the ENTSO-E API

April 2026 · 10 min read

European electricity prices change every hour. Since the energy crisis of 2022, millions of households across Europe are on dynamic tariffs where they pay the actual spot price rather than a flat rate. The catch: you need to know when prices are cheap to shift your consumption — run the dishwasher, charge the car, heat the water — and avoid the peak hours that can cost ten times as much.

This is the story of building Wattora, an iOS and macOS app that turns raw ENTSO-E data into something you can glance at on your home screen and make immediate decisions.

What Is ENTSO-E and Why Does It Matter?

ENTSO-E (European Network of Transmission System Operators for Electricity) is the organization that coordinates electricity transmission across 36 European countries. Their Transparency Platform publishes day-ahead and intraday electricity prices for every bidding zone in Europe. A bidding zone is the price region — Spain is one zone, Germany-Luxembourg is another, and some countries like Italy are split into six.

The API is free with registration, returns XML (not JSON), and provides data going back years. Day-ahead prices for tomorrow are published around 13:00 CET, which means an app can show users what electricity will cost for every hour of the next day before they go to bed.

The API: REST with XML Responses

The ENTSO-E Transparency Platform API is straightforward in concept but requires specific knowledge to use correctly. Every request needs a security token and uses a document type identifier to specify what you want. For day-ahead prices, that is A44:

GET https://web-api.tp.entsoe.eu/api
  ?securityToken={token}
  &documentType=A44
  &in_Domain=10YES-REE------0   // Spain
  &out_Domain=10YES-REE------0
  &periodStart=202604250000
  &periodEnd=202604260000

The in_Domain and out_Domain parameters use EIC codes — standardized identifiers for each bidding zone. Spain is 10YES-REE------0, Germany-Luxembourg is 10Y1001A1001A82H, and France is 10YFR-RTE------C. The full list is roughly 35 zones. You need to know these by heart or keep a mapping table.

The response comes back as XML, not JSON. Here is a simplified version of what a single price point looks like:

<TimeSeries>
  <Period>
    <timeInterval>
      <start>2026-04-25T22:00Z</start>
      <end>2026-04-26T22:00Z</end>
    </timeInterval>
    <resolution>PT60M</resolution>
    <Point>
      <position>1</position>
      <price.amount>42.37</price.amount>
    </Point>
    <Point>
      <position>2</position>
      <price.amount>38.91</price.amount>
    </Point>
    <!-- ... 22 more points -->
  </Period>
</TimeSeries>

Note the timestamps are always in UTC. Prices are in EUR/MWh. The position field is 1-indexed and represents the hour within the period. A full day has 24 points (or 23/25 on DST transition days, which is a separate headache).

Parsing XML in Swift

Swift has XMLParser built into Foundation, but it uses a delegate-based callback pattern that produces verbose, hard-to-follow code. For Wattora, we wrapped it in a structured approach:

final class ENTSOEParser: NSObject, XMLParserDelegate {
    private var prices: [HourlyPrice] = []
    private var currentElement = ""
    private var currentPosition: Int?
    private var currentPrice: Double?
    private var periodStart: Date?

    func parser(_ parser: XMLParser,
                didStartElement elementName: String, ...) {
        currentElement = elementName
    }

    func parser(_ parser: XMLParser,
                foundCharacters string: String) {
        switch currentElement {
        case "position":
            currentPosition = Int(string.trimmingCharacters(in: .whitespaces))
        case "price.amount":
            currentPrice = Double(string.trimmingCharacters(in: .whitespaces))
        case "start" where periodStart == nil:
            periodStart = ISO8601DateFormatter().date(from:
                string.trimmingCharacters(in: .whitespaces))
        default: break
        }
    }

    func parser(_ parser: XMLParser,
                didEndElement elementName: String, ...) {
        if elementName == "Point",
           let pos = currentPosition,
           let price = currentPrice,
           let start = periodStart {
            let hour = Calendar.current.date(
                byAdding: .hour, value: pos - 1, to: start)!
            prices.append(HourlyPrice(
                date: hour,
                priceEURperMWh: price
            ))
            currentPosition = nil
            currentPrice = nil
        }
    }
}

The parsed output is an array of HourlyPrice structs — each with a UTC timestamp and a price in EUR/MWh. We convert to the user's local timezone and from MWh to kWh (divide by 1000) at the display layer, never in the data layer. This avoids compounding rounding errors across timezone and unit conversions.

30+ Bidding Zones, One Data Model

Wattora supports over 30 European bidding zones. Each zone has its own EIC code, display name, country flag, and typical price range. We model this as a Swift enum:

enum BiddingZone: String, CaseIterable, Codable {
    case spain = "10YES-REE------0"
    case germany = "10Y1001A1001A82H"
    case france = "10YFR-RTE------C"
    case netherlands = "10YNL----------L"
    case italy_north = "10Y1001A1001A73I"
    case italy_south = "10Y1001A1001A788"
    case finland = "10YFI-1--------U"
    case denmark_west = "10YDK-1--------W"
    case denmark_east = "10YDK-2--------M"
    case norway_south = "10YNO-1--------2"
    // ... 20+ more zones

    var displayName: String { ... }
    var flag: String { ... }
    var defaultCurrency: Currency { ... }
}

Italy alone has six bidding zones (North, Central-North, Central-South, South, Sardinia, Sicily). Scandinavia has separate zones for different regions within the same country. Getting this mapping right matters: a user in Milan needs italy_north, not italy_south, or the prices on their home screen are wrong.

Day-Ahead vs. Intraday Prices

The European electricity market operates in two stages. Day-ahead prices are published around 13:00 CET and cover the next day from midnight to midnight. Intraday prices are continuous adjustments as real-time supply and demand shift.

For a consumer app, day-ahead is what matters. The moment tomorrow's prices are published, Wattora fetches them and the user can plan their evening and next morning. We poll for new data at 13:00, 13:15, and 13:30 CET — sometimes the publication is delayed by a few minutes — and stop once we have a full 24-point series for the next day.

Caching Strategy

Electricity prices for a given hour in a given zone do not change once published. This makes caching straightforward. We use a two-layer approach:

  • In-memory cache: The current day's and tomorrow's prices (if available) are held in memory for instant access by the UI and widgets. This covers the hot path — every chart render, every widget refresh, every complication update reads from this cache.
  • Disk cache: All fetched data is persisted to an App Group container (shared between the main app, widgets, and complications) as JSON. On cold launch, the app hydrates from disk before making any network requests.

The App Group container is the key architectural decision. Without it, the main app and the widget extension would maintain separate caches and make separate network requests. With it, the main app fetches once and the widget reads the same data — no duplicate API calls, no risk of showing different prices in the widget vs. the app.

let sharedDefaults = UserDefaults(
    suiteName: "group.com.wattora.app")

// Write (main app)
func cachePrices(_ prices: [HourlyPrice], zone: BiddingZone) {
    let key = "prices_\(zone.rawValue)_\(dateKey)"
    let data = try? JSONEncoder().encode(prices)
    sharedDefaults?.set(data, forKey: key)
}

// Read (widget extension)
func loadCachedPrices(zone: BiddingZone) -> [HourlyPrice]? {
    let key = "prices_\(zone.rawValue)_\(dateKey)"
    guard let data = sharedDefaults?.data(forKey: key) else {
        return nil
    }
    return try? JSONDecoder().decode([HourlyPrice].self, from: data)
}

WidgetKit: The Timeline Provider Pattern

Widgets are the primary interface for an energy price app. You do not open the app to check prices — you glance at your home screen. WidgetKit requires a TimelineProvider that returns a series of entries with timestamps. The system renders the correct entry at the right time.

For Wattora, each timeline entry represents one hour:

struct PriceEntry: TimelineEntry {
    let date: Date
    let currentPrice: Double
    let nextCheapestHour: Date?
    let nextCheapestPrice: Double?
    let dayPrices: [HourlyPrice]
    let zone: BiddingZone
}

struct PriceTimelineProvider: TimelineProvider {
    func getTimeline(in context: Context,
                     completion: @escaping (Timeline<PriceEntry>) -> Void) {
        let prices = loadCachedPrices(zone: selectedZone) ?? []
        var entries: [PriceEntry] = []

        for price in prices {
            let cheapest = prices
                .filter { $0.date > price.date }
                .min(by: { $0.priceEURperMWh < $1.priceEURperMWh })

            entries.append(PriceEntry(
                date: price.date,
                currentPrice: price.priceEURperMWh / 1000,
                nextCheapestHour: cheapest?.date,
                nextCheapestPrice: cheapest.map {
                    $0.priceEURperMWh / 1000
                },
                dayPrices: prices,
                zone: selectedZone
            ))
        }

        let timeline = Timeline(entries: entries,
                                policy: .after(nextRefreshDate()))
        completion(timeline)
    }
}

The policy: .after(nextRefreshDate()) is calculated to fire shortly after 13:00 CET when tomorrow's prices are expected. Until then, the existing timeline covers every hour transition.

Price Alert Notifications

Users can set a price threshold: "Notify me when electricity drops below X cents/kWh." When tomorrow's prices arrive, Wattora schedules local notifications for every hour that falls below the threshold. No push notification server needed — everything runs on-device with UNUserNotificationCenter:

func scheduleAlerts(prices: [HourlyPrice], threshold: Double) {
    let center = UNUserNotificationCenter.current()
    center.removePendingNotificationRequests(
        withIdentifiers: prices.map { "price_\($0.date)" })

    for price in prices where price.pricePerKWh < threshold {
        let content = UNMutableNotificationContent()
        content.title = String(localized: "alert_cheap_title")
        content.body = String(localized: "alert_cheap_body_\(
            price.pricePerKWh, format: .number.precision(
                .fractionLength(1)))")

        let trigger = UNCalendarNotificationTrigger(
            dateMatching: Calendar.current.dateComponents(
                [.year, .month, .day, .hour],
                from: price.date),
            repeats: false)

        let request = UNNotificationRequest(
            identifier: "price_\(price.date)",
            content: content, trigger: trigger)
        center.add(request)
    }
}

DST Transitions: The 23-Hour and 25-Hour Day

Twice a year, European countries shift clocks. In spring, clocks jump forward and one hour disappears — the ENTSO-E API returns 23 data points instead of 24. In autumn, clocks fall back and one hour repeats — 25 data points.

This breaks naive assumptions everywhere: chart rendering code that expects 24 bars, widget timelines that assume hour + 1 always produces a valid next entry, and notification scheduling that targets a clock hour that does not exist.

The fix is to never work with clock hours directly. All internal timestamps are UTC. The position field from ENTSO-E maps directly to UTC offsets from the period start. The display layer converts to local time only at render, using Calendar.current which is aware of DST transitions. On a 23-hour day, the chart has 23 bars. On a 25-hour day, it has 25. The layout code adapts rather than forcing a fixed grid.

What We Learned

Building Wattora reinforced a few principles that apply to any app that depends on external data:

  • Cache aggressively, invalidate precisely. Electricity prices for a past hour will never change. Cache them forever. But the "current hour" marker needs to update every 60 minutes. Mixing these two concerns in one cache layer causes bugs.
  • App Group containers are not optional for widget apps. Without shared data, the widget makes its own network requests on an unpredictable schedule, leading to stale data and wasted API quota.
  • XML is fine. The ENTSO-E API predates the JSON-everywhere era. Swift's XMLParser is verbose but reliable. We considered converting to a third-party XML library but Foundation's built-in parser has zero dependencies and handles every edge case we have encountered.
  • Timezone-correct code is harder than timezone-aware code. Displaying "14:00" in the user's timezone is easy. Handling the hour that does not exist during spring DST transition is hard. The fix is to keep everything in UTC until the last possible moment.

Wattora is available on the App Store for iOS and macOS. It covers 30+ European bidding zones, includes home screen widgets, and is localized in over 25 languages.

Related Posts

Want me to build something for you?

15 minutes. No pitch. Just honest advice on whether I can help.

Book Free Audit