Klare Koek ↔ Jobfeed — integratiehandleiding

Laatst bijgewerkt: Versie 1.9.1 — 1 september 2026


Deze handleiding beschrijft de huidige, live situatie. De versiehistorie staat als bijlage achterin.


In één zin

Jullie halen onze vacatures op via een REST-API; bij elke wijziging sturen wij een webhook naar het endpoint dat jullie daarvoor instellen, zodat jullie niet hoeven te pollen; en jullie voegen zelf nieuwe klanten bij ons toe.

Eén set endpoints voor alle omgevingen

Dit is het belangrijkste ontwerpprincipe van de integratie:

Alle endpoints staan op dezelfde base-URL:

https://yxbnxdoirwwgwiugpunj.functions.supabase.co
Endpoint Methode Doel
/get-vacancies GET Vacatures ophalen (§4)
/add-source POST Nieuwe klant (jobfeed) toevoegen (§5)
/manage-source/<source_id> PATCH / DELETE Klant wijzigen of uitzetten (§5b)
/preview GET Aantal vacatures in een feed checken vóór onboarding (§6)

Daarnaast sturen wíj webhooks naar jullie endpoints (§3).


1. Wat je van ons krijgt

Type Wat Wanneer
Webhook new event Nieuwe vacature in onze feed
Webhook update event Bestaande vacature is gewijzigd
Webhook close event Vacature is gesloten (verdween uit de bronfeed)
Webhook source_down event Bronfeed van een klant is onbereikbaar
REST GET Vacature-details Op basis van het ID uit een webhook
REST POST Nieuwe klant toevoegen Wanneer jullie een nieuwe klant onboarden
REST PATCH Klant wijzigen Feed-URL, webhook-URL, naam, land, … aanpassen (§5b)
REST DELETE Klant uitzetten Soft-delete; vacatures sluiten de eerstvolgende nacht (§5b)
REST GET Preview-aantal Tijdens setup om aantal vacatures te tonen

2. Sleutels en omgevingen

Er zijn twee soorten sleutels, beide per omgeving:

Sleutel Waarvoor Hoe gebruikt
API-key, één per omgeving Authenticeert Klare Koek richting ons Authorization: Bearer <key> op alle vier de endpoints hierboven. Zeven keys met jullie labels: 360-prod, 360-staging, 360-dev, dev-jelle, dev-mark, dev-remco, dev-jouke. Het label bepaalt de omgeving: een key maakt bronnen aan in, beheert en leest alléén de eigen omgeving.
Webhook-secret, één per omgeving Authenticeert onze webhooks richting jullie Bearer-token-check op de Authorization-header (§3.4). Een event draagt het secret van de omgeving waar de bijbehorende bron in staat.

De sleutels delen we uitsluitend via een secure kanaal (1Password-links) — nooit via mail of chat. Belandt een sleutel toch ooit in een onveilig kanaal, dan vervangen wij dat paar uit voorzorg en delen we het nieuwe via 1Password; de overige omgevingen blijven dan ongemoeid.

Overgang: de oude gedeelde KK_API_KEY werkt nog en telt als de 360-prod-key, zodat er geen moment is waarop bestaande bronnen onbeheerbaar zijn. Zodra jullie bevestigen dat alle omgevingen via /get-vacancies lezen (§8), trekken wij die oude key in.

SUPABASE_ANON_KEY is niet meer nodig — en per 1 september ingetrokken. De anon-key die jullie in juni kregen werkt niet meer; jullie kunnen hem uit jullie configuratie verwijderen. Vacatures ophalen gaat via /get-vacancies met je gewone API-key (§4). De publieke database-views waar de anon-key toegang toe gaf zijn er alleen nog voor onze eigen website en tonen uitsluitend productie-data; voor de integratie spelen ze geen rol.


3. Webhooks die wij naar jullie sturen

3.1 Aanleveren door Klare Koek — per jobfeed een eigen endpoint

Elke jobfeed (bron) heeft een eigen, optionele webhook_url. Zo kan elke developer/omgeving (lokaal, dev, staging, prod) z'n eigen endpoint ontvangen. Je geeft hem op bij het aanmaken (POST /add-source, §5) en wijzigt hem wanneer je wilt (PATCH /manage-source/<id>, §5b):

{ "webhook_url": "https://staging.klarekoek.nl/jobfeed/webhook" }

Regels:

