# Referentie-ontvanger voor Billver-webhooks (v1.0)

Voor de integrerende partij (bv. MyDocIT). Werktitel Billver. Geen secrets in dit document.

## Contract
- `POST` naar uw HTTPS-endpoint, `content-type: application/json`.
- Headers: `x-event-id` (uniek, gebruik voor dedupe), `x-event-type`, `x-signature: t=<unix>,v1=<hex>`.
- Handtekening: `HMAC-SHA256(secret, "<t>.<ruwe body>")`, hex. Controleer op de **ruwe** body, vóór JSON-parsing.
- Weiger als `|nu − t| > 300 s` (replay). Vergelijk in constante tijd.
- Antwoord binnen 10 s met 2xx. Verwerk asynchroon. Niet-2xx = nieuwe poging (max. 8, backoff 1 min → 6 u).
- Dedupe op `x-event-id`: dezelfde melding kan meer dan eens aankomen.
- Vangnet: haal periodiek `GET /api/public/v1/events?cursor=…` op; push kan falen, pull mist niets.
- Markeer een factuur pas als beschermd wanneer `status = VERIFIED` en `protected = true`.

## TypeScript (Node / Express)
```ts
import express from "express";
import crypto from "node:crypto";

const SECRET = process.env.BILLVER_WEBHOOK_SECRET!; // nooit in de browser
const seen = new Set<string>(); // in productie: databasetabel met unieke sleutel

const app = express();
app.post("/billver/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  const m = String(req.header("x-signature") ?? "").match(/^t=(\d+),v1=([0-9a-f]{64})$/);
  if (!m) return res.sendStatus(400);
  const t = Number(m[1]);
  if (Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${body}`).digest();
  const given = Buffer.from(m[2], "hex");
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return res.sendStatus(401);

  const id = req.header("x-event-id")!;
  if (seen.has(id)) return res.sendStatus(200);
  seen.add(id);
  queueProcessing(JSON.parse(body)); // asynchroon
  res.sendStatus(200);
});
declare function queueProcessing(ev: unknown): void;
```

## C# (.NET 8 minimal API)
```csharp
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("BILLVER_WEBHOOK_SECRET")!);
var app = WebApplication.CreateBuilder(args).Build();

app.MapPost("/billver/webhook", async (HttpRequest req) =>
{
    using var reader = new StreamReader(req.Body, Encoding.UTF8);
    var body = await reader.ReadToEndAsync();
    var m = Regex.Match(req.Headers["x-signature"].ToString(), "^t=(\\d+),v1=([0-9a-f]{64})$");
    if (!m.Success) return Results.BadRequest();
    var t = long.Parse(m.Groups[1].Value);
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return Results.BadRequest();
    var expected = HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes($"{t}.{body}"));
    var given = Convert.FromHexString(m.Groups[2].Value);
    if (!CryptographicOperations.FixedTimeEquals(expected, given)) return Results.Unauthorized();

    var eventId = req.Headers["x-event-id"].ToString();
    // INSERT INTO ig_events(event_id, ...) — unieke sleutel; bij duplicaat gewoon 200 teruggeven.
    // Verwerk daarna asynchroon.
    return Results.Ok();
});
app.Run();
```

## Acceptatietests voor de ontvanger
| # | Test | Verwacht |
|---|------|----------|
| 1 | Geldige testmelding (Dashboard → Integraties → Testmelding) | 200, verwerkt |
| 2 | Body 1 byte gewijzigd | 401 |
| 3 | Verkeerd secret | 401 |
| 4 | `t` ouder dan 5 min (replay) | 400 |
| 5 | Zelfde `x-event-id` twee keer | 2× 200, 1× verwerkt |
| 6 | Endpoint 1 u offline | meldingen komen later; pull-API vult gaten |
| 7 | `registration_rejected` / `finding.created` | factuur nooit als beschermd getoond |
