Skip to content

Sync Architecture

Sportlink Club is the member administration system used by Dutch sports clubs, mandated by the KNVB (Royal Dutch Football Association). It is the single source of truth for member data, but it lacks APIs and has a limited, dated web interface. This tool extracts member data from Sportlink and syncs it to the systems where it’s actually needed: Laposta for email marketing, Rondo Club (a WordPress site) for club management, and FreeScout for helpdesk support. It also pulls contribution data from Nikki, a separate financial system. The goal is to keep all downstream systems in sync with Sportlink automatically, so club volunteers never have to enter the same data twice.

graph LR
SL[Sportlink Club]
NK[Nikki]
SYNC[Rondo Sync Tool<br>+ SQLite databases]
ST[Rondo Club]
LP[Laposta]
FS[FreeScout]
SL -->|Members, teams,<br>functions, discipline| SYNC
NK -->|Contributions| SYNC
SYNC -->|Members, custom fields| LP
SYNC -->|Members, parents, teams,<br>commissies, work history,<br>photos| ST
SYNC -->|Customers| FS
FS -->|Conversations| SYNC
ST -->|Field changes| SYNC
SYNC -->|Reverse sync| SL

Neither Sportlink Club nor Nikki provide APIs. All data is extracted by automating their web applications using Playwright (headless Chromium). The sync tool logs in, navigates pages, and intercepts the internal requests (like SearchMembers, MemberHeader, MemberFreeFields, UnionTeams) that the web app makes to its own backend. Nikki is different — it has no internal requests to intercept, so contribution data is scraped from HTML tables and CSV exports. The reverse sync also uses browser automation to fill in Sportlink’s web forms.

All times are Europe/Amsterdam timezone.

PipelineScheduleCronNotes
People4x daily0 8,11,14,17 * * *Members, parents, photos
NikkiDaily0 7 * * *Contributions to Rondo Club
Functions (recent)4x daily30 7,10,13,16 * * *30 min before each people sync; members updated in last 2 days + VOG-filtered volunteers
Functions (full)Weekly Sunday0 1 * * 0All members with --all
FreeScoutDaily0 8 * * *Rondo Club members to FreeScout customers
TeamsWeekly Sunday0 6 * * 0Team creation + work history
DisciplineWeekly Monday30 23 * * 1Discipline cases
Reverse SyncHourly0 * * * *Rondo Club changes back to Sportlink (currently disabled)
Every hour Reverse sync (Rondo Club -> Sportlink) [currently disabled]
07:00 Nikki sync
07:30 Functions sync (recent) -> 08:00 People sync (1st) + FreeScout sync
10:30 Functions sync (recent) -> 11:00 People sync (2nd)
13:30 Functions sync (recent) -> 14:00 People sync (3rd)
16:30 Functions sync (recent) -> 17:00 People sync (4th)
Sunday 01:00 Functions sync (full --all)
Sunday 06:00 Teams sync
Monday 23:30 Discipline sync

Every pipeline is designed to keep data as fresh as possible while minimizing API calls and avoiding overload on external services.

Selective Sync via Hash-Based Change Detection

Section titled “Selective Sync via Hash-Based Change Detection”

All pipelines use hash-based change detection. Each record gets a SHA-256 hash of its data (source_hash). On sync, the hash is compared to last_synced_hash. API calls only happen when hashes differ. This means:

  • Local work (reading SQLite, computing hashes) runs every time — this is cheap
  • Expensive work (API calls to Laposta/Rondo Club/FreeScout) only runs for actual changes
  • On a typical run with no changes, zero API calls are made

The Functions pipeline takes this further: the daily run only scrapes data for members updated in Sportlink within the last 2 days (plus VOG-filtered volunteers), avoiding the need to scrape all ~1000+ members every day.

When API calls are needed, all pipelines insert delays between requests to avoid overwhelming external services:

TargetDelayNotes
Laposta API2s between requestsFixed delay via waitForRateLimit()
Rondo Club APIExponential backoff on errors1s, 2s, 4s on retries
Rondo Club photo uploads2s between uploadsBoth upload and delete operations
Nikki to Rondo Club500ms between updates
Photo downloads (Sportlink)500ms-1.5s between membersPlaywright-based, random jitter
FreeScout APIExponential backoff on errors1s, 2s, 4s on 5xx errors
Sportlink browser scraping500ms-1.5s between membersRandom jitter to avoid patterns
Reverse sync (Sportlink)1-2s between membersRandom jitter, exponential backoff on errors

Each pipeline has its own detailed documentation page covering step-by-step flow, field mappings, CLI flags, error handling, and source files.

PipelineWhat it doesDetails
PeopleDownloads members from Sportlink, syncs to Laposta + Rondo Club, handles photos7-step flow: download, prepare Laposta, submit Laposta, submit Rondo Club (members + parents + birthdate), download photos, upload photos, reverse sync
NikkiDownloads contribution data from Nikki, writes per-year financial fields to Rondo ClubPlaywright scraping of HTML tables + CSV; aggregates multiple contribution lines per member per year
TeamsDownloads team rosters from Sportlink, creates team posts, links members via work history3-step flow: download teams, sync teams to Rondo Club, sync work history to person posts
FunctionsScrapes committee memberships and free fields from SportlinkRuns in daily (recent) and weekly (full) modes; also scrapes FreeScout ID, VOG date, and financial block used by the People pipeline
FreeScoutSyncs Rondo Club member data to FreeScout as customers; downloads conversations as activitiesEnriches helpdesk customers with team memberships, KNVB ID, Nikki contribution data, photo URLs, and website URLs. Downloads FreeScout conversations and creates activities in Rondo Club.
DisciplineDownloads discipline cases from Sportlink, syncs to Rondo ClubCases linked to person posts via knvb_id; categorized by season taxonomy
Reverse SyncDetects field changes in Rondo Club, pushes back to SportlinkCurrently disabled. Two-phase: change detection + browser automation sync with conflict resolution

Four SQLite databases on the server at /home/rondo/data/ track sync state. See Database Schema for full table definitions.

DatabasePurposeKey Tables
laposta-sync.sqliteLaposta sync + Sportlink run datamembers, laposta_fields, sportlink_runs
rondo-sync.sqliteRondo Club ID mappings + all Sportlink scraped datarondo_club_members, rondo_club_parents, rondo_club_teams, rondo_club_commissies, rondo_club_work_history, rondo_club_commissie_work_history, sportlink_member_functions, sportlink_member_committees, sportlink_member_free_fields, sportlink_team_members
nikki-sync.sqliteNikki contribution datanikki_contributions
freescout-sync.sqliteFreeScout customer mappings + conversation trackingfreescout_customers, freescout_conversations

The rondo_club_id mapping (knvb_id -> WordPress post ID) is critical: without it, syncs create duplicate entries instead of updating existing ones. All databases live on the production server only.


Each sync type uses flock via scripts/sync.sh to prevent overlapping runs of the same type. Different sync types can run in parallel. Lock files: .sync-{type}.lock in the project directory.