We sturen alle vier event-types naar het endpoint van de betreffende bron. Type staat in de body. Webhooks gaan gebatched — één POST per endpoint per event-type per pipeline-run, niet per individuele vacature. Uitzondering: source_down wordt per getroffen bron als aparte POST geleverd (de sources-array bevat dan één bron) — vallen er meerdere bronnen uit in één run, dan krijg je dus meerdere source_down-POSTs. Elke body bevat ook een idempotency_key waarmee je dubbele leveringen kunt herkennen (zie §3.5).

3.2 Frequentie

Onze pipeline draait elke dag om 01:00 UTC. Direct daarna sturen wij alle events van die run: per endpoint één POST per event-type dat voorkwam, plus per getroffen bron een aparte POST voor source_down (§3.1). Zijn jullie tijdelijk onbereikbaar, dan parkeren we de events en proberen we de volgende dag opnieuw. Max 10 pogingen per event (zie §3.6).

3.3 Payload-voorbeelden

new event — nieuwe vacatures:

{
  "event": "new",
  "occurred_at": "2026-05-29T01:15:00Z",
  "idempotency_key": "9f2c0b1e…(sha256-hex)",
  "vacancy_ids": [
    "5ce220c0-a9ad-4de7-871c-3630e78f3b5c",
    "c228f56b-01c9-4260-b1c6-caefdceec2f5"
  ],
  "vacancies": [
    { "id": "5ce220c0-a9ad-4de7-871c-3630e78f3b5c",
      "source_id": "ab50c37a-23a4-46de-b823-7c924cf3f062" },
    { "id": "c228f56b-01c9-4260-b1c6-caefdceec2f5",
      "source_id": "29c01d3b-5715-494d-b95b-49fc7c9473dc" }
  ]
}

update event — gewijzigde vacatures:

{
  "event": "update",
  "occurred_at": "2026-05-29T01:15:00Z",
  "idempotency_key": "a1b2c3d4…(sha256-hex)",
  "vacancy_ids": ["a689f51b-c778-4cc8-be77-9b158f6242bd"],
  "vacancies": [
    { "id": "a689f51b-c778-4cc8-be77-9b158f6242bd",
      "source_id": "ab50c37a-23a4-46de-b823-7c924cf3f062" }
  ]
}

close event — gesloten vacatures (verdwenen uit bron):

{
  "event": "close",
  "occurred_at": "2026-05-29T01:15:00Z",
  "idempotency_key": "e5f6a7b8…(sha256-hex)",
  "vacancy_ids": ["13fa266f-6668-4482-919d-0b8206b8e6be"],
  "vacancies": [
    { "id": "13fa266f-6668-4482-919d-0b8206b8e6be",
      "source_id": "29c01d3b-5715-494d-b95b-49fc7c9473dc" }
  ]
}

