Skip to main content
Web Design & Development

Connecting LeagueApps to a CRM and a WordPress Website

A league-management platform is good at registrations, payments, rosters and schedules. It is not built to be a relationship database, a retention system, or a public website somebody reads on a phone while standing at a field.

For one volunteer-run league, that gap meant about 3,400 people in the registration platform, no safe way to email any useful subset of them, a website that sent visitors elsewhere for the team list, and ten years of history reachable only through exports.

We built two read-only layers beside the platform: a CRM layer holding contact history, roles, seasons and consent state, and a publishing layer putting approved team information on the website by division, refreshed daily.

Neither layer is a single integration. Four systems have an opinion here: the league platform, WordPress, a CRM, and a sending service, in this case FluentCRM and SendGrid. Each has its own idea of what a contact is, its own consent model, and its own failure mode, and the work is in the seams between them rather than in any one connection.

Licenses are not the cost. Time is, and it goes where most people do not expect. Authenticating against the API and pulling the first clean list took about two days. The data model took weeks. Roughly, where the time went:

WorkShare of the build
Auth, paging, and reading the exportsAbout a tenth
Deciding what to store, what never to copy, and who may be contactedThe largest single piece
Duplicate detection, consent state, and suppressionSubstantial, and it never fully ends
Publishing layer, field policy, and cache behaviorModerate
Testing the failure modes rather than the happy pathMore than the connector itself

Plan for the middle three. They are the parts a quote based on “connect these two systems” leaves out, and they decide whether the thing is safe to turn on.

The platform here is LeagueApps, and the specific findings are LeagueApps findings. The failure patterns generalize to any registration system you are pulling from. If you came for the API behavior, start at the two APIs and read backwards for the business case if you need to argue for the budget.

BeforeAfter
A board member needed someone to pull a list before emailing this season’s captains”Current season managers” is a saved segment
Sponsor and volunteer history lived in individual inboxesEach relationship has an owner, a contact history and a next step
Somebody with two accounts could get the same email twiceDuplicate candidates are grouped for a person to review
The league knew registrations, not retentionIt can see first-season retention, repeat players and who is lapsing
The website linked out for the team listIt publishes teams by division, refreshed daily
A member spreadsheet lived on a volunteer’s laptopThe data has a home, a backup and an access rule

None of this is about collecting more data. It is about no longer deciding from memory, scattered spreadsheets and one-off exports.

Registrations Are Not Relationships

Your platform knows exactly who registered for what and whether they paid. It is a transaction system, and a good one.

What it does not model is how somebody found you, whether they are getting more or less involved as distinct from registered or not, when they stopped, and anyone who is not a registrant at all: sponsors, venue contacts, the volunteer who never played, the person who emailed twice and never signed up.

None of that is a defect. A registration platform should be a registration platform. The trouble starts when it is the only system you have, because then nobody owns the relationship.

A decade of registration history for this league, counted: of everyone who had ever registered, 45% did it exactly once. Signed up, played one season, never came back. Not a bad year, not one cohort, nearly half of everyone across more than ten years. Nobody there knew, and that is not carelessness. The question had never been askable, because the history lived in a system that thinks in seasons rather than in people.

“Registered this season” is the obvious health metric because it is the number you have. It is also the last thing that happens. By the time somebody fails to register, the decision is months old. They had a season where they did not know anyone, or they got injured and nobody noticed, or their team folded and nobody put them somewhere else. A registration announces a decision made months earlier, so a retention program built on it is always responding to something already finished.

Keep the history in a shape you can query and these become available instead.

SignalWhat it suggests
Registered but never placed on a teamThe onboarding broke, not their interest
Played one season, then nothingThe single largest group. Mostly a first-season experience problem
Skipped a season, came backResilient. These people tolerate a gap and should not be written off
Was a captain, now a playerStepped back. Worth asking why before they step out
Volunteered once, never asked againAn ask that never happened, not a refusal

One field is worth adding before you build anything: how somebody found you, asked once, at the first point of contact, stored as an origin that never changes rather than a tag that gets overwritten. Ask at registration instead and the answer has already compressed to “the website.” Include “a friend or teammate” as an option and expect it to win by a wide margin. That is a real answer, not a dodge: if most people arrive through somebody they already know, the best thing you can do is make it easy for a current member to bring one.

