Skip to content

Leden (People) API Documentation

This document describes how to use the Rondo Club REST API to add and update “leden” (people/contacts).

All endpoints are relative to your WordPress installation:

https://your-site.com/wp-json/

The API supports two authentication methods:

Section titled “Method 1: Application Password (Recommended for External Integrations)”

Use HTTP Basic Authentication with a WordPress Application Password. This is the recommended method for scripts, external services, and API integrations.

  1. Generate an Application Password in WordPress: Users → Profile → Application Passwords
  2. Use your WordPress username and the generated password (with spaces)
Terminal window
curl -X GET "https://your-site.com/wp-json/wp/v2/people" \
-u "username:xxxx xxxx xxxx xxxx xxxx xxxx"

Or with the Authorization header:

Terminal window
curl -X GET "https://your-site.com/wp-json/wp/v2/people" \
-H "Authorization: Basic $(echo -n 'username:xxxx xxxx xxxx xxxx xxxx xxxx' | base64)"

For requests from the Rondo Club frontend (same browser session), use the REST nonce:

X-WP-Nonce: {nonce_value}

The nonce is available in window.rondoConfig.nonce when logged in to Rondo Club.

For fast remote operations from your terminal, use:

Terminal window
bin/person-values.sh find --query "jan"
bin/person-values.sh get --id 123 --field first_name
bin/person-values.sh set --id 123 --field former_member --value true --type boolean

The script uses these .env variables:

  • RONDO_API_URL
  • RONDO_API_USER
  • RONDO_API_PASSWORD

It updates people via REST only (no WP-CLI dependency) and supports fields, acf, and meta sources (--source auto by default).


Access Control: Users can only see and modify people they created themselves. Sharing and workspace visibility can extend access to other users.


MethodEndpointDescription
GET/wp/v2/peopleList all accessible people
GET/wp/v2/people/{id}Get single person
POST/wp/v2/peopleCreate new person
PUT/wp/v2/people/{id}Update person
DELETE/wp/v2/people/{id}Delete person
GET/rondo/v1/people/filteredPaginated people list with server-side name filtering and sorting
POST/rondo/v1/people/bulk-updateUpdate multiple people
POST/rondo/v1/people/{id}/photoUpload profile photo
POST/rondo/v1/people/{id}/provisionProvision WordPress user account (admin only)
GET/rondo/v1/people/{primary_id}/merge-preview?duplicate_id={id}Preview a person merge (admin only)
POST/rondo/v1/people/{primary_id}/mergeMerge a duplicate person into the primary record (admin only)
GET/rondo/v1/people/{id}/merge-targetResolve a merged source ID to its published survivor (admin/integration only)

Person summaries keep name as a backwards-compatible full display name and also expose the canonical parts first_name, infix, and last_name. List interfaces should render two data columns:

  • Voornaam: first_name
  • Achternaam: the displayed value is infix plus last_name

Sort surname columns by last_name, not by the combined display value. This keeps a name such as de Valk under V rather than D. Use first_name as the secondary sort for equal surnames.

GET /rondo/v1/people/filtered accepts first_name and last_name text filters. The last_name filter matches the displayed surname, including the infix. Both first_name and last_name are valid orderby values.

The leeftijdsgroep filter accepts one age group or a comma-separated list, for example leeftijdsgroep=Onder%206,Onder%207,Onder%2013. A person matches any selected group; other filters and the caller’s access restrictions still apply. Omit the parameter for all permitted groups. Existing single-group URLs remain valid. The People filter offers checkboxes for these choices, preserves them during navigation, and uses the same selection for pagination and CSV exports.

Pass has_rondo_account=1 for people with an existing linked WordPress account or has_rondo_account=0 for people without one. Omit it (or pass an empty string) for all people. Other values return HTTP 400.

Both _rondo_wp_user_id person metadata and legacy rondo_linked_person_id user metadata are recognized. Deleted accounts do not count. This checks account existence, not whether someone has signed in or completed their welcome flow. The lookup is read-only and preserves the caller’s person access restrictions.

In Relaties → Filter → Kenmerken → Rondo-account, choose Met account or Zonder account. Clear the selection for all people. This combines with Ouder/verzorger and other filters, persists as rondoAccount=1 or 0 in the page URL, and applies to totals, pagination and the CSV export.

