Common pitfalls when working with the Hello Club API, discovered through live testing. Items marked Verified have been confirmed against the live API and corroborated by Hello Club support (Mar 2026).
Verified. The API does not return historical data indefinitely for all entity types. Core entities (members, events, bookings, transactions) appear to be retained since club creation, but log endpoints are capped at approximately 2.5–3 years of history, regardless of the fromDate parameter.
Tested Mar 2026 with fromDate=2015-01-01 against a club created in Jun 2019:
| Entity | Endpoint | Earliest Record | Retention |
|---|---|---|---|
| Members | /member |
Jun 2019 | All time (since club creation) |
| Events | /event |
Jul 2019 | All time |
| Bookings | /booking |
Jun 2019 | All time |
| Transactions | /transaction |
Aug 2019 | All time |
| Access logs | /accessLog |
Mar 2023 | ~3 years |
| Activity logs | /activityLog |
Mar 2023 | ~3 years |
| Audit logs | /auditLog |
Jul 2023 | ~2.5 years |
| Check-in logs | /checkInLog |
Jul 2023 | ~2.5 years |
| Email logs | /emailLog |
Jul 2023 | ~2.5 years |
Verified. GET /event/upcoming returns 400 BadRequestError: "Invalid request" for all parameter combinations. Hello Club have confirmed this is not a bug — the endpoint has been removed, though it remains in the outdated OpenAPI spec. The same behaviour applies in V2.
Workaround: Use GET /event with fromDate and toDate:
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
events = client.get("/event", params={
"fromDate": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"toDate": (now + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"sort": "startDate",
})Note: Use
strftime("%Y-%m-%dT%H:%M:%SZ")rather thanisoformat(). Python'sisoformat()includes microseconds and+00:00offset which the API may not accept.
Verified. The spec says fromDate/toDate are optional for these endpoints, but the API returns 422 (ValidationError: "query.fromDate" is required) without them. Hello Club have confirmed this is intentional — the spec is outdated, not the API. The same behaviour applies in V2.
GET /checkInLog— returns 422 without datesGET /emailLog— returns 422 without dates
Fix: Always provide both fromDate and toDate:
logs = client.get("/checkInLog", params={
"fromDate": "2025-01-01T00:00:00Z",
"toDate": "2026-03-01T00:00:00Z",
})The official spec (version 2021-08-30) is significantly out of date. Roughly 40% of fields returned by the live API are undocumented. Key areas:
- Event
categoriesarray (name, color, id) — completely absent from spec - Member
grades,circles,groups(expanded objects),directory,vaccination - Attendee
isStillRefundable,rule.refundableUntil - Computed booleans like
hasSpacesLeft,hasMembers,canSignIn - Full
taxobjects on transactions - Activity log
stoppedBydetails
Recommendation: Use the field reference docs in this guide instead of the spec, or run the field discovery script against your own club.
Several fields were renamed in the live API compared to the spec:
| Spec Field | Actual Field | Entity |
|---|---|---|
hash |
intercomHash |
Member |
tagLastUsed |
lastTagUse |
Member |
welcomeEmailLastSent |
lastWelcomeEmail |
Member |
lowAccountCreditEmailLastSent |
lastLowAccountCreditWarning |
Member |
group (singular ID) |
groups (array of objects) |
Member |
canBeRefunded |
isStillRefundable |
Attendee |
privacy (single object) |
directory / staff / highlight |
Member |
To update a member's groups, you must use PUT /member/{id} (not PATCH). The groups field is rejected by PATCH with a 422 "not allowed" error.
The groups field must be an array of objects with at least id, name, and color. Sending an array of ID strings returns 422 ("must be of type object").
# WORKS — PUT with group objects
client._request("PUT", f"/member/{member_id}", json={
"firstName": member["firstName"],
"lastName": member["lastName"],
"gender": member["gender"],
"groups": [
{"id": "abc123", "name": "Pickleball", "color": "#cc0c98"},
{"id": "def456", "name": "Badminton", "color": "#ff0000"},
],
})
# FAILS (422) — PATCH with groups
client._request("PATCH", f"/member/{member_id}", json={"groups": [...]})
# FAILS (422) — PUT with ID strings
client._request("PUT", f"/member/{member_id}", json={"groups": ["abc123"]})Note: PUT requires
firstName,lastName, andgenderas mandatory fields. GET the member first to preserve existing values. Thecolorfield on each group object cannot be empty — an empty string returns 422 (string.empty). Always use the color value from the group definition (GET /memberGroup) or from the member's existing groups.
Tested: Mar 2026. Round-trip verified (add group → confirm → remove → confirm).
Verified. Member groups with isDynamic: true (visible in GET /memberGroup) have their membership computed automatically from ruleSets. Attempting to add or remove a dynamic group via PUT /member/{id} will:
- Return 200 OK with the group included in the response body
- Not persist the change — subsequent GET requests may still show the group (cached), but the Hello Club UI will not reflect it, and the membership will revert on the next rule evaluation
This is a silent data loss trap — the API gives every indication of success.
Check isDynamic on the group definition before attempting updates:
groups = client.get("/memberGroup", params={"limit": 100}).json()
dynamic_ids = {
g["id"] for g in groups.get("memberGroups", [])
if g.get("isDynamic")
}
# Before updating a member's groups, filter out dynamic ones
if target_group_id in dynamic_ids:
print("Cannot manually assign members to dynamic group"){
"name": "Badminton All",
"color": "#ffb300",
"isDynamic": true,
"ruleSets": [
{
"rules": [
{
"type": "group",
"condition": "oneOf",
"prop": "groups",
"value": ["group-id-1", "group-id-2", "group-id-3"]
}
]
}
]
}Dynamic groups do not appear in the member profile group checkboxes in the Hello Club admin UI — they are managed entirely by rules.
Tested: Mar 2026. PUT returned 200 with group present in response, but group was not visible in Hello Club UI and did not persist. Confirmed across 611 member updates.
The spec documents address fields as streetNumber + streetName, but the API returns:
{
"address": {
"line1": "123 Main Street",
"line2": "Unit 4",
"suburb": "Kensington",
"city": "Whangarei",
"state": "Northland",
"postalCode": "0112",
"country": "New Zealand",
"formatted": "123 Main Street, Kensington, Whangarei 0112",
"mapsLink": "https://www.google.com/maps/...",
"embedLink": "https://www.google.com/maps/embed/...",
"placeId": "ChIJ..."
}
}The formatted, mapsLink, embedLink, and placeId fields are all undocumented.
Member dob (date of birth) is an integer in YYYYMMDD format, not an ISO 8601 string:
{
"dob": 19850315
}Parse it as: year=1985, month=03, day=15.
List responses include a meta object, but the wrapper key varies by entity type. Don't assume a fixed key:
| Endpoint | Wrapper Key |
|---|---|
/event |
events |
/member |
members |
/eventAttendee |
attendees |
/membership |
memberships |
/transaction |
transactions |
/booking |
bookings |
Both events and members have a customFields object with club-configured fields. These vary completely between clubs. Don't hardcode field names — use the field discovery script to find your club's custom fields.
The 30 req/min limit is per API key, not per IP. Multiple applications sharing the same key share the same rate limit budget.
The API is inconsistent with empty values:
- Some fields return
[]when empty, others returnnull - Some string fields return
"", others returnnull - The
guestfield on attendees isnullfor members, andmemberisnullfor guests
Always check for both null and empty values when parsing.
Verified. Fix confirmed.
Sorting GET /member by anything other than the default (-lastOnline) produces duplicate records across pages, causing other members to be silently skipped. The offset-based pagination uses an unstable sort when records share the same sort value, so the same member can appear at different offsets on subsequent pages.
Root cause (confirmed by Hello Club, Mar 2026): The sort is unstable when multiple records share the same value for the sort field. To ensure stable sorting, append ,id to your sort specifier (e.g. sort=-updatedAt,id). Hello Club may change the API to do this automatically in future.
Original test (Mar 2026, full dataset — 2,143 total members):
| Sort | Records Returned | Unique Members | Duplicates | Missing Members |
|---|---|---|---|---|
-lastOnline (default) |
2,143 | 2,115 | 28 | 0 |
-updatedAt |
2,143 | 1,423 | 720 | 697 |
updatedAt (ascending) |
2,143 | 1,060 | 1,083 | 1,060 |
Verification test (Mar 2026, 3 pages of 100 members):
| Sort | Records | Unique | Duplicates | Page 1-2 Overlap |
|---|---|---|---|---|
-updatedAt (broken) |
300 | 217 | 83 | ~28% |
-updatedAt,id (fixed) |
300 | 300 | 0 | 0 |
The ,id tiebreaker completely eliminates the pagination bug.
Fetch page 1 and page 2 with sort=-updatedAt and compare member IDs:
page1 = client.get("/member", params={"limit": 100, "offset": 0, "sort": "-updatedAt"})
page2 = client.get("/member", params={"limit": 100, "offset": 100, "sort": "-updatedAt"})
ids_1 = {m["id"] for m in page1["members"]}
ids_2 = {m["id"] for m in page2["members"]}
overlap = ids_1 & ids_2
print(f"Members on BOTH pages: {len(overlap)}") # Expected: 0, Actual: 41Adjacent pages share 41 out of 100 members. Some members appear up to 6 times across the full result set, while ~700 members never appear at all.
The same test with the default sort shows 0 overlap — pagination is stable.
Any code that paginates through all members using sort=-updatedAt (e.g., for incremental sync based on last-modified date) will:
- Miss ~33% of members entirely
- Process ~33% of members multiple times
- Return a "complete" result set that is actually incomplete
Verified fix (confirmed by Hello Club, Mar 2026). Append ,id to any sort specifier to make pagination stable:
# Stable sort — 0 duplicates, 0 missing records (verified)
page = client.get("/member", params={
"limit": 100,
"offset": 0,
"sort": "-updatedAt,id", # id tiebreaker ensures stable ordering
})This works with any sort field, not just updatedAt. Always append ,id when paginating with non-default sort orders.
Verified. For incremental sync, Hello Club recommends filtering by updatedAt instead of sorting by it. Tested with 1,381 members matching a 7-day window — all returned members had correct updatedAt dates:
# Fetch only members updated since your last sync
last_sync = "2026-03-01T00:00:00Z" # store and update this after each sync
members = {}
offset = 0
while True:
page = client.get("/member", params={
"limit": 100,
"offset": offset,
"updatedAt": last_sync,
})
for m in page["members"]:
members[m["id"]] = m
if len(page["members"]) < 100:
break
offset += 100For full dataset fetches, use the default sort (-lastOnline) and deduplicate by member ID:
members = {}
offset = 0
while True:
page = client.get("/member", params={"limit": 100, "offset": offset})
for m in page["members"]:
members[m["id"]] = m # upsert — handles the rare default-sort duplicates
if len(page["members"]) < 100:
break
offset += 100Note: Even the default sort has ~28 duplicates out of 2,143 (1.3%), likely from members coming online during the paginated fetch. Always deduplicate by ID.
Tested: Mar 2026 against a club with 2,143 members. Verification script:
scripts/verify_fixes.py. Original test:scripts/test_api_sort_bug.py