The Retention Number That Changed Our Priorities

The platform could produce a cohort retention table once we went looking. First-year retention, meaning of everyone who joined in a given year how many came back the next, ran between 41% and 50% in normal years.

That number compounds. A cohort that loses half in year one is down to single digits by year five or six. Everything after the first year is a rounding error next to the first year.

So your first-season experience is your retention program. Whatever you were going to spend winning people back is better spent on the first eight weeks. Somebody who knows three names by week three comes back; somebody who does not, does not.

It also means you should watch the cohort and not the headcount. Total registrations can sit flat while retention collapses underneath, with new recruitment papering over the loss. Flat is not stable. Flat can be a leaky bucket with a bigger hose.

What We Built, and What It Cost

A read-only sync that runs nightly and keeps a short summary on each CRM contact: first and last season, how many seasons, current role, current status. Enough to choose an audience in ten seconds, and deliberately not a second copy of the league platform.

The benefits landed roughly in this order.

  • “Email the captains this season” became a filter instead of an afternoon. That was the original ask, and it is now trivial.
  • Suppression became automatic. About a tenth of the member list must never be emailed, and nothing had previously stopped somebody pasting a full export into a campaign.
  • Problems nobody knew about surfaced. Seven percent of members held more than one account, and over a hundred people were getting every message twice. Nobody had complained.
  • Tenure became a real number instead of “long-time member” as a vibe. That is the difference between a thank-you that lands and one that gets a correction.
  • It survives the board. The knowledge sits in a documented system rather than with whoever currently knows the trick.

And the one most organizations do not think about until later: it gets the spreadsheet off somebody’s laptop. Names, addresses, dates of birth and phone numbers, in a volunteer’s downloads folder, forever. If you do nothing else in this article, fix that.

Why Nobody Can Email 3,400 Members

The organization has about 3,400 contact records, and it is tempting to look at that number and plan a campaign around it.

Roughly ten percent of those addresses have bounced, gone invalid, unsubscribed or reported a message as spam. Several hundred more never opted into anything. The number you can write to is closer to 2,200.

Participating in something is not permission to email somebody. We built that in as a hard boundary: the import can create a contact, record their history and apply internal tags, but it is structurally incapable of adding anyone to a mailing list or changing a subscription status. The code refuses at the last possible moment before the write, because a policy that lives in a comment is one refactor away from being gone. New contacts arrive marked pending and on no list. Consent comes from a form somebody filled in, or it does not exist.

The same holds for anything else you collect. Permission to publish a member spotlight is not permission to subscribe somebody to a newsletter, and wiring those to the same list quietly converts a favor into a subscription.

Which leaves the question of who you can reach out to, and how far back. Recency predicts return better than anything else we looked at. Somebody who played last season and did not register this one is a good audience: they know what it is, they liked it enough to do it once, something small got in the way. Somebody who played six years ago is a different person with a different life, and the further back you reach the more the exercise becomes analysis rather than outreach.

There is one more risk here that most email advice skips. An address that has not heard from you in eight years may not belong to that person anymore. Employers reassign them, households share them, people move on. For most organizations that is a bounce and a shrug. For an affinity or identity-based group, a message revealing somebody’s past association with you can disclose something they did not choose to make public, to whoever holds that address now. That is a different risk from a deliverability metric, and a good reason to move slower than your email tool allows.

The rule we settled on is deep data, shallow contact. Use the old history for analysis, not for outreach. The people who left are the only reason your retention rate means anything. That does not make them an audience.

Duplicates are the other reason the headline number lies. We assumed a handful of people had two accounts. Matching on name plus date of birth rather than name alone, it is 229 people, about seven percent. Some have three. Two have five. Of those, 110 are live and subscribed, which means 110 people currently receive every message more than once.

The source data already knew which account was real. Last-login dates separate the person deliberately running two accounts, whose logins are weeks apart, from the person who forgot they had an old one, dormant for two years. Those need opposite handling. We still do not merge automatically: the system groups them and shows a human the evidence, because a wrong merge sends somebody’s mail to an address they abandoned, which is invisible to you and looks like silence to them.