Pass child_team=<team ID> to /rondo/v1/people/filtered to return parents/guardians whose current child plays in that team. The people list exposes this under Filter → Relaties → Team van kind and stores the selection as teamVanKind in the page URL. It works with account, volunteer-task and other filters, totals, pagination and CSV export. A parent appears once, including when several children play in the same team. Positive unknown or inaccessible team IDs return no matches; non-positive or non-integer values are rejected.

/rondo/v1/people/filter-options includes child_teams, an array of accessible published teams with id and plain-text name. Matching uses IDs, so teams with the same display name remain distinct.

The single-person REST response includes a read-only top-level children_teams array when relationship fields are included:

{
"children_teams": [
{ "child_id": 123, "teams": [{ "id": 456, "name": "JO13-1" }] }
]
}

The parent profile shows these teams as links beneath each child’s name in Relaties. The summary is separate from editable fields.relationships and must not be included in domain-field writes. Children without visible current player teams have no summary entry. Each team appears once per child.

Both directions use ParentRelationshipService, the configured player roles and VolunteerStatus::is_position_current(). Staff roles, future or ended player roles, inactive roles without an end date, former members, deceased people and unpublished records do not qualify. Child relationships must use the child relationship type. Person and team access checks apply before returning matches; scoped members whose work-history fields are hidden do not receive team summaries or child-team filter options. Filtering uses native WordPress metadata queries.

Pass spelactiviteit_no_team=1 to return people with a non-empty spelactiviteit field who do not have a current player role linked to a team. Current staff or volunteer roles on a team do not exclude a person. The endpoint resolves these candidates through the native field layer before applying the normal row-level access filters.

Deceased people are excluded from this endpoint by default, including CSV exports built from it. Pass include_deceased=1 to include them; this automatically includes former members as well. Summary records expose a read-only is_deceased boolean. The standard single-person REST response also exposes is_deceased, computed from fields.datum_overlijden.


Only administrators can preview or execute a merge. The primary person remains published; the duplicate is moved to the WordPress trash with _rondo_merged_into_person_id, _rondo_merged_at, and _rondo_merged_by audit metadata.

GET /rondo/v1/people/209/merge-preview?duplicate_id=8010

The response contains both person summaries, automatically combined fields, scalar conflicts that require a choice, blocking conflicts, and counts of linked domain records. Different non-empty stable identifiers of the same kind, or two different linked WordPress accounts, block execution.

POST /rondo/v1/people/209/merge
Content-Type: application/json
X-WP-Nonce: {nonce}
{
"duplicate_id": 8010,
"resolutions": {
"nickname": "primary",
"company_name": "duplicate"
},
"confirmed": true
}

Every conflict returned by the preview must have a primary or duplicate resolution. The service automatically combines unique emails, phones, addresses, relationships, work history, gallery/list values, person and sponsor roles, and empty scalar fields.

References in relationships, sponsor-company contacts (including archived companies), shifts, todos, discipline cases, invoices, clothing assignments, comments, attachments, and linked user accounts are moved to the primary person. The preview includes a references.sponsors count. Per-person shift metadata is renamed as part of the same operation. A successful merge returns the surviving person_id.

Sponsor contacts retain their roles, Sponsit IDs and pass settings. Matching contacts at the same company collapse to one row, combining their contact/pass flags. Different non-empty Sponsit IDs or roles at the same company block the merge, as do multiple primary sponsor-pass relationships after combining the profiles. Resolve these differences on the sponsor records first. These checks run in both preview and execution, before writes. Sponsor-contact writes are verified before the duplicate is moved to trash.

Trusted integrations that persist WordPress person IDs can resolve an ID that was merged away:

GET /rondo/v1/people/8010/merge-target
{
"person_id": 8010,
"merged_into_person_id": 209
}

The endpoint follows successive merge audit links until it finds the current published person. It returns 404 when the ID has not been merged or no published survivor exists. rondo-sync uses this response to repair its local Sportlink-to-WordPress mapping instead of recreating the duplicate.


