# Webhooks

Agreli stuurt een ondertekend bericht naar je server zodra er iets verandert aan een onboarding. Berichten bevatten geen persoonsgegevens: details haal je op met de API.

## Een adres registreren

Registreer een adres in het developer dashboard of met de API. Het geheim (`whsec_…`) staat alleen in het antwoord; bewaar het veilig.

```bash
curl -X POST https://app.agreli.nl/api/public/v1/webhooks \
  -H "Authorization: Bearer $AGRELI_API_KEY" \
  -H "Idempotency-Key: 9a3e…" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://jouwapp.nl/webhooks/agreli", "description": "Productie", "events": ["onboarding.approval_required", "onboarding.completed"] }'
```

- Alleen `https`, naar een publiek bereikbaar adres en zonder inloggegevens in de URL. Vlak voor elke aflevering zoeken we de naam op en weigeren we interne adressen.
- Laat `events` weg of leeg voor alle gebeurtenissen; de API toont dat als `["*"]`. Een onbekende naam geeft `422 events_invalid`.
- Hooguit 20 adressen per organisatie.
- Een adres dat je met een testsleutel registreert, hoort bij je sandbox en krijgt alleen gebeurtenissen uit de sandbox.

## Gebeurtenissen

| Gebeurtenis | Ook verstuurd als | Wanneer | Extra in `data` |
| --- | --- | --- | --- |
| `onboarding.created` | — | Onboarding aangemaakt, via de API of het dashboard. | — |
| `onboarding.invitation_sent` | — | Uitnodiging, nieuwe link of herinnering naar de medewerker verstuurd (niet bij de contractmail). | `kind`: `invitation`, `new_link` of `reminder` |
| `onboarding.started` | — | De medewerker is begonnen met invullen. | — |
| `onboarding.employee_submitted` | `onboarding.submitted` | De medewerker heeft de gegevens verstuurd. | — |
| `onboarding.approval_required` | — | Er is een controle nodig: na het versturen, of opnieuw als gewijzigde voorwaarden goedkeuringen lieten vervallen. | — |
| `onboarding.returned` | — | Teruggestuurd voor correctie. | `returned_to` |
| `onboarding.approval_given` | — | Eén goedkeuring gegeven. | `role`: `manager`, `payroll_reviewer` of `owner` |
| `onboarding.approved` | — | Alle vereiste goedkeuringen zijn binnen. | — |
| `onboarding.terms_changed` | — | Arbeidsvoorwaarden gewijzigd. | — |
| `contract.sent` | — | Contract ter ondertekening verstuurd. | — |
| `contract.signed_by_employee` | — | De medewerker heeft getekend. | — |
| `onboarding.contract_signed` | `contract.signed` | De werkgever heeft ook getekend; het contract is rond. | — |
| `integration.sync_succeeded` | `payroll.completed` | De medewerker staat in de salarisadministratie. | `connector` |
| `integration.sync_failed` | — | Verwerking in de salarisadministratie lukt niet: geen koppeling, een fout of definitief mislukt (niet bij handmatige invoer). | `connector`, `error_code` |
| `onboarding.failed` | — | Tegelijk met `integration.sync_failed`. | `connector`, `error_code` |
| `onboarding.completed` | — | Onboarding afgerond. | — |
| `onboarding.cancelled` | — | Onboarding geannuleerd. | — |
| `ping` | — | Testbericht via `POST /v1/webhooks/{id}/test`; `data` bevat alleen `message`. | `message` |

Sommige gebeurtenissen worden ook onder een oudere naam verstuurd ("Ook verstuurd als"). Dat zijn aparte berichten met een eigen `id`. Abonneer je op één van de twee namen; zonder filter krijg je ze allebei.

## Inhoud van een bericht

```http Koppen
POST /webhooks/agreli HTTP/1.1
Content-Type: application/json
User-Agent: Agreli-Webhooks/1
Agreli-Event-Id: evt_…
Agreli-Event-Type: onboarding.employee_submitted
Agreli-Signature: t=1795176900,v1=5f2c…
```

```json Body
{
  "id": "evt_…",
  "type": "onboarding.employee_submitted",
  "created_at": "2026-11-20T10:15:00.000Z",
  "organization_id": "org_…",
  "api_version": "2026-10-01",
  "data": {
    "onboarding_id": "onb_…",
    "reference": "sollicitant-4711",
    "metadata": { "vacature_id": "vac-311" },
    "data_version": 2
  }
}
```