Sponsors and Partners Live in Nobody’s System

The most overlooked group, every time. Sponsors, community partners, venue contacts, the person at the other league you co-host with, the volunteer who has never played a game. None of them are registrants, so none of them exist in the league platform. They live in one board member’s inbox and they leave when that board member does.

This is the highest-return change available to most organizations. One CRM record per relationship: who they are, who owns it as a named person rather than “the board”, when somebody last spoke to them and who, and what was agreed, in one line. No pipeline stages, no scoring. The value is not sophistication. It is that the sponsorship conversation from two years ago is findable by somebody who was not there.

Publishing to the Website Without Rebuilding the Platform

Most organizations feel the public half first. Your schedule lives inside a platform members have to log into, so your website links out to it, and people stop trusting either one.

The instinct is to rebuild the platform in WordPress. Resist it. Keep the platform as the system that runs the league and build a read-only publishing layer beside it. A team name, a game time, a division assignment or a score gets corrected in the platform, and the website receives the correction. One place to fix things.

Read-only has to mean something stronger than a setting. Our plugin cannot write to the platform because there is no method that does it: the client exposes named read operations and no general request($method, $url) for somebody to pass “POST” to later. The credential can read more than the website needs, so the plugin exposes no member-data operation at all. Even holding the credential, the production code has no path that asks for member records. A comment asking people not to would not survive a year of patches.

The field policy is two lists. The allowlist is what we take. The denylist is what must never be taken even if somebody widens the allowlist by mistake, and a test fails loudly if a denied field reaches storage. One limitation went into the docs rather than being glossed over: team names exist only on registration rows, so deriving teams that way carries personal data in transit for the moment before the allowlist drops it. It is never stored and never logged, but “we never read it” would have been inaccurate.

The tournament wanted to show where each team travels from, and the obvious field is the city on the registration. That city is the registrant’s home address, not the team’s. In the record it sits between the street address and the zip code. One team showed Frisco, another Fort Worth, another a suburb of St Louis. Printed beside a named manager, it tells anyone reading roughly where that person lives.

Mapping the city to its metro fixed both halves at once. Dallas is more useful to a reader than Frisco, and it narrows a named individual to eight million people rather than two hundred thousand. Showing a location instead of the manager’s name, rather than beside it, breaks the link entirely. A lookup table of about fifty metros covers a national circuit. Anything unmatched falls through as written, which is the least surprising behavior and also the more identifying one, so the docs say so.

Every page we publish carries the time of the last successful sync and a link to the official source. On a tournament weekend, a schedule without a freshness stamp is less trustworthy than a link.

LeagueApps Has Two APIs and You May Have the Wrong One

This cost us more time than anything else in the project, so it goes first.

Most sports platforms, this one included, run two separate APIs. A private export API, authenticated with a signed certificate, returns registrations, where every row carries a name, an email address, a phone number, a street address, a date of birth and a payment status. A public API, authenticated with a plain key, returns teams, programs and venues.

We had a working private credential that read registrations perfectly, so we derived the team list from registrations, built a field allowlist to throw away the ninety percent that was personal, and wrote a careful privacy boundary around the whole thing. The public API returns the same team list directly, in eight fields, none of them personal. We had built the harder, riskier version of something the platform already offered safely.

How we ended up there matters more than the mistake itself. The documentation lists the public resources but does not publish the request format, and when we tried it our credential came back with a rejection that looked like an answer:

403 {"errorCode":40001,"message":"Invalid API Key"}

A valid key returned that. An invalid key returned that. So did no key at all. From outside there is no way to tell a wrong key from a wrong parameter name from a disabled account, because the gateway answers all three the same way. We concluded the public API was not available to us, and we were wrong about that for weeks.

What broke it open was crossing two keys against two sites and looking for a different error rather than a successful response:

key A + site 1   403 Invalid API Key
key A + site 2   403 Invalid API Key
key B + site 1   500 Unknown Exception      <- different
key B + site 2   404 Site Not Found         <- different

