Bestaand API-oppervlak¶
Doel & URL('s)¶
Dit document beschrijft het bestaande, werkende API-oppervlak van de WordPress-installatie zoals dat vandaag door de Waarheidsvriend-app wordt gebruikt en als startpunt dient voor de Next.js-rebuild. Peildatum: 2026-08-06.
| Wat | URL |
|---|---|
| REST-basis | https://<host>/wp-json/ |
| OpenAPI 3.1-spec (machineleesbaar) | GET /wp-json/custom/v1/openapi (publiek) |
| Swagger UI (interactieve documentatie) | /api-docs (publiek; achter login te zetten via filter gb_api_docs_require_login) |
Omgevings-eigenaardigheid lokaal
De gb-*-mu-plugins staan in web/app/mu-plugins/ en zijn niet actief op de lokale omgeving (localhost:8080 draait met WP_CONTENT_DIR = wp-content/). Alle gb-app/v1- en custom/v1-gedrag is dus alleen te testen tegen dev.gereformeerdebond.nl (of staging/prod). De thema-logica (401-afscherming, CPT's) draait wél lokaal.
Opbouw: de 17 endpoints¶
Auth-opties: Bearer-JWT (uit POST /gb-app/v1/login; geldt via determine_current_user óók op wp/v2) of WP Application Password (HTTP Basic). Alle afgeschermde content-endpoints geven zonder authenticatie 401 rest_forbidden.
Auth (gb-app/v1)¶
| # | Endpoint | Methode | Parameters | Responses |
|---|---|---|---|---|
| 1 | /wp-json/gb-app/v1/login |
POST | body: email (verplicht), wachtwoord (verplicht; alias password) |
200 TokenPair · 401 generiek "Onjuiste inloggegevens" · 429 te veel pogingen |
| 2 | /wp-json/gb-app/v1/refresh |
POST | body: vernieuwbewijs (verplicht; alias refresh_token) — token roteert, het oude vervalt |
200 TokenPair · 401 |
| 3 | /wp-json/gb-app/v1/logout |
POST | body: vernieuwbewijs (verplicht) |
altijd 200 {ok:true} (onthult niet of het bewijs bestond) |
| 4 | /wp-json/gb-app/v1/me |
GET | Bearer vereist | 200 {id, email, display_name, is_subscriber} · 401 |
Drukversie / PDF (gb-app/v1)¶
| # | Endpoint | Methode | Parameters | Responses |
|---|---|---|---|---|
| 5 | /wp-json/gb-app/v1/issues/{id}/pdf-link |
GET | path id (integer). Auth: Bearer of app-password; rol wv-subscriber vereist |
200 {url, expires_in} (standaard 300 s) · 401 · 403 gb_app_no_subscription · 404 (gb_app_issue_not_found / gb_app_no_pdf) |
| 6 | /wp-json/gb-app/v1/issues/{id}/pdf |
GET | path id; query uid (user-id), exp (unix-tijd), sig (HMAC-SHA256). De handtekening ís de autorisatie, geen header nodig |
200 application/pdf (inline, ondersteunt Range/206) · 302 (redirect-modus) · 403 (ongeldig/verlopen/geen abonnement) · 404 |
Gedragsdetails: link-TTL 5 min (filter gb_app_pdf_link_ttl); handtekening over issue_id|user_id|exp met sleutel secret + '|' + wachtwoord-hash → links vervallen bij wachtwoordwijziging; abonnement wordt bij uitlevering opnieuw gecontroleerd; headers o.a. Content-Disposition: inline, CSP frame-ancestors op de CORS-allowlist, X-Robots-Tag: noindex. Range-requests (HTTP 206) worden ondersteund, ook doorgeschakeld naar Azure Blob, zodat PDF.js per pagina laadt.
Content (wp/v2)¶
Gedeelde lijstparameters: page (default 1), per_page (default 10, max 100), search, orderby (default date), order (asc/desc, default desc), _embed.
| # | Endpoint | Extra parameters | Responseschema (kern) |
|---|---|---|---|
| 7 | GET /wp-json/wp/v2/issues |
— | id, date, slug, link, title.rendered, gb_locked, acf.{issue_number, issue_cover, issue_file} — issue_file alleen voor abonnees |
| 8 | GET /wp-json/wp/v2/issues/{id} |
— | idem |
| 9 | GET /wp-json/wp/v2/wv-articles |
article_issue (int ≥ 1; onbekend/concept → lege lijst; niet-numeriek → 400) · article_author (int ≥ 1; zelfde gedrag) |
id, date, slug, link, title, content (leeg + protected:true voor niet-abonnees), excerpt, gb_locked, article_author_data{id,name,bio,image,link}, acf.{article_type, article_subtitle, article_intro, article_image, article_image_as_cover, article_author[], article_issue, article_category} |
| 10 | GET /wp-json/wp/v2/wv-articles/{id} |
— | idem |
| 11 | GET /wp-json/wp/v2/author |
— | id, slug, title, acf.{name, image, description} (description = bio-HTML) |
| 12 | GET /wp-json/wp/v2/author/{id} |
— | idem |
| 13 | GET /wp-json/wp/v2/media/{id} |
— | id, source_url, mime_type, media_details (incl. alle image sizes) |
| 14 | GET /wp-json/wp/v2/wv_article_categories |
— | id, name, slug, parent, count |
| 15 | GET /wp-json/wp/v2/faith-questions |
sorteren op redactievolgorde: ?orderby=menu_order&order=asc |
id, title.rendered (= de vraag), menu_order, acf.{faith_question_summary, faith_question_term, faith_question_category, faith_question_articles} (elk …|false) |
| 16 | GET /wp-json/wp/v2/faith-questions/{id} |
— | idem |
Menu's (menus/v1)¶
| # | Endpoint | Parameters | Opmerking |
|---|---|---|---|
| 17 | GET /wp-json/menus/v1/menus/{id} |
path id = menu-slug of -id |
plugin wp-rest-api-v2-menus; geen schema in de OpenAPI-spec |
Zie § menus/v1 voor de bijbehorende collectie-/locatie-routes en de productiestatus.
Databronnen¶
| Bron | Details |
|---|---|
| CPT's in REST | wv-article (wv-articles, 8.728), tr-article (tr-articles, 49), cgc-article (cgc-articles, 35), issue (issues, 205), author (author, 1.005), faith-question (faith-questions), page (pages, 54), attachment (media) |
| Taxonomieën in REST | wv_article_categories, tr_article_categories, cgc_article_categories, categories, tags |
| ACF-groepen in REST | Artikelen, Auteur, Uitgaven, Geloofsvraag (show_in_rest=1); alle overige groepen niet |
| Artikelbody | ACF flexible content components met layouts: text, text_image, frame, quote, gallery, call_to_action, donation, video, downloads, paywall (marker), table. Een headless renderer moet al deze layouts aankunnen |
| SEO | elk REST-object bevat yoast_head (HTML-string) en yoast_head_json (og/twitter/canonical/robots/schema.org), ook op terms en pages |
Gedrag & rollen (auth-model)¶
JWT (gb-app/v1) + Application Passwords¶
- Access-token TTL 1 uur (filter
gb_app_access_ttl); refresh-token 90 dagen (gb_app_refresh_ttl), max 10 actieve refresh-tokens per gebruiker. Geheim =wp_salt('auth'); het token bevat de wachtwoord-hash zodat álle tokens vervallen bij wachtwoordwijziging. - Bearer wordt op alle REST-routes geaccepteerd (
determine_current_user, prio 20); een ongeldig token maakt de request anoniem (en loopt dan tegen de 401-afscherming aan). - Rate limiting login: max 5 pogingen per 15 minuten, per e-mail én per IP (transients); succesvolle login wist alleen de e-mailteller. Nginx heeft daarnaast
limit_req(1 r/s) op login — er is géén rate-limit op de rest vanwp-json.
Thema-afscherming: anonieme 401¶
rest_pre_dispatch (thema, app/filters.php) geeft 401 rest_forbidden ("Authenticatie vereist.") aan iedere niet-ingelogde request op wp/v2-routes van: wv-articles, cgc-articles, tr-articles, issues, author, faith-questions, wv_article_categories, cgc_article_categories, tr_article_categories.
Open zonder auth blijven o.a.: /wp/v2/pages, /wp/v2/media, /wp/v2/search, /wp/v2/categories, /wp/v2/tags, custom/v1/openapi, gb-app/v1/* en menus/v1/* (dev).
/wp/v2/search lekt titels
/wp/v2/search?search=… is publiek en geeft wél titels + URL's van wv-article-posts terug (met subtype), ook anoniem — alleen de detail-endpoints zijn dicht.
Rol-afscherming: gb_locked¶
Voor geauthenticeerde gebruikers zonder rol wv-subscriber (en voor iedereen met een service-account dat die rol mist):
| Resource | Veld | Gedrag zonder wv-subscriber |
|---|---|---|
wv-article |
gb_locked |
boolean op elk artikel; content → {rendered:'', protected:true}; acf.article_components én acf.components verwijderd (artikelbody). Chapeau/titel/lead/auteur/beeld blijven staan |
issue |
gb_locked |
boolean; acf.issue_file (PDF-verwijzing) verwijderd |
Dit gb_locked-model is het fundament voor de paywall in de rebuild: teaser-data is er altijd, de body alleen voor abonnees.
Rollenoverzicht per endpoint¶
| Endpoint(s) | Anoniem | Ingelogd (geen abonnee) | wv-subscriber |
|---|---|---|---|
gb-app/v1/login, refresh, logout |
✓ | ✓ | ✓ |
gb-app/v1/me |
401 | ✓ (is_subscriber:false) |
✓ (is_subscriber:true) |
wp/v2/wv-articles e.a. afgeschermde types |
401 | ✓, maar gb_locked-velden gestript |
✓ volledig |
gb-app/v1/issues/{id}/pdf-link |
401 | 403 gb_app_no_subscription |
200 |
custom/v1/openapi, /api-docs, wp/v2/search, wp/v2/pages, wp/v2/media |
✓ | ✓ | ✓ |
CORS-situatie¶
Functie GB\ApiDocs\allowed_origins() vervangt de WP-default (die elke origin terugkaatst) op rest_api_init prio 15:
- Altijd toegestaan:
https://app.gereformeerdebond.nl,https://app-dev.gereformeerdebond.nl. - Alleen buiten productie:
http://localhost:8081(Expo dev),http://localhost:19006(Expo web),http://localhost:3000(web-frontend dev). - Uitbreidbaar via filter
gb_api_allowed_origins.
Headers bij match: Access-Control-Allow-Origin: <origin>, Allow-Methods: GET, POST, OPTIONS, Allow-Headers: Authorization, Content-Type, X-WP-Nonce, X-Requested-With, Range, Expose-Headers: X-WP-Total, X-WP-TotalPages, Link, Allow-Credentials: true, Max-Age: 600, altijd Vary: Origin. Elke OPTIONS-preflight mét Origin slaagt altijd met 204 (rest_pre_dispatch prio 99 — anders blokkeerde de thema-afscherming ook de preflight).
Rebuild-domein staat niet in de allowlist
Het toekomstige Next.js-domein (bijv. www.gereformeerdebond.nl) staat niet in de allowlist. Server-side fetches (SSR/ISR) hebben geen CORS nodig; client-side browser-calls wél → allowlist uitbreiden via gb_api_allowed_origins (zie gap-analyse).
menus/v1 (navigatiemenu's)¶
Plugin wp-rest-api-v2-menus — actief op dev, maar niet in de active_plugins van de productie-DB-snapshot (op prod dus vermoedelijk niet actief; activeren is een rebuild-voorwaarde, zie gap-analyse).
Publiek getest op dev:
| Route | Levert |
|---|---|
GET /wp-json/menus/v1/menus |
alle 5 menu's |
GET /wp-json/menus/v1/locations |
locaties: GB_primary_navigation → menu 12, WV_primary_navigation → 19, TR_primary_navigation → 20, CGC_primary_navigation → 18 |
GET /wp-json/menus/v1/menus/{id} |
menu incl. items (2 niveaus) |
Menu's in de DB: Gereformeerde Bond (12, 26 items), Waarheidsvriend (19, 11), Theologia Reformata (20, 4), CGC (18, 17), Primary (2, legacy/leeg).
Yoast REST-oppervlak¶
yoast_head+yoast_head_jsonop alle REST-zichtbare objecten (posts, pages, terms): og-/twitter-tags, canonical, robots, schema.org. Let op: canonicals/og:url's wijzen naar de WP-URL en moeten in de rebuild herschreven worden.GET /wp-json/yoast/v1/get_head?url=…— publiek; levert de volledige Yoast-<head>-HTML voor een willekeurige site-URL. Bruikbaar als SEO-bron voor content die (nog) niet viawp/v2loopt.- Sitemaps:
/sitemap_index.xml(200 op prod) wordt door WP/Yoast gegenereerd — proxy- of hergeneratiestrategie: zie gap-analyse.
Gravity Forms REST v2 (gf/v2)¶
- Namespace actief (lokaal én dev). Web API-instellingen:
enabled=1, impersonate user 23. - Auth: GF API-keys via HTTP Basic (consumer key/secret); Application Passwords en cookies werken ook. Bestaande keys: 3× "dynamics" (read_write), 2× "gb-mail-overzicht" (read).
- Publiek zonder auth:
POST /wp-json/gf/v2/forms/{id}/submissions— een headless frontend kan hiermee formulieren insturen; bij validatiefouten komtis_valid:false+ de volledige formulierdefinitie + veldfouten terug. GET /wp-json/gf/v2/forms→ 401 zonder key; formulierdefinities ophalen vereist een read-key.- 39 actieve formulieren (contact, lid worden, abonnementen, donaties incl. Mollie/iDEAL, registratie, account, nieuwsbrief). Add-ons Mailchimp, Mollie en User Registration draaien server-side op de submission — betaal-redirects en registratieflows werken dus niet vanzelf headless (zie gap-analyse).
Overige aanwezige namespaces¶
Lokaal (productie-DB): oembed/1.0, advanced-ads/v1 (alleen admin-routes), redirection/v1 (auth vereist), yoast/v1, wp-all-import/v1, wp/v2, gf/v2. Dev daarbovenop: custom/v1, gb-app/v1, menus/v1, code-snippets/v1, wp-abilities/v1.
WPGraphQL: onbruikbaar én onbedoeld open
/graphql werkt lokaal anoniem (omzeilt de REST-401-afscherming; artikelbody lekt niet omdat ACF niet in het schema zit), maar de drie article-CPT's hebben hetzelfde graphqlSingleName: "article" (registratie defect). Live geven prod en dev HTML terug i.p.v. JSON. Besluit voor de rebuild: verwijderen of volledig opnieuw inrichten — zie gap-analyse.
Acceptatiecriteria¶
Testbaar met Playwright (API request context) tegen dev/staging; <host> = omgeving met actieve mu-plugins.
- Als anonieme client op
GET <host>/wp-json/custom/v1/openapidan HTTP 200 met een OpenAPI 3.1-document (titel "Gereformeerde Bond — Headless app-API"). - Als anonieme client op
GET <host>/api-docsdan HTTP 200 met een Swagger UI-pagina. - Als anonieme client op
POST /wp-json/gb-app/v1/loginmet geldigemail+wachtwoordvan een abonnee, dan 200 met access- én refresh-token. - Als anonieme client op
POST /gb-app/v1/loginmet onjuist wachtwoord, dan 401 met generieke melding "Onjuiste inloggegevens" (geen indicatie of het account bestaat). - Als client die 6× binnen 15 minuten fout inlogt op hetzelfde e-mailadres, dan bij poging 6 een 429.
- Als client op
POST /gb-app/v1/refreshmet een geldig vernieuwbewijs, dan 200 met een nieuw tokenpaar én het oude vernieuwbewijs werkt daarna niet meer (rotatie). - Als abonnee (Bearer) op
GET /gb-app/v1/me, dan 200 metis_subscriber:true; als ingelogde niet-abonnee danis_subscriber:false. - Als anonieme client op
GET /wp-json/wp/v2/wv-articlesdan 401rest_forbidden; idem voorissues,author,faith-questions,wv_article_categories,tr_article_categories,cgc_article_categories,tr-articles,cgc-articles. - Als anonieme client op
GET /wp-json/wp/v2/pages,/wp/v2/media/{id},/wp/v2/search?search=xdan 200 (géén afscherming). - Als ingelogde niet-abonnee (Bearer) op
GET /wp/v2/wv-articles/{id}van een afgeschermd artikel, dan 200 metgb_locked:true,content.renderedleeg +content.protected:true, en zonderacf.components/acf.article_components; titel,article_subtitle,article_intro,article_imageenarticle_author_datazijn wél aanwezig. - Als
wv-subscriber(Bearer) op hetzelfde artikel, dan 200 met gevuldeacf.components. - Als
wv-subscriberopGET /wp/v2/issues/{id}, dan bevatacf.issue_fileeen waarde; als niet-abonnee dan ontbreektissue_file. - Als client op
GET /wp/v2/wv-articles?article_issue={geldig-id}, dan alleen artikelen van die uitgave; met een niet-bestaand id een lege lijst; metarticle_issue=abceen 400. - Als client op
GET /wp/v2/wv-articles?article_author={geldig-id}, dan alleen artikelen van die auteur (geen false positives op deel-id's, bijv. 6064 matcht niet 16064). - Als
wv-subscriberopGET /gb-app/v1/issues/{id}/pdf-link, dan 200 met{url, expires_in:300}; de URL levert binnen de TTL eenapplication/pdf(inline) op; ná de TTL een 403. - Als ingelogde niet-abonnee op
GET /gb-app/v1/issues/{id}/pdf-link, dan 403gb_app_no_subscription; anoniem 401. - Als client op de getekende PDF-URL met een
Range-header, dan HTTP 206 met het gevraagde bytebereik. - Als client op
GET /wp-json/menus/v1/menus/12(dev), dan 200 met de 26 GB-menu-items;/menus/v1/locationsbevat de vier*_primary_navigation-locaties. - Als browser-client met
Origin: https://app.gereformeerdebond.nleen OPTIONS-preflight doet op een willekeurigewp-json-route, dan 204 metAccess-Control-Allow-Origingelijk aan die origin. - Als browser-client met een onbekende origin, dan geen
Access-Control-Allow-Origin-header in de respons. - Als anonieme client op
POST /wp-json/gf/v2/forms/{id}/submissionsmet lege verplichte velden, dan 200 metis_valid:falseen veldvalidatiefouten;GET /wp-json/gf/v2/formszonder key geeft 401. - Als client op
GET /wp-json/yoast/v1/get_head?url=<site-url>, dan 200 met de Yoast-head-HTML voor die URL. - Elke
wp/v2-respons van artikelen/uitgaven/auteurs bevatyoast_head_jsonmet minimaaltitle,canonicalenog_image(waar van toepassing).
Bekende afwijkingen¶
Niet nabouwen in Next.js — wel vermelden in het testrapport
- WPGraphQL staat lokaal anoniem open en omzeilt de REST-afscherming; de drie article-types delen één GraphQL-naam (defecte registratie). Live levert
/graphqlHTML. → Saneren, niet repliceren. /wp/v2/searchgeeft anoniem titels/URL's van afgeschermdewv-article-posts terug terwijl de detail-endpoints 401 geven — inconsistentie in de afscherming.menus/v1is niet actief op productie (wel op dev) — de rebuild vereist activering (zie gap-analyse); het huidige ontbreken is een omgevingsafwijking, geen feature.- CORS-allowlist bevat het toekomstige sitedomein niet; dit is een bekende beperking, geen bug.
- Debug-advertentie
#16280(Advanced Ads,allow_php=1metvar_dump($_SERVER)) staat nog in de database; nergens geplaatst, wel opruimen.