diff --git a/src/App/Api/ApiEndpoints.cs b/src/App/Api/ApiEndpoints.cs
new file mode 100644
index 0000000..f26706b
--- /dev/null
+++ b/src/App/Api/ApiEndpoints.cs
@@ -0,0 +1,117 @@
+using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Costing;
+using MeterVault.Infrastructure.Dashboard;
+using MeterVault.Infrastructure.Ingestion;
+using MeterVault.Infrastructure.Normalization;
+using MeterVault.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace MeterVault.App.Api;
+
+/// Request/response contracts for the REST API (SDD §9).
+public sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
+
+public sealed record EventPush(int MeterId, DateTimeOffset Time, MeterEventType Type,
+ double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
+
+public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffComponent Component,
+ double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
+
+public sealed record IngestResult(int Written, int Updated, int Rejected);
+
+/// Maps the versioned REST API. All endpoints require a valid API key (SDD §9).
+public static class ApiEndpoints
+{
+ public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
+ {
+ var api = app.MapGroup("/api/v1").AddEndpointFilter().WithTags("MeterVault");
+
+ api.MapPost("/readings", async (ReadingPush[] readings, IngestionService ingestion, CancellationToken ct) =>
+ {
+ int written = 0, updated = 0, rejected = 0;
+ foreach (var r in readings)
+ {
+ switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct))
+ {
+ case IngestionOutcome.Written: written++; break;
+ case IngestionOutcome.Updated: updated++; break;
+ case IngestionOutcome.RejectedDecrease: rejected++; break;
+ default: break;
+ }
+ }
+
+ return Results.Ok(new IngestResult(written, updated, rejected));
+ }).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
+
+ api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
+ Results.Ok(await db.Meters.AsNoTracking()
+ .Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
+ .ToListAsync(ct)));
+
+ api.MapGet("/energy-types", async (MeterVaultDbContext db, CancellationToken ct) =>
+ Results.Ok(await db.EnergyTypes.AsNoTracking()
+ .Select(t => new { t.Id, t.Key, t.DisplayName, t.BaseUnit, Mode = t.DefaultMode.ToString() })
+ .ToListAsync(ct)));
+
+ api.MapGet("/consumption", async (int meter, DateTimeOffset from, DateTimeOffset to,
+ CostService cost, CancellationToken ct) =>
+ {
+ var buckets = await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct);
+ return Results.Ok(buckets.Select(b => new { b.Period, b.Consumption, b.Generation }));
+ }).WithSummary("Normalized monthly consumption/generation for a meter.");
+
+ api.MapGet("/cost", async (int meter, DateTimeOffset from, DateTimeOffset to,
+ CostService cost, CancellationToken ct) =>
+ Results.Ok(await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct)));
+
+ api.MapGet("/dashboard/summary", async (DashboardService dashboard, CancellationToken ct) =>
+ Results.Ok(await dashboard.GetSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow), ct)));
+
+ api.MapPost("/events", async (EventPush push, MeterVaultDbContext db,
+ NormalizationService normalization, CancellationToken ct) =>
+ {
+ db.MeterEvents.Add(new MeterEvent
+ {
+ MeterId = push.MeterId,
+ Time = push.Time,
+ EventType = push.Type,
+ Amount = push.Amount,
+ PrevValue = push.PrevValue,
+ NewValue = push.NewValue,
+ Unit = push.Unit,
+ Notes = push.Notes,
+ });
+ await db.SaveChangesAsync(ct);
+ await normalization.RecomputeMeterAsync(push.MeterId, null, ct);
+ await db.SaveChangesAsync(ct);
+ return Results.Ok();
+ }).WithSummary("Record a delivery / swap / tank level / correction and recompute the meter.");
+
+ api.MapGet("/tariffs", async (MeterVaultDbContext db, CancellationToken ct) =>
+ Results.Ok(await db.Tariffs.AsNoTracking().OrderBy(t => t.ValidFrom).ToListAsync(ct)));
+
+ api.MapPost("/tariffs", async (TariffPush push, MeterVaultDbContext db, CancellationToken ct) =>
+ {
+ var tariff = new Tariff
+ {
+ ScopeType = push.ScopeType,
+ ScopeId = push.ScopeId,
+ Component = push.Component,
+ Value = push.Value,
+ Unit = push.Unit,
+ ValidFrom = push.ValidFrom,
+ ValidTo = push.ValidTo,
+ };
+ db.Tariffs.Add(tariff);
+ await db.SaveChangesAsync(ct);
+ return Results.Created($"/api/v1/tariffs/{tariff.Id}", new { tariff.Id });
+ });
+
+ api.MapGet("/sources/status", async (MeterVaultDbContext db, CancellationToken ct) =>
+ Results.Ok(await db.MeterSources.AsNoTracking()
+ .Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus })
+ .ToListAsync(ct)));
+
+ return app;
+ }
+}
diff --git a/src/App/Api/ApiKeyFilter.cs b/src/App/Api/ApiKeyFilter.cs
new file mode 100644
index 0000000..e91688f
--- /dev/null
+++ b/src/App/Api/ApiKeyFilter.cs
@@ -0,0 +1,31 @@
+using MeterVault.Infrastructure.Options;
+using Microsoft.Extensions.Options;
+
+namespace MeterVault.App.Api;
+
+///
+/// Endpoint filter enforcing the X-Api-Key header against the configured keys (SDD §9).
+/// When no keys are configured the API is open — intended only for local development.
+///
+public sealed class ApiKeyFilter(IOptions options) : IEndpointFilter
+{
+ public const string HeaderName = "X-Api-Key";
+
+ private readonly MeterVaultOptions _options = options.Value;
+
+ public async ValueTask