Key B was getting past authentication. That told us the key was valid, the header name was right, and the remaining failures were about the site rather than the credential. The transport was a custom request header, not a query parameter.

If every input gives you the same failure, the input is not the variable. Change something else. We had two keys and two accounts, tried all four combinations, and watched for an error that read differently. One did, and that was enough to work backwards from.

The caution runs the other way too. A working credential proves nothing about other endpoints. For a while we treated a 404 on schedules as proof that schedules did not exist, while asking the wrong host, with the wrong credential, on paths we had invented. That 404 told us nothing. Before telling a client their platform cannot do something, check that you asked it a question it could hear.

Authenticating Against LeagueApps, and What the Docs Get Wrong

Auth is OAuth 2.0 with a signed JWT, and the discovery document is wrong.

LeagueApps publishes a standard .well-known/openid-configuration on their auth host. It advertises authorization_code and private_key_jwt. Build against that, JWT as a client_assertion with grant_type=client_credentials, and you get 401 invalid_client with a perfectly valid key. What works is the older JWT bearer grant:

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<your signed JWT>

The JWT is RS256, signed with the private key from the .p12 they give you. Issuer and subject are both your client ID, and audience is the token endpoint itself. Five more things are not written down anywhere useful:

  • Your client ID is the key’s name. In the console, a private API key has a “Key Name” that looks like a random hex string. That is the client ID, it is not labeled as one, and it is easy to go hunting for a separate value that does not exist.
  • The .p12 password is notasecret. The conventional default for an exported key, and there is no password field in the console to tell you.
  • The token endpoint is not the URL you would guess. The obvious /v2/auth path on the auth host redirects to a human login page, which is hard to debug because it looks like an authorization failure rather than a wrong address. The real endpoint has /token on the end, stated only in the discovery document, the same document that is wrong about everything else.
  • The data is on a different host from the one you authenticated against, and from the one in their examples. One host answers for site metadata and returns 404 for everything interesting. The exports live on their admin host. We only found it because a deprecated endpoint returns a 410 whose error body names the replacement URL. If you get a 410 from LeagueApps, read the body. It is the most useful thing in the API.
  • The current export endpoints have a -2 suffix. The un-suffixed names are in older documentation and now return that 410.

Tokens last about fifteen minutes and come back scoped leagueapps:export, read-only and enforced on their side, which is a good design decision: a bug in your code cannot write to their system.

Every export endpoint needs two parameters and will only complain about one at a time:

?last-updated=<milliseconds since epoch>&last-id=<0 for the first page>

Send only the first and you get a 400 asking for the second. Send only the second and you get a 400 asking for the first. A five-minute problem once you know, a twenty-minute problem while you do not.

The pairing is well designed once you see it. last-updated is your incremental watermark and last-id is a keyset cursor. Page size is 1,000, undocumented, so a few thousand members is a handful of calls and a nightly delta is usually one. No rate-limit headers come back at all, so until they tell you otherwise: one request at a time, sequential pagination.

One gap has consequences beyond engineering. The API has no email deliverability status. You can read members, registrations and programs, but nothing tells you an address has bounced or that somebody marked a message as spam. That lives only in a CSV export from the admin UI, which means a sync built on the API alone will mail dead addresses and damage your ability to reach everybody else. You need both sources on different cadences: the API nightly, the CSV occasionally and by hand.

A fair word about the platform. The API works, it is quick, the read-only scope is the right call, and the watermark design suits a nightly sync exactly. The engineering is sound. The documentation is behind it, and the discovery document misleads. Only one of those cost us a day.

The Paging Bug That Read a Tenth of the Data

Because the two parameters are required together, they look like a pair of equals. They are not. The timestamp is the cursor, and the id only breaks ties inside a single timestamp. We advanced the id and left the timestamp at zero, so every request fetched the first page again.

On the tournament account this was invisible, because all 871 rows arrive in one page. On the league account, with ten years of history, it read 1,000 rows out of 10,763 and stopped.