| Veld | Type | Omschrijving |
| --- | --- | --- |
| `id` | string | — |
| `object` | `"event"` | Alleen in GET /v1/events. |
| `type` | `contract.sent`, `contract.signed`, `contract.signed_by_employee`, `integration.sync_failed`, `integration.sync_succeeded`, `onboarding.approval_given`, `onboarding.approval_required`, `onboarding.approved`, `onboarding.cancelled`, `onboarding.completed`, `onboarding.contract_signed`, `onboarding.created`, `onboarding.employee_submitted`, `onboarding.failed`, `onboarding.invitation_sent`, `onboarding.returned`, `onboarding.started`, `onboarding.submitted`, `onboarding.terms_changed`, `payroll.completed`, `ping` | — |
| `api_version` | string | Bij webhooks die van het adres; bij GET /v1/events die van het verzoek. |
| `created_at` | string (date-time) | — |
| `organization_id` | string | De werkgever waar de onboarding bij hoort. |
| `data` | object | — |
| `data.onboarding_id` | string | — |
| `data.reference` | string \| null | — |
| `data.metadata` | object (sleutel → string \| number \| boolean) | Je eigen kenmerken, bijvoorbeeld een vacature-id (sleutels: letters, cijfers, _ . -, hooguit 40 tekens; tekst hooguit 500). Komt terug in de API en in webhooks. Geen persoonsgegevens: e-mail, IBAN, BSN en telefoonnummers worden geweigerd (metadata_personal_data). Hooguit 20 sleutels. |
| `data.data_version` | integer \| null | — |
| `data.role` | string | Bij onboarding.approval_given. |
| `data.returned_to` | string | Bij onboarding.returned. |
| `data.kind` | `invitation`, `new_link`, `reminder` | Bij onboarding.invitation_sent. |
| `data.connector` | string \| null | Bij integration.*: nmbrs, afas, loket, manual, … |
| `data.error_code` | string \| null | Bij integration.sync_failed en onboarding.failed. |

`metadata` is wat jij bij het aanmaken meegaf. Zet er nooit persoonsgegevens in: waarden die op een e-mailadres, IBAN, BSN of Nederlands mobiel nummer lijken, weigert de API met `422 metadata_personal_data`. Die herkenning is een vangnet, geen garantie.

## De handtekening controleren

De kop `Agreli-Signature` heeft de vorm `t=<unix>,v1=<hex>`. `v1` is HMAC-SHA256 met je geheim over `"<t>.<body>"`, waarbij body de ruwe tekst van het verzoek is. Controleer de handtekening voordat je iets met het bericht doet, en weiger berichten ouder dan 5 minuten.