vacancies naast vacancy_ids. Elke new/update/close-body bevat beide: vacancy_ids (alleen de ID's) en een vacancies-array met per vacature ook het source_id van z'n jobfeed (dezelfde UUID als in de add-source-response, §5). De arrays zijn index-uitgelijnd: vacancies[i].id == vacancy_ids[i], altijd. Een batch op het globale fallback-endpoint kan vacatures van meerdere bronnen bevatten; gebruik dus het source_id per entry, niet één waarde voor de hele batch. Een eigen source-id in de per-bron webhook-URL verwerken (jullie eerdere workaround) mag ook nog steeds, maar is hiermee niet meer nodig.

⚠️ Een vacature die close heeft gehad blijft bij ons in de database staan met active=false. Je kunt de details nog steeds ophalen via /get-vacancies (zie §4). Als de vacature later weer in de bron verschijnt, krijg je opnieuw een new event met hetzelfde ID.

source_down event — bronfeed van een klant kapot:

{
  "event": "source_down",
  "occurred_at": "2026-05-29T01:15:00Z",
  "idempotency_key": "c9d0e1f2…(sha256-hex)",
  "sources": [
    {
      "source_id": "29c01d3b-5715-494d-b95b-49fc7c9473dc",
      "client_name": "Some Pharma BV",
      "url": "https://careers.somepharma.com/feed.xml",
      "old_status": "healthy",
      "new_status": "broken",
      "message": "HTTP 503 bij ophalen URL",
      "locked": false
    }
  ]
}

3.4 Authenticatie — bearer-token verifiëren

Elke POST draagt een vaste header:

Authorization: Bearer <webhook-secret van de omgeving>

Vergelijk die header met het secret en weiger met 401 als hij niet klopt. Gebruik een constant-time vergelijking om timing-aanvallen te voorkomen. Er is geen raw-body, hash of timestamp nodig — een eenvoudige bearer-token dus, geen HMAC.

Welk secret een POST draagt, hangt af van de omgeving van de bron waar het event bij hoort. Een endpoint dat events uit meerdere omgevingen ontvangt, moet dus meerdere secrets accepteren — controleer de header tegen de set secrets die bij dat endpoint hoort. Elke POST bevat events uit precies één omgeving.

Verificatie in Node.js:

const crypto = require("crypto");

function verifyJobfeedAuth(authHeader, secret) {
  const a = Buffer.from(authHeader || "");
  const b = Buffer.from(`Bearer ${secret}`);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: if (!verifyJobfeedAuth(req.get("authorization"), SECRET)) return res.sendStatus(401);

Verificatie in Python:

import secrets

def verify_jobfeed_auth(auth_header: str, secret: str) -> bool:
    return secrets.compare_digest(auth_header or "", f"Bearer {secret}")

De check kijkt alleen naar de header, niet naar de body. Je mag de JSON dus gewoon door je normale parser laten afhandelen. Verifieer de token vóór je de body verwerkt.

3.5 Idempotency — dubbele leveringen herkennen

Elke POST draagt een idempotency-key: dezelfde waarde staat in de body (idempotency_key) én in een header:

X-Idempotency-Key: 9f2c0b1e…(sha256-hex)

De key is deterministisch — een sha256 over het event-type plus de gesorteerde ID's. Een re-send van exact dezelfde batch levert dus exact dezelfde key op (alleen de occurred_at-timestamp in de body verschilt per verzending). De vacancies-array telt niet mee in de key: die wordt alleen uit event-type + ID's afgeleid. Batches worden per endpoint samengesteld (§3.1) met disjuncte ID-sets, dus keys zijn ook over meerdere endpoints heen uniek; dedup per omgeving op alleen de key blijft correct.

In zeldzame gevallen kunnen wij een batch twee keer sturen — bijvoorbeeld een time-out waarbij onze kant de levering niet als geslaagd registreerde. Dedup hierop: heb je deze X-Idempotency-Key al verwerkt, antwoord dan 200 en negeer de inhoud. Zo voorkom je dubbele vacatures aan jullie kant.

Bewaar verwerkte keys daarbij een beperkte tijd — een paar weken is ruim voldoende, want een re-send komt binnen dagen (§3.6). De key bevat bewust geen datum: een legitieme latere levering die toevallig exact dezelfde ID-set heeft (bijvoorbeeld een heropende vacature, §3.3) krijgt dezelfde key en moet niet op een maandenoude dedup-entry stranden.

3.6 Response

Stuur HTTP 200299 bij succes — geef ook een 2xx bij een al-geziene X-Idempotency-Key (zie §3.5). Bij 400+ markeren wij de batch als gefaald en proberen we het de volgende run opnieuw. Binnen één leverronde proberen we tot 4× met korte tussenpozen (met dezelfde X-Idempotency-Key); zo'n ronde telt als één van de max 10 pogingen (§3.2). Wij volgen geen redirects: jullie endpoint moet terminal zijn — een 3xx behandelen wij als leveringsfout. Na 10 mislukte pogingen zetten wij het event stil; wij krijgen daarvan een interne alert en nemen contact op.


4. Vacature-data ophalen (REST)

Vacatures haal je op via /get-vacancies, met de API-key van de omgeving waarvoor je leest (§2). Zoals overal geldt: zelfde endpoint voor alle omgevingen, de key bepaalt wat je ziet.

GET https://yxbnxdoirwwgwiugpunj.functions.supabase.co/get-vacancies
Headers: Authorization: Bearer <API-key van jullie omgeving>

De query-syntax is PostgREST:

# Eén vacature op basis van een webhook-ID
curl "https://yxbnxdoirwwgwiugpunj.functions.supabase.co/get-vacancies?id=eq.<UUID>" \
  -H "Authorization: Bearer <API-key>"

# Alleen actieve vacatures, compacte payload
curl "https://yxbnxdoirwwgwiugpunj.functions.supabase.co/get-vacancies?active=eq.true&select=id,title,source_id" \
  -H "Authorization: Bearer <API-key>"

Spelregels:

Belangrijkste velden

Veld Type Toelichting
id uuid 5ce220c0-...
source_id uuid Het id van de jobfeed (bron) waar de vacature bij hoort — dezelfde UUID als in de add-source-response (§5), /manage-source/<source_id> (§5b) en het source_down-event (§3.3). Gebruik dit om vacatures aan de juiste org/user te koppelen; company_name is presentatietekst, geen sleutel.
external_url text URL naar oorspronkelijke vacaturepagina
title text Engelstalig (uniform), origineel via description_markdown
description_markdown text Hoofdtekst in Markdown (originele taal)
description_translated text Engelse vertaling van de samenvatting
company_name text Bedrijfsnaam van de klant
location_city, location_country text Stad, land
salary_min, salary_max, currency int / text Salaris als bekend
category, function_area, function_areas text / text[] Categorie + functiegebieden
hours_type, seniority_level, type_of_employment, education_level text Job-metadata
seniority_level_id, education_level_id, type_of_employment_id, category_id, function_area_id text Het stabiele filter-id (ULID) uit jullie filters-API, horend bij het gelijknamige naamveld (seniority_level_id bij seniority_level, category_id bij category, enz.). Nachtelijk bijgewerkt. null betekent: die waarde kennen jullie (nog) niet als filter, of jullie gebruiken dezelfde naam voor twee filters (dan kunnen wij niet kiezen).
function_area_ids text[] De filter-id's van álle function_areas, index-uitgelijnd: function_area_ids[i] hoort altijd bij function_areas[i]. Een niet-gekoppelde waarde levert een null-element, géén kortere lijst — zo kun je veilig per index zippen.
experience, education, type_of_contract (+ _id) text Jullie eigen sleutelnamen, met dezelfde waarden als respectievelijk seniority_level, education_level en type_of_employment. type_of_contract is een alias van type_of_employment (bevestiging gevraagd in §8).
info_about_next_job, info_about_next_colleagues, info_about_tasks_and_responsibilities, info_about_skills_experience, info_about_benefits, info_about_molecules_meet_opportunities text Jullie sleutelnamen voor de secties, met dezelfde inhoud als de about_your_*-velden hieronder. Beide sets staan in de feed; kies er één (select= gebruiken scheelt payload). Voor only_apply_if (harde eisen) bestaat geen eigen 360-sleutelnaam; die staat alleen onder de bestaande sleutel.
about_your_next_job, about_your_next_colleagues, about_your_tasks_and_responsibilities, about_your_skills_and_experience, about_your_benefits, where_molecules_meet_opportunities, only_apply_if text Gestructureerde secties in Markdown: --bullets en spaarzaam **bold**, geen headings/links/HTML. Oudere rijen zijn platte tekst en daarmee óók geldige Markdown. Render ze door dezelfde Markdown-renderer (+ sanitizer) als description_markdown.
environment text Omgevings-label van de bron waar de vacature bij hoort (§2). Via /get-vacancies is dit per definitie (een alias van) de omgeving van je key — puur informatief dus.
active bool false = vacature is gesloten
closed_at timestamptz Wanneer dichtgegaan (alleen als active=false)
posted_at timestamptz Plaatsings­datum uit de bronfeed (RSS <pubDate>, Atom <published>, custom <date>). NULL als de bron geen datum levert (typisch bij sitemaps) — gebruik dan created_at als fallback. Dit is de datum die jullie op een vacature­pagina als "geplaatst op" willen tonen.
created_at timestamptz Wanneer wij de rij voor het eerst aanmaakten in onze DB. Fallback voor posted_at
updated_at timestamptz Wanneer wij de rij voor het laatst hebben bijgewerkt
last_seen_at timestamptz Wanneer de URL voor het laatst in de bronfeed werd gezien. Voor actieve rijen typisch "vandaag"; voor gesloten rijen de laatste datum

Lege velden komen als null of een lege string. Op jullie kant tonen jullie ze niet — wij garanderen alleen wat de bron aanlevert.

De feed bevat daarnaast een paar velden voor onze eigen site (science_parks, science_park_names); die mag je negeren.

Voor een volledige veld-lijst: GET .../get-vacancies?limit=1 — dat is de bron van waarheid.

Waardelijsten — wij volgen die van jullie

Onze AI mag alléén waarden toekennen die op dat moment in jullie filters-API staan. Concreet:


5. Nieuwe klant toevoegen (REST)

POST https://yxbnxdoirwwgwiugpunj.functions.supabase.co/add-source
Headers:
  Authorization: Bearer <API-key van jullie omgeving>
  Content-Type: application/json

Body:
{
  "client_name": "Some Pharma BV",
  "url": "https://careers.somepharma.com/feed.xml",
  "country": "NL",                           // optioneel, default NL (volledige namen worden naar ISO genormaliseerd)
  "source_type": "xml_feed",                 // optioneel, auto-detected
  "min_description_chars": 50,               // optioneel, per-klant tunable
  "webhook_url": "https://dev.klarekoek.nl/jobfeed/webhook"  // optioneel, per-bron endpoint (§3.1)
}

Mogelijke responses:

Voorbeeldrespons (201):

{
  "source_id": "ab50c37a-23a4-46de-b823-7c924cf3f062",
  "client_name": "Some Pharma BV",
  "source_type": "xml_feed",
  "url": "https://careers.somepharma.com/feed.xml",
  "country": "NL",
  "min_description_chars": null,
  "webhook_url": "https://dev.klarekoek.nl/jobfeed/webhook",
  "environment": "dev",
  "candidate_count": 1952,
  "message": "Bron actief — eerstvolgende pipeline-run (dagelijks 01:00 UTC) verwerkt ~1952 vacatures"
}

⚠️ candidate_count is het totale aantal in de feed. Onze pipeline filtert per country — voor een NL-klant blijven typisch minder vacatures over. Voor een nauwkeuriger preview zie §6.

Default-landen


5b. Klant wijzigen of uitzetten (REST)

Gebruik het source_id uit de add-source-response (of uit de 409-body).

Omgevings-scope: een key beheert alléén bronnen die met een key van dezelfde omgeving zijn aangemaakt. Een source_id uit een andere omgeving geeft dezelfde 404 als een onbekend id.

Wijzigen

PATCH https://yxbnxdoirwwgwiugpunj.functions.supabase.co/manage-source/<source_id>
Headers:
  Authorization: Bearer <API-key van jullie omgeving>
  Content-Type: application/json

Body (alle velden optioneel, minstens één):
{
  "client_name": "Some Pharma B.V.",
  "url": "https://careers.somepharma.com/feed-v2.xml",  // wordt opnieuw geprobed
  "source_type": "xml_feed",
  "country": "BE",
  "min_description_chars": 80,     // null = terug naar onze default
  "webhook_url": "https://staging.klarekoek.nl/jobfeed/webhook",  // null = globale fallback
  "active": true                   // true = gedeactiveerde bron heractiveren
}

Bijzonderheden:

Uitzetten (soft-delete)

DELETE https://yxbnxdoirwwgwiugpunj.functions.supabase.co/manage-source/<source_id>
Headers:
  Authorization: Bearer <API-key van jullie omgeving>

Responses (beide methoden)


6. Preview tijdens setup (REST)

Vóórdat een klant in onze sources staat, kunnen jullie tonen hoeveel vacatures er in de feed zitten.

GET https://yxbnxdoirwwgwiugpunj.functions.supabase.co/preview?url=<URL-encoded>
Headers:
  Authorization: Bearer <API-key van jullie omgeving>

Respons (200):

{
  "url": "https://careers.somepharma.com/feed.xml",
  "source_type": "xml_feed",
  "candidate_count": 1952,
  "message": "1952 vacatures gevonden in xml_feed (root: <source>)"
}

Mogelijke errors: 400 (ongeldige URL), 401 (auth), 422 (URL is geen herkende feed of niet bereikbaar), 429 (max 20 requests per minuut), 500 (interne fout).


7. Bron-types die wij ondersteunen

Type Detectie Voorbeeld-root
xml_feed RSS/Atom/custom job-XML <rss>, <feed>, <channel>, <source> (Indeed/Adzuna-stijl)
sitemap XML sitemap <urlset>, <sitemapindex>

HTML-scraping is niet actief (uitgezet per 26 mei 2026). Jullie kunnen alleen klanten toevoegen die een feed of sitemap publiceren. Bij twijfel: gebruik eerst /preview om te checken of de URL detecteerbaar is.


8. Wat moeten jullie ons aanleveren?


9. Open punten / known issues


10. Test-feed

Voor jullie implementatietest hosten wij een kleine, door ons bestuurbare test-jobfeed met 3 fictieve pharma-vacatures:

https://yxbnxdoirwwgwiugpunj.supabase.co/storage/v1/object/public/testfeeds/kk/feed.xml

Zo gebruik je hem:

  1. GET /preview?url=<encoded feed-url> → hoort candidate_count: 3 te geven.
  2. POST /add-source met deze URL, client_name: "KK Testfeed" en jullie eigen webhook_url (bv. een dev-tunnel) → 201 met source_id.
  3. Wij draaien de pipeline on demand tijdens jullie testvenster en muteren de feed in stappen, zodat jullie achtereenvolgens een new-, update-, close- en desgewenst source_down-batch ontvangen.
  4. Test PATCH/DELETE op ditzelfde source_id (§5b) — inclusief het wisselen van webhook_url tussen jullie omgevingen.
  5. Na afloop ruimen wij de testbron en -vacatures op.

Plan het testvenster even met ons in — dan draaien wij de runs en de feed-mutaties op afroep, en kijken we live mee met de logs.


Bijlage — versiehistorie

Eén regel per versie; de details staan verwerkt in de hoofdtekst.