The only reason it failed loudly rather than quietly publishing a tenth of a league was a guard written for a different purpose: if the cursor does not move between requests, stop and report the read as incomplete. That guard existed to stop an endpoint that ignores its cursor from looping until something kills the job. It caught a truncation it was never written for.

Any paging code should treat “I asked for the next page and got the same page” as a failure rather than an ending.

Every Serious Bug We Found Reported Success

The integration itself was two days. The data model took considerably longer, because almost every serious bug we found reported success. None threw an error. Several would have run for months producing confident, wrong output. It is the pattern we would most want another developer to watch for.

A Renamed Tag Stopped the Import, Quietly

The connector applies tags to contacts: player, manager, coach. It found those tags by name. Rename a tag in the admin, a two-second act and a reasonable one, and the import keeps running, keeps reporting how many contacts it updated, and applies no tag at all. No error, and the contacts look fine when you open them. What breaks is the segment built on that tag, which now matches fewer people than it should.

The fix was to write down the expected shape of the CRM, every field and type and tag, and check it before any write. If the shape has drifted the import aborts rather than adapts, because adapting is how a renamed tag becomes a second near-duplicate tag and you end up with two segments holding half your members each. When an integration meets a mismatch, stopping is safer than coping. Coping is silent. Stopping is loud, and loud is what you want.

We Invented the Vocabulary and Every Value Was Wrong

Before we had a real export, we built the import around reasonable-sounding values. Roles would be player, manager, coach. Registration status would be completed, started, canceled.

Then the real export arrived, and not one of those values appears anywhere in ten thousand rows. LeagueApps uses different words for all of them, in different casing, and splits one of our fields into two. The word most people reach for when they mean “the person who runs a team” is not the word LeagueApps uses.

That matters more than it sounds, because a filter matching nothing looks exactly like a filter that works. The import would have run, reported success, and produced contacts with no role. You would find out when somebody asked why an email went to eleven people instead of eighty.

Never write code against a vocabulary you have not seen in the source data. Not a guess, not the documentation’s example. The values, read out of a real export. If you cannot get one yet, build everything else and leave that part unwritten.

The Undo Button Did Nothing and Reported Success

Every import got a batch ID so it could be rolled back. We tested it: it deleted the right contacts and left the others alone. Then we tested at realistic scale. Import 1,500 contacts, roll back, and it deleted zero, reporting “0 to delete” as though that were a successful outcome.

Each contact stored the ID of the batch that last touched it. Run a second import, and every contact from the first now carried the second batch’s ID, so rolling back the first found nothing. The safety net had silently stopped working for every batch except the most recent one, which is the batch you are least likely to need to undo.

Two fixes. One field for the batch that created a contact, written once and never changed, and another for the batch that last touched it, free to move. Plus a rule worth applying everywhere: “nothing to do” and “it worked” must never produce the same message. A rollback that found no matching batch, one that found a batch with nothing in it, and one that deleted 190 contacts are three different outcomes needing three different answers.

There is a worse version of this bug: suppose it had worked perfectly and deleted everything the batch created. A contact created by a mistaken import does not stay mistaken. Someone signs up for the newsletter, someone registers for the season, a volunteer adds a note. Three weeks later, “delete everything that import created” destroys real work, silently.

So the code no longer asks whether this import created them. It asks whether anything has happened to them since: subscribed to something, status changed, a note added, a tag no import applies, touched by a later process. Any of those and it refuses to delete and flags the record for a person. A record wrongly kept costs somebody ten seconds. A record wrongly deleted takes their preferences, their consent history and their form submissions with it.

What LeagueApps Would Not Give Us

We cannot publish schedules or standings. Once we were on the correct API with a key it accepted, we asked properly:

/sites/{site}/programs/{program}/teams        200
/sites/{site}/locations                       200
/sites/{site}/programs/{program}/schedules    404
/sites/{site}/programs/{program}/games        404
/sites/{site}/programs/{program}/standings    404

That 404 means something, because the paths beside it, on the same host with the same key, return 200. The schedule endpoints are not there for this credential type.

The honest option is a supported export with an approval step, which can be reliable even though it is not automatic. Export the official schedule, upload it to a controlled import screen, validate the columns, divisions, fields, dates, timezone and game ids, compare that against the last published version, show staff every changed time, field, matchup and status, and publish only what somebody approved.