Tijdens het [vernieuwen van het geheim](#het-geheim-vernieuwen) staan er twee `v1`-waarden in de kop: `t=…,v1=<nieuw>,v1=<vorig>`. Accepteer het bericht als er één klopt.

```ts agreli-webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";

/** Controleert Agreli-Signature (t=<unix>,v1=<hex>[,v1=<hex>]) over "<t>.<ruwe body>". */
export function geldigeHandtekening(kop: string | null, rawBody: string, secret: string, nu = Date.now()): boolean {
  const delen = (kop ?? "").split(",").map((d) => d.trim().split("="));
  const t = delen.find(([k]) => k === "t")?.[1];
  const handtekeningen = delen.filter(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/.test(v ?? "")).map(([, v]) => Buffer.from(v!, "hex"));
  if (!t || !/^\d+$/.test(t) || !handtekeningen.length) return false;
  if (Math.abs(nu / 1000 - Number(t)) > 300) return false; // ouder dan 5 minuten
  const verwacht = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return handtekeningen.some((h) => timingSafeEqual(h, verwacht));
}
```

Gebruik de ruwe body, niet een opnieuw geserialiseerd object: een andere volgorde of spatiëring geeft een andere handtekening. In een Next.js-route ziet dat er zo uit:

```ts app/api/webhooks/agreli/route.ts
import { geldigeHandtekening } from "@/lib/agreli-webhook";

export async function POST(request: Request) {
  const rawBody = await request.text();
  if (!geldigeHandtekening(request.headers.get("agreli-signature"), rawBody, process.env.AGRELI_WEBHOOK_SECRET!)) {
    return new Response("Ongeldige handtekening", { status: 400 });
  }
  const event = JSON.parse(rawBody) as { id: string; type: string; organization_id: string; data: { onboarding_id: string; reference: string | null } };
  // Jouw eigen opslag: sla elk event-id één keer op en verwerk het daarna in de achtergrond.
  if (await alOntvangen(event.id)) return new Response(null, { status: 200 });
  await zetInWachtrij(event);
  return new Response(null, { status: 200 });
}
```

## Afleveren en opnieuw proberen

- Elke `2xx` telt als ontvangen. We volgen geen redirects en wachten hooguit 10 seconden op een antwoord. Antwoord dus snel en verwerk daarna.
- Lukt het niet, dan proberen we het opnieuw na 1, 5, 30, 120, 360, 720 en 1440 minuten. Daarna is de aflevering definitief mislukt.
- Na 10 definitief mislukte afleveringen achter elkaar zetten we het adres uit. In het developer dashboard zie je de laatste afleveringen en zet je het adres weer aan.
- Elk bericht heeft `api_version`: de [versie](/docs/authenticatie#versies) van het adres, vastgezet bij het aanmaken.
- Een bericht kan meer dan eens aankomen. Ontdubbel op `id` (ook in de kop `Agreli-Event-Id`).
- Reken niet op de volgorde. Gebruik `data_version` en haal bij twijfel de actuele stand op met `GET /v1/onboardings/{id}`.

## Het geheim vernieuwen

`POST /v1/webhooks/{id}/rotate-secret` maakt een nieuw geheim. Het staat alleen in het antwoord. Het nieuwe geheim ondertekent direct; het vorige ondertekent nog `overlap_hours` mee (standaard 24, hooguit 168). Zet het nieuwe geheim in je configuratie, en accepteer in de tussentijd beide. `previous_secret_expires_at` zegt tot wanneer het vorige meetelt. Met `"overlap_hours": 0` is het vorige direct ongeldig, bijvoorbeeld als het gelekt is. In het developer dashboard heet dit **Nieuw geheim**.

```bash
curl -X POST https://app.agreli.nl/api/public/v1/webhooks/wh_…/rotate-secret \
  -H "Authorization: Bearer $AGRELI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "overlap_hours": 24 }'
```

## Afleverlog en opnieuw versturen

- `GET /v1/webhooks/{id}/deliveries` geeft alle afleveringen van een adres, nieuwste eerst, met status, pogingen, de laatste HTTP-status en de fout. Filter met `status` en blader met `limit` en `starting_after`.
- `POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry` verstuurt een aflevering opnieuw, ook een geslaagde of definitief mislukte. Het `id` van het bericht blijft gelijk, dus je ontdubbeling vangt een herhaling op. In het developer dashboard is dit de knop **Opnieuw**.

## Gemiste gebeurtenissen nalopen

Lag je server plat, of wil je zeker weten dat je niets mist? `GET /v1/events` geeft dezelfde gebeurtenissen als de webhooks, met hetzelfde `id` en dezelfde inhoud. Dat werkt ook als je geen webhookadres hebt. De oudste staan bovenaan, en we bewaren ze 30 dagen.

```bash
curl "https://app.agreli.nl/api/public/v1/events?starting_after=evt_…&limit=100" \
  -H "Authorization: Bearer $AGRELI_API_KEY"
```

Bewaar het `id` van de laatste gebeurtenis die je verwerkte en vraag daarna op met `starting_after`. Zonder cursor kun je beginnen bij een tijdstip met `since`. Filter op één soort met `type`. Daarvoor is het recht `onboardings:read` nodig. Een softwarepartner ziet alleen de gebeurtenissen van onboardings die via zijn eigen sleutels liepen.

## Testen

`POST /v1/webhooks/{id}/test` stuurt een `ping` naar één adres, langs dezelfde weg als echte berichten. In de [sandbox](/docs/sandbox) speel je met `POST /v1/sandbox/onboardings/{id}/advance` de hele onboarding na; bij elke stap komen de echte gebeurtenissen binnen.

## Webhooks voor softwarepartners

Een API-partner krijgt de gebeurtenissen van een onboarding als die via een sleutel van de partner is aangemaakt, de werkgever de partner (nog) toelaat en de partner het recht `onboardings:read` heeft. De berichten gaan naar de adressen in de eigen organisatie van de partner; `organization_id` zegt van welke werkgever ze zijn. Beëindigt de werkgever de relatie, dan stoppen nieuwe berichten direct. Onboardings die de werkgever zelf startte, ziet de partner nooit.
