Agreli
Docs / Webhooks

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.

MarkdownOpenAPI

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.

Terminal
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

GebeurtenisOok verstuurd alsWanneerExtra 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_submittedonboarding.submittedDe 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_signedcontract.signedDe werkgever heeft ook getekend; het contract is rond.—
integration.sync_succeededpayroll.completedDe 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

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…
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
  }
}
VeldTypeOmschrijving
idstring—
object"event"Alleen in GET /v1/events.
typecontract.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_versionstringBij webhooks die van het adres; bij GET /v1/events die van het verzoek.
created_atstring (date-time)—
organization_idstringDe werkgever waar de onboarding bij hoort.
dataobject—
data.onboarding_idstring—
data.referencestring | null—
data.metadataobject (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_versioninteger | null—
data.rolestringBij onboarding.approval_given.
data.returned_tostringBij onboarding.returned.
data.kindinvitation, new_link, reminderBij onboarding.invitation_sent.
data.connectorstring | nullBij integration.*: nmbrs, afas, loket, manual, …
data.error_codestring | nullBij 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 staan er twee v1-waarden in de kop: t=…,v1=<nieuw>,v1=<vorig>. Accepteer het bericht als er één klopt.

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:

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 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.

Terminal
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.

Terminal
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 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.