FieldTypeDescription
acf.first_namestringPerson’s first name (required for auto-title generation)
FieldTypeDescriptionValues/Format
acf.first_namestringFirst nameAny string
acf.infixstringTussenvoegsel (e.g., van, de, van der)Any string. Read-only in UI, synced from Sportlink
acf.last_namestringLast nameAny string
acf.nicknamestringNicknameAny string
acf.genderstringGendermale, female, non_binary, other, prefer_not_to_say
acf.pronounsstringPronounse.g., “hij/hem”, “zij/haar”
acf.birthdatestringBirthdateY-m-d format (e.g., “1982-02-06”). Read-only in UI, synced from Sportlink
fields.datum_overlijdenstring/nullDate of deathY-m-d format. Synced from Sportlink’s inactive-member data
FieldTypeDescriptionValues
acf.former_memberbooleanWhether the person is a former member (oud-lid)true, false (default)

Note: This field is managed by rondo-sync. When a member is no longer found in Sportlink data, they are automatically marked as a former member. The field defaults to false for all new and active members.

When datum_overlijden is set, the person remains available as a read-only historical record. Rondo keeps the stored contact data, but central communication policy returns no recipient address for invoices, reminders, onboarding, VOG, IVA, shifts, account provisioning, and manual person-mail actions. Volunteer eligibility also treats the person as inactive.

Contact info is stored as a repeater field with multiple entries:

"acf": {
"contact_info": [
{
"contact_type": "email",
"contact_label": "Werk",
"contact_value": "[email protected]"
},
{
"contact_type": "mobile",
"contact_label": "Privé",
"contact_value": "+31612345678"
}
]
}

Contact Types:

  • email - E-mailadres
  • email2 - Tweede e-mailadres (Sportlink Email2)
  • phone - Telefoon (vast)
  • mobile - Mobiel
  • website - Website
  • linkedin - LinkedIn
  • twitter - Twitter/X
  • bluesky - Bluesky
  • threads - Threads
  • instagram - Instagram
  • facebook - Facebook
  • calendar - Agenda link
  • other - Anders

For Sportlink sync, a second mobile or landline number is stored as an additional mobile/phone row with contact_label: "2".

Addresses are stored as a repeater field:

"acf": {
"addresses": [
{
"address_label": "Thuis",
"street": "Hoofdstraat 123",
"postal_code": "1234 AB",
"city": "Amsterdam",
"state": "Noord-Holland",
"country": "Nederland"
}
]
}

Link people to teams with their role history:

"acf": {
"work_history": [
{
"team": 42,
"job_title": "Aanvoerder",
"description": "Aanvoerder van het eerste elftal",
"start_date": "2020-08-01",
"end_date": "",
"is_current": true
}
]
}
FieldTypeDescription
teamintegerTeam post ID
job_titlestringPosition/role title
descriptionstringRole description
start_datestringStart date (Y-m-d)
end_datestringEnd date (Y-m-d), empty if current
is_currentbooleanCurrently in this role

Link people to other people:

"acf": {
"relationships": [
{
"related_person": 123,
"relationship_type": 5,
"relationship_label": ""
}
]
}
FieldTypeDescription
related_personintegerRelated person post ID
relationship_typeintegerRelationship type taxonomy term ID
relationship_labelstringCustom label override

These fields relate to the user provisioning system. They are included in the API response when a person has a linked WordPress user account.

FieldTypeDescription
linked_user_idint|nullWordPress user ID linked to this person
welcome_email_sent_atstring|nullISO timestamp of when the welcome email was sent
linked_user_rolesstring[]Role slugs on the linked account; only included for administrators
linked_user_role_labelsobjectLabels keyed by role slug; includes built-in and custom Rondo roles and is only included for administrators
linked_user_switch_urlstringNonce-secured User Switching URL; only included for administrators who may switch to the linked account

These fields are automatically calculated and should not be set manually. They are included in the API response but ignored on create/update.

FieldTypeDescriptionValues
acf.huidig-vrijwilligerstringCurrent volunteer status, auto-calculated from work history"1" (volunteer) or "0" (not)
acf.is_deceasedbooleanWhether the person is deceasedtrue, false
acf.birth_yearstring/nullBirth year (derived from birthdate field)e.g., "1990" or null

Volunteer status logic: A person is considered a current volunteer (huidig-vrijwilliger = "1") if they have an active position where:

  • The position is in a commissie (any role, unless the commissie is exempt), OR
  • The position is in a team with a staff role (not a player role like Aanvaller, Keeper, etc.)

Positions with honorary/membership roles (Donateur, Erelid, etc.) are excluded. The status is recalculated automatically whenever the person is saved.

FieldTypeDescriptionValues
acf._visibilitystringWho can see this personprivate, workspace, shared
acf._assigned_workspacesarrayWorkspace term IDs[1, 2, 3]