That is an accountable publishing workflow rather than a sync, and for a lot of leagues it beats an automatic one nobody trusts. What we will not do is scrape the platform’s rendered pages. It breaks without warning, it is difficult to validate, and it makes a client’s public site depend on a vendor’s HTML.

Four Smaller Traps That Cost Us Time

One Token Cache for Two Accounts

When we connected the second account, everything broke in a way that looked like a permissions problem. The cache was keyed globally, the first account to authenticate filled it, the second reused that token, and the platform answered 403. With one account this is invisible. With two it looks exactly like an entitlement issue, and you will go and ask the vendor about entitlements. Cache anything credential-shaped under a key derived from the credential. It costs one line.

A Cache Served the Public Copy of a Private Page

Protection was switched on, every child page redirected to sign in, and two parent pages carried on handing their cached public version to anyone who asked. The server answered from cache and the application never ran, so the new rule never got a chance. Separately, renaming a division heading wrote nothing to the data table, so the cache generation never moved and the old heading kept rendering. Same shape both times: enabling a rule has to invalidate what the rule now covers, and a settings change counts as a content change. On managed hosting there is a third version, where our Cache-Control: private, no-store header never reached a browser because the web server strips it and writes its own. Read what your host does, not what the standard says should work.

The Command Line and the Web Server Ran Different PHP

Every command-line check passed and every browser request returned a 500. The CLI ran PHP 8.2 and the web pool ran 8.0, so every check we had run tested a version the site does not serve pages with. The plugin now refuses to load on an unsupported version with an admin notice rather than taking the site down. The Requires PHP header only gates the updater: a copy already on disk is parsed regardless, and a parse error in a required file takes down the admin too, so you cannot get in to remove it.

Passing Tests Were Not Evidence That Guards Work

All 99 tests passed, so we started deleting the safety guards one at a time to see whether any test would notice. Two went unnoticed: a gate that refused to plan deletions from an incomplete read, and a re-check in the writer. Both read like defense in depth. Both sat behind an earlier check that caught everything first, so no test ever reached them, and the next person to read the code would have trusted them anyway. Two more tests passed for the wrong reason, which no coverage number will tell you.

When your tests pass, delete each guard in turn and confirm something fails. It takes a few minutes, and it is the only way to learn whether a guard does anything.

What to Do First

If you run the organization:

  1. Count how many members have taken part exactly once. You already have the data, and the number tells you whether your problem is recruitment or retention. It is usually retention.
  2. Work out who you may contact today and who must be suppressed, before anyone plans a campaign.
  3. Start asking how people found you at first contact. Today, before you build anything.
  4. Get sponsors and partners out of somebody’s inbox. One afternoon, and the best return on effort on this list.
  5. Fix the first season before you build re-engagement. Winning back a lapsed member is harder and worth less than keeping a new one.
  6. Find out what API or export access you have before commissioning a build.

If you are building it:

  1. Get a real export before you write the import. Every assumption about the shape of the data will be wrong, and wrong assumptions fail quietly.
  2. Test by breaking it. Rename things, change a field type, kill the process halfway. A test that only proves the happy path proves the least interesting thing.
  3. Check the surface that matters. We had markup that rendered perfectly on the front end and was invalid in the editor. Verifying in the wrong place produces confidence, which is worse than not verifying.
  4. Make “nothing happened” loud. Most of what went wrong here was a system doing nothing and calling it done.

Authenticating against the API is an afternoon. What your CRM should hold, what it must never do, what happens when two records are the same person, and who is allowed to receive what: that is the part that takes judgment, and it is the part that matters when somebody gets an email they never asked for.

We build these connections for a living, against WordPress, FluentCRM and other CRMs, and Zapier where a hosted connector is the honest answer instead of custom code. If you have a league, club, school or organization with member data stranded in a platform that will not talk to anything else, get in touch and tell us what you are trying to reach people about. If it turns out you do not need a build, we will say so.

Planning a New Site or a Rebuild?

We build sites that stay maintainable after launch, on the platform that fits how your team works.