Request:

POST /wp/v2/people
Content-Type: application/json
X-WP-Nonce: {nonce}

Body:

{
"status": "publish",
"acf": {
"first_name": "Jan",
"last_name": "de Vries",
"gender": "male",
"contact_info": [
{
"contact_type": "email",
"contact_label": "Werk",
"contact_value": "[email protected]"
},
{
"contact_type": "mobile",
"contact_label": "Privé",
"contact_value": "+31612345678"
}
],
"addresses": [
{
"address_label": "Thuis",
"street": "Sportlaan 45",
"postal_code": "1234 AB",
"city": "Amsterdam",
"country": "Nederland"
}
],
"_visibility": "private"
}
}

Response (201 Created):

{
"id": 456,
"date": "2026-01-25T14:30:00",
"slug": "jan-de-vries",
"status": "publish",
"type": "person",
"title": {
"rendered": "Jan de Vries"
},
"author": 1,
"acf": {
"first_name": "Jan",
"infix": "",
"last_name": "de Vries",
"gender": "male",
"birthdate": "",
"former_member": false,
"contact_info": [...],
"addresses": [...],
"work_history": [],
"relationships": [],
"huidig-vrijwilliger": "0",
"is_deceased": false,
"birth_year": null,
"_visibility": "private"
}
}

Note: The title is automatically generated from first_name, infix, and last_name. You don’t need to set it manually.


Request:

PUT /wp/v2/people/456
Content-Type: application/json
X-WP-Nonce: {nonce}

Body (partial update):

{
"acf": {
"contact_info": [
{
"contact_type": "email",
"contact_label": "Werk",
"contact_value": "[email protected]"
},
{
"contact_type": "mobile",
"contact_label": "Privé",
"contact_value": "+31612345678"
}
]
}
}

Important: When updating repeater fields (contact_info, addresses, work_history, relationships), you must send the complete array. Partial updates will replace the entire field.

Response (200 OK): Returns the full updated person object.

Example - Mark a member as former (used by rondo-sync):

Terminal window
curl -X PUT "https://your-site.com/wp-json/wp/v2/people/456" \
-u "username:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-d '{"acf": {"former_member": true}}'

Request:

GET /wp/v2/people/456
X-WP-Nonce: {nonce}

Response:

{
"id": 456,
"title": { "rendered": "Jan de Vries" },
"acf": {
"first_name": "Jan",
"infix": "",
"last_name": "de Vries",
"nickname": "",
"gender": "male",
"pronouns": "",
"birthdate": "",
"former_member": false,
"photo_gallery": [],
"contact_info": [...],
"addresses": [...],
"work_history": [],
"relationships": [],
"huidig-vrijwilliger": "0",
"is_deceased": false,
"birth_year": null,
"_visibility": "private",
"_assigned_workspaces": []
}
}

Request:

GET /wp/v2/people?per_page=20&page=1
X-WP-Nonce: {nonce}

Query Parameters:

ParameterTypeDefaultDescription
per_pageint10Items per page (max: 100)
pageint1Page number
searchstring-Search in name
orderbystringdateSort by: date, title, modified
orderstringdescSort order: asc or desc
_fieldsstring-Limit fields returned (comma-separated)

Example - Search for people named “Jan”:

GET /wp/v2/people?search=Jan&per_page=50

Example - Get only IDs and names (faster):

GET /wp/v2/people?_fields=id,title,acf.first_name,acf.last_name

Request:

DELETE /wp/v2/people/456
X-WP-Nonce: {nonce}

Response (200 OK):

{
"deleted": true,
"previous": { ... }
}

Update multiple people at once (e.g., assign to workspace, add labels).

Request:

POST /rondo/v1/people/bulk-update
Content-Type: application/json
X-WP-Nonce: {nonce}

Body:

{
"ids": [456, 457, 458],
"updates": {
"visibility": "workspace",
"assigned_workspaces": [5],
"organization_id": 42
}
}

Available bulk updates:

FieldTypeDescription
visibilitystringSet visibility for all
assigned_workspacesarraySet workspace IDs
organization_idintSet team association

Response:

{
"success": true,
"updated": [456, 457, 458],
"failed": []
}

The person detail page opens the photo crop editor before uploading. It sends the selected square as a JPEG up to 800 × 800 pixels through this existing endpoint.

Request:

POST /rondo/v1/people/456/photo
Content-Type: multipart/form-data
X-WP-Nonce: {nonce}

Form Data:

  • file: Image file (JPEG, PNG, GIF, WebP)

Response:

{
"success": true,
"attachment_id": 789,
"filename": "jan-de-vries.jpg",
"thumbnail_url": "https://your-site.com/wp-content/uploads/2026/01/jan-de-vries-150x150.jpg",
"full_url": "https://your-site.com/wp-content/uploads/2026/01/jan-de-vries.jpg"
}

Common Error Responses:

401 Unauthorized:

{
"code": "rest_not_logged_in",
"message": "You are not currently logged in.",
"data": { "status": 401 }
}

403 Forbidden:

{
"code": "rest_forbidden",
"message": "Sorry, you are not allowed to edit this person.",
"data": { "status": 403 }
}

404 Not Found:

{
"code": "rest_post_invalid_id",
"message": "Invalid person ID.",
"data": { "status": 404 }
}

400 Bad Request (validation error):

{
"code": "rest_invalid_param",
"message": "Invalid parameter(s): acf",
"data": { "status": 400 }
}

const API_BASE = 'https://your-site.com/wp-json';
const nonce = window.rondoConfig?.nonce || 'your-nonce';
// Create a person
async function createPerson(data) {
const response = await fetch(`${API_BASE}/wp/v2/people`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({
status: 'publish',
acf: data,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// Update a person
async function updatePerson(id, data) {
const response = await fetch(`${API_BASE}/wp/v2/people/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({ acf: data }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// Usage
const newPerson = await createPerson({
first_name: 'Jan',
last_name: 'de Vries',
gender: 'male',
contact_info: [
{ contact_type: 'email', contact_label: 'Werk', contact_value: '[email protected]' }
],
});
console.log('Created person:', newPerson.id);
<?php
// Create a person programmatically
$person_id = wp_insert_post([
'post_type' => 'person',
'post_status' => 'publish',
'post_author' => get_current_user_id(),
]);
if ($person_id && !is_wp_error($person_id)) {
// Set ACF fields
update_field('first_name', 'Jan', $person_id);
update_field('last_name', 'de Vries', $person_id);
update_field('gender', 'male', $person_id);
// Set contact info (repeater)
update_field('contact_info', [
[
'contact_type' => 'email',
'contact_label' => 'Werk',
'contact_value' => '[email protected]',
],
], $person_id);
}
// Update a person
update_field('nickname', 'Jan-Jan', $person_id);
Terminal window
# Create a person
curl -X POST "https://your-site.com/wp-json/wp/v2/people" \
-u "username:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-d '{
"status": "publish",
"acf": {
"first_name": "Jan",
"last_name": "de Vries",
"gender": "male"
}
}'
# Update a person (change name)
curl -X PUT "https://your-site.com/wp-json/wp/v2/people/456" \
-u "username:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-d '{
"acf": {
"first_name": "Johannes"
}
}'
# List people
curl -X GET "https://your-site.com/wp-json/wp/v2/people?per_page=10" \
-u "username:xxxx xxxx xxxx xxxx xxxx xxxx"
# Delete a person
curl -X DELETE "https://your-site.com/wp-json/wp/v2/people/456" \
-u "username:xxxx xxxx xxxx xxxx xxxx xxxx"

  1. Auto-generated Title: The post title is automatically created from first_name + infix + last_name (e.g., “Jan van de Berg”). You don’t need to set it.

  2. Repeater Fields: When updating contact_info, addresses, work_history, or relationships, always send the complete array. WordPress will replace the entire field.

  3. Access Control: Each user only sees people they created. Use visibility settings and sharing to extend access.

  4. Authentication Choice:

    • Application Passwords: Best for external scripts, integrations, and automation. No expiration, revocable per-app.
    • Nonces: Best for browser-based requests. Expire after 24 hours.
  5. Rate Limiting: There’s no built-in rate limiting, but be mindful of server resources when making bulk requests.


Documentation generated: 2026-01-25

From 35.83.0, an assigned team’s current players are visible in addition to the coordinator’s permitted age groups. This union applies to both the filtered list and core person collection/single-record endpoints, including pagination and CSV export. Explicit age-group filters still narrow the union. The current-user payload exposes permitted_team_ids alongside the existing permitted_age_groups; null age groups continues to mean unrestricted management access.