Roll Call API
Connect automation tools to your gym. The Roll Call API serves narrow, read-only projections of gym facts and accepts structured commands— requests for action that Roll Call validates, applies through the same code paths its own dashboard uses, and may reject. It never exposes raw records, payment instruments, biometric data, or another gym's members.
Basics
| Base URL | https://api.rollcallaccess.com/v1 |
| Auth | Authorization: Bearer <token>— a scoped key minted from your gym's Settings page |
| Versioning | Path-versioned (/v1). Additive changes don't bump; breaking changes ship as /v2 alongside. |
| Tenancy | Every key is bound to one gym. Every response is scoped to it. |
Every read response uses one envelope — the payload plus explicit freshness:
{
"data": { /* endpoint-specific */ },
"meta": {
"asOf": "2026-08-04T09:30:00+10:00", // when Roll Call computed this projection
"source": "live", // computed at request time, never a cached mirror
"requestId": "req_01J4X8ZK9M" // echoed in logs on both sides
}
}Authentication & keys
Keys are minted (and revoked) by the gym owner at owner.rollcallaccess.com/settings → API access. A key is shown once at creation and stored hashed — treat it like a password. Each key carries scopes:
| Scope | Grants |
|---|---|
read | The read projections below |
command | POST /commands |
A missing or revoked key is 401; a valid key without the needed scope, or one bound to a different gym, is 403.
Read endpoints
Look up a member by email
/members/lookup?email=<urlencoded>scope: readCase-insensitive exact match on the member's contact email — never a search or list. Returns the member-summary projection, or 404 when no member of your gym has that email.
{
"data": {
"memberId": "0b2f6d6e-…",
"displayName": "Rocco Talevski",
"status": "active", // active | current | trial | grace_period |
// overdue | suspended | cancelled | staff |
// guest | pending
"statusReason": null,
"statusChangedAt": "2026-07-01T10:00:00+10:00",
"joinedAt": "2025-11-14T10:00:00+11:00",
"isMinor": false,
"household": null, // or { "householdId": "…", "role": "guardian|junior|spectator" }
"flags": { "hasOpenFollowUp": false, "onHold": false }
},
"meta": { "asOf": "2026-08-04T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Member summary
/members/:memberIdscope: readThe same projection as the lookup, keyed by member id. flags.onHold reflects an active membership hold; flags.hasOpenFollowUp means staff already have an open task for this member.
Membership status
/members/:memberId/membershipscope: readThe member's current plan and hold state. No price instruments, no processor payloads.
{
"data": {
"plan": {
"name": "Adults Unlimited",
"priceCents": 4500,
"cadence": "weekly", // weekly | fortnightly | monthly | one_off | per_visit
"planType": "recurring" // recurring | one_off | class_pack | casual | prepaid_term | comp
},
"startedAt": "2025-11-14T10:00:00+11:00",
"currentPeriodEnd": "2026-08-11T00:00:00+10:00", // null when open-ended
"billingRail": "stripe_owned",
"hold": { "active": false, "resumesAt": null },
"scheduledHolds": [], // upcoming pauses, soonest first
"revenueClass": "recurring"
},
"meta": { "asOf": "2026-08-04T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Pause options
/members/:memberId/membership/pause-optionsscope: readAnswers "could a pause succeed, and within what limits?" before you send the command — computed from the same rule code the command uses, so it never disagrees with a real attempt. Every gym sets its own hold rules per plan (minimum/maximum length, a per-term day cap, whether a pause extends the contract); check this first and only propose pauses that fit.
{
"data": {
"canPause": true,
"reason": null, // blocking reason code when canPause = false
"rules": {
"minDays": 7, // null = no minimum
"maxDays": 28, // null = no maximum
"capDaysPerTerm": 30, // null = uncapped
"usedDaysThisTerm": 0,
"remainingDaysThisTerm": 30, // null = unlimited
"noticeDays": 0,
"extendsContract": true // a pause pushes the contract end out
},
"currentHold": { "active": false, "resumesAt": null },
"scheduledHolds": [ // upcoming pauses, soonest first ([] = none)
{ "startsAt": "2026-08-17T00:00:00+10:00", "resumesAt": "2026-08-24T00:00:00+10:00", "days": 7, "source": "api" }
],
"term": { "start": "2026-07-01T00:00:00+10:00", "end": "2027-07-01T00:00:00+10:00" }
},
"meta": { "asOf": "2026-08-04T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}When canPause is false, reason carries the same code the command would reject with (see the table below).
Attendance history & trend
/members/:memberId/attendancescope: readRecent visits plus an 8-week trend. All counts are distinct training days (two classes in one day count once — the same unit the member sees), over confirmed attendance only.
{
"data": {
"recent": [
{ "sessionId": "6f2a…", "className": "No-Gi Fundamentals",
"attendedAt": "2026-08-04T18:00:00+10:00" }
],
"weeklyCounts": [2, 3, 3, 2, 3, 1, 2, 3], // distinct training days/week, most recent first
"trend": "stable", // stable | improving | declining | insufficient_data
"lastAttendedAt": "2026-08-04T18:00:00+10:00",
"lifetimeVisits": 148 // distinct training DAYS
},
"meta": { "asOf": "2026-08-05T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Payment state (observed)
/members/:memberId/paymentsscope: readObserved state only — enough to reply to a member intelligently, never amounts, invoices, or payment instruments. dishonoured means a debit bounced in the last 14 days; arrearsfollows the member's status ladder.
{
"data": {
"state": "clear", // clear | arrears | dishonoured | unknown
"lastObservedAt": "2026-08-01T10:00:00+10:00",
"rail": "stripe_owned",
"lastDishonourAt": null,
"escalation": null, // or { "memberStatus": "overdue", "graceEntersAt": "…" }
"openFollowUp": null // or { "kind": "dishonour", "createdAt": "…" }
},
"meta": { "asOf": "2026-08-05T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Lead & trial status
/members/:memberId/trialscope: readWhere this person sits in the funnel: are they a lead, has a trial started, did they show up, did they convert — plus their next booking.
{
"data": {
"memberId": "0b2f6d6e-…",
"isTrialLead": true,
"source": "facebook", // lead channel; "direct" when unknown
"createdAt": "2026-07-28T12:00:00+10:00",
"trial": { // null until a trial plan is attached
"startedAt": "2026-07-29T09:00:00+10:00",
"endsAt": "2026-08-05T09:00:00+10:00",
"attendedCount": 2, // distinct training days since the trial started
"outcome": "in_progress" // in_progress | converted | trial_ended | cancelled
},
"booking": { // next upcoming (or most recent) booking, or null
"bookingId": "…", "sessionId": "…", "className": "No-Gi Fundamentals",
"startsAt": "2026-08-06T18:00:00+10:00", "status": "booked"
}
},
"meta": { "asOf": "2026-08-05T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Lead reads
The speed-to-lead surface: who just enquired, what they were offered, whether they're ready to train, and what contact has already happened. Every lead is minted as a Roll-Call-native member the moment it exists, so lead ids are member ids — there is no separate lead record to reconcile. Roll Call also pushes signed lead lifecycle events (lead.created@v1, lead.status_changed@v1, and trial.booked@v1 / trial.cancelled@v1 for lead bookings) to a consumer endpoint configured per deployment — outbound events are not configured per API key. These reads are the pull side: the reconciliation and detail behind those events.
Recent leads
/leads/recent?since=<ISO8601>scope: readLeads created or changed since the timestamp — the reconciliation read behind the pushed lead events. Converted and dead leads stay visible so your status reconciliation works; filter on status yourself. Ordered by most-recent activity (created or status-changed) descending, capped at 200 (meta.cap — narrow since if you hit it; meta.since echoes your parameter). A since that isn't ISO 8601 is 422.
{
"data": [
{
"memberId": "9c41d2ab-…",
"displayName": "Jess Lee",
"status": "pending", // same vocab as the member summary; a lead on
// an active trial reads "trial", a converted
// lead reads "active"
"statusReason": "trial_lead", // nullable
"source": "facebook", // creation channel; "direct" when unknown
"createdAt": "2026-08-20T09:00:00+10:00",
"contact": { // keys omitted when unknown
"phone": "+61400123123",
"email": "jess@example.com"
},
"audience": "any", // kids | adult | any — kids when a minor,
// else the attached plan's audience
"trialPlan": { // null until a trial plan is attached
"name": "Trial Week", "priceCents": 2000, "accessDays": 7
},
"readyToTrain": false, // waiver signed + emergency contact + medical
// answered — derived at read time, never stored
"attribution": { "channel": "facebook", "campaignRef": null }
}
],
"meta": { "asOf": "2026-08-20T09:30:00+10:00", "source": "live",
"since": "2026-08-19T09:00:00+10:00", "cap": 200,
"requestId": "req_…" }
}Single lead
/leads/:memberIdscope: readOne lead in the same LeadSummary shape as the list above. status uses the member-status vocab — a native member on an active trial reads trial, any other current member reads active. readyToTrain is derived at read time (waiver signed + emergency contact + medical answered) and never stored. Unknown id — or a member of another gym — is 404.
Comms preferences
/members/:memberId/comms-preferencesscope: readMarketing consent, reachable channels, and quiet hours — honestly minimal. Roll Call holds no consent or quiet-hours record today, so marketing.state is always unknown (with recordedAt null) and quietHours is null. channels.sms / channels.email report whether that contact point is on file — reachability, not consent. Treat consent as your own problem until this read says otherwise. Wrong gym is 404.
{
"data": {
"marketing": { "state": "unknown", "recordedAt": null },
"channels": { "sms": true, "email": false }, // contact point on file — reachability, NOT consent
"quietHours": null
},
"meta": { "asOf": "2026-08-20T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Lead contact timeline
/leads/:memberId/timelinescope: readThe contact history behind a double-contact guard: check this before reaching out so two systems never message the same person twice. kind is one of email_sent, sms_sent, note, booking, attendance, status_change (call_logged is defined but never served — no call log exists). email_sent / sms_sent cover automated sends only (journeys, booking confirmations) — an empty timeline means unknown, never "never contacted", and coveragestates exactly what is and isn't held. Wrong gym is 404.
{
"data": {
"entries": [ // newest first, capped at 100
{ "at": "2026-08-20T09:05:00+10:00", "kind": "sms_sent",
"summary": "Welcome SMS sent (trial journey)" },
{ "at": "2026-08-20T09:00:00+10:00", "kind": "status_change",
"summary": "Lead created via facebook" }
],
"coverage": {
"served": ["email_sent", "sms_sent", "note", "booking",
"attendance", "status_change"],
"not_held": ["call_logged"], // defined but never served — no call log exists
"notes": "email_sent / sms_sent cover automated sends only. An empty timeline means unknown, never \"never contacted\"."
}
},
"meta": { "asOf": "2026-08-20T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Gym-level reads
Timetable
/timetable?days=7scope: readUpcoming sessions with live seat counts (computed from bookings at request time, never a cached number). days is optional: 1–28, default 7.
{
"data": {
"sessions": [
{
"sessionId": "6f2a…",
"name": "No-Gi Fundamentals",
"room": "Mat 1",
"audience": "adult", // kids | adult | any
"startsAt": "2026-08-06T18:00:00+10:00",
"endsAt": "2026-08-06T19:00:00+10:00",
"coach": "Sam",
"capacity": 20, // null = unlimited
"booked": 14, // LIVE booking counts, never a cached mirror
"waitlisted": 0,
"seatsLeft": 6
}
]
},
"meta": { "asOf": "2026-08-05T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Single class session
/classes/:sessionIdscope: readOne session in the same shape as the timetable entries — use it to re-check seats just before proposing a booking.
Staff
/staffscope: readActive coaches, the class types they're assigned to, and their session load over the next 7 days. No pay or contact data. Availability is honestly unknown until Roll Call tracks it.
{
"data": {
"staff": [
{
"coachId": "…", "memberId": "…",
"name": "Sam", "role": "head_coach", // coach | head_coach
"assignedClassTypes": ["Gi", "No-Gi"],
"upcomingSessions": 6, // next 7 days
"availability": { "state": "unknown", "note": null }
}
]
},
"meta": { "asOf": "2026-08-05T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Funnel stats
/stats/funnel?days=30scope: readLead → trial → conversion counts over a window (days 1–365, default 30), plus cancellations and a member-count breakdown. Aggregates only — no member identities. leads.created counts trial leads and enquiries created in the window.
{
"data": {
"windowDays": 30,
"leads": { "created": 8 },
"trials": { "started": 8, "attendedAtLeastOnce": 1, "converted": 0,
"trialEnded": 8, "inProgress": 0 },
"trialConversionRate": 0.0,
"cancellations": { "count": 1, "byReason": [] },
"memberCounts": { "active": 191, "grace_period": 74, "overdue": 17 }
},
"meta": { "asOf": "2026-08-05T09:30:00+10:00", "source": "live", "requestId": "req_…" }
}Commands
One write surface. You request; Roll Call authorises against its own rules, applies through its own single-writer appliers, audits, and may reject. A rejection is a normal, expected outcome (200 with status: "rejected"and a machine-readable reason) — surface it to a human, don't retry it.
request_membership_pause
/commandsscope: commandPauses (holds) a membership until resumeAt, via the same hold path a staff member uses in the dashboard. The Idempotency-Key header is required: replaying the same key returns the original receipt and applies nothing twice; the same key with a different body is 409.
POST /v1/commands
Authorization: Bearer <token>
Idempotency-Key: pause:0b2f6d6e:2026-09-07
{
"command": {
"type": "request_membership_pause",
"payload": {
"memberId": "0b2f6d6e-…",
"startAt": "2026-08-31T00:00:00+10:00",
"resumeAt": "2026-09-07T00:00:00+10:00",
"reason": "Member-requested pause (1 week), approved in Operator."
}
},
"context": {
"correlationId": "wfr_01J4…",
"approvalRef": "apr_01J4…", // your approval id — audit context only
"requestedBy": "operator"
}
}{
"data": {
"commandId": "rcmd_01J4…",
"status": "applied", // accepted | applied | rejected
"reason": null, // machine-readable code when rejected
"detail": null,
"appliedAt": "2026-08-04T09:30:05+10:00"
},
"meta": { "asOf": "2026-08-04T09:30:05+10:00", "requestId": "req_…" }
}Check pause options first — the gym's own hold rules (length limits, a per-term day cap, billing rail) decide whether a pause is allowed, and the command re-validates every one of them. Rejection reasons you should expect and handle:
| reason | Meaning |
|---|---|
member_not_found | No such member in your gym |
policy_member_not_active | Member's status doesn't allow a pause (e.g. cancelled) |
no_active_membership | Member has no membership (or no plan) to hold |
already_on_hold | The membership is already paused |
invalid_resume_date | resumeAt is in the past or unreasonably far out |
rail_not_pausable | The membership is billed on a rail Roll Call can't pause (pause it at the source) |
hold_limit_exceeded | The plan's hold rules refuse this pause (cap / min / max days) — detail carries the message |
billing_pause_failed | Payment collection couldn't be paused, so nothing was changed |
internal_error | Something unexpected — nothing was changed; safe to retry with a new key after review |
Supported command types: request_membership_pause, request_member_note, request_trial_booking, request_cancellation_review (each documented below). Any other command.type returns 422 unsupported — new types appear here first.
request_member_note
/commandsscope: commandAppends a note to the member's timeline so gym staff see what your software did ("replied to pause request, approved by Sam"). It never edits member fields — the lowest-risk command, and the one to send after most automated actions. Payload: { memberId, note, kind: "operator_observation" }. Receipt detail carries timelineEventId.
request_trial_booking
/commandsscope: commandBooks a member into a class session through Roll Call's own booking engine — capacity locking, waitlist rules and audience gates all apply. Payload: { memberId, sessionId }. A full class comes back rejected / booking_full; a successful waitlist is applied with detail.bookingStatus: "waitlisted"; a repeat request for the same member+session is applied with detail.alreadyBooked. Other rejection codes: bookings_not_enabled, session_not_found, class_already_started, audience_mismatch, already_attended.
request_cancellation_review
/commandsscope: commandA member wants to cancel? This creates an open follow-up task for gym staff — your software never cancels anyone. Payload: { memberId, reason, evidenceSummary }. Receipt detail carries followUpId.
Errors
| Status | Meaning | Retry? |
|---|---|---|
| 401 | Missing, invalid, or revoked key | No |
| 403 | Valid key, wrong scope or wrong gym | No |
| 404 | Unknown member / resource | No |
| 409 | Idempotency-Key reused with a different body | No |
| 422 | Unsupported command type or invalid payload | No |
| 429 | Rate limited — honour Retry-After | After delay |
| 503 | API not configured / dependency down | Yes |
GETs are safe to retry. Commands are only safe to retry by re-sending the same Idempotency-Key.
Build board
Roll Call's own delivery roadmap, read-only. These require the roadmap scope, which is not issued on partner keys — this is our internal build plan rather than anything about your gym, so a partner key returns 403here by design. It is never part of a key's default scopes and has to be asked for explicitly. Documented because the reference never trails the deployed API.
Every response carries ranAt: these are served from a dated snapshot, not computed per request, because the underlying facts come from probes that read the repo, the production database and the door units. Treat a stale ranAt as stale data rather than assuming it is current.
Condition state is never a boolean. Each condition reports one of four states, and they do not mean the same thing: verified (a probe passed), attested (a person signed for it, with a date, and the signature expires), stale (that signature has expired — quote it as “as of” a date, never as done) and open (no claim at all). A per-condition rollup is provided as the worst state across its steps; the individual step states are always present alongside it and must not be collapsed away.
Every goal and its progress
/roadmap/goalsscope: roadmapEach goal with priority, conditions met/total, how many are startable now, whether it is parked (and the condition that brings it back), and conditionStates — a count per state, so a consumer reading only the headline still cannot mistake three attested conditions for three verified ones.
One goal's full chain
/roadmap/goals/:idscope: roadmapEvery condition under the goal with its dependency wave, size, what it is blocked by, and each probe step with its own state and detail. 404 when the goal id is unknown.
What is startable, best first
/roadmap/next?limit=8scope: roadmapOrdered by goal priority, then by how much each unlocks, then smallest first — the same ordering the terminal uses, so the two never disagree. Also returns any failing gate, and topGoalHasNothingStartable naming a goal to move instead when the top one is entirely blocked.
Changelog
| Date | Change |
|---|---|
| 2026-08-29 | ADDED: GET /roadmap/goals, /roadmap/goals/:id and /roadmap/next — Roll Call's own build board, behind a new `roadmap` scope that is not issued on partner keys. Served from a dated snapshot (every response carries ranAt) because the facts come from probes over the repo, the production database and the door units. Condition state is reported as verified/attested/stale/open and never as a boolean: a probe passing and a person signing something that later expires are different facts, and collapsing them is how a masked file became a retired one. |
| 2026-08-24 | REMOVED: GET /members/:id/membership/gymmaster — the temporary GymMaster-billed check added 2026-08-04, retired as promised now the GymMaster migration is complete. No membership is GymMaster-billed; the endpoint now returns 404. The same fact lives permanently on the membership read as billingRail (no value ever reads "gymmaster" again), and the pause command's rail_not_pausable rejection is unchanged for genuinely un-pausable rails. |
| 2026-08-21 | billingRail no longer falls back to "gymmaster" for a rail we don't recognise, and a membership with no rail recorded is no longer reported as GymMaster-billed (isGymMasterBilled false, billingRail "rollcall"). GymMaster was decoupled and bills nobody, so the old fallback asserted something that cannot be true — and made such a member read as un-pausable. If you branch on isGymMasterBilled, it now means what it says. |
| 2026-08-21 | status can now return "current" — the value a Roll-Call-native member has always carried in the database. It was previously reported as "pending" by mistake, so some live, training members appeared not to be set up yet. Treat "current" and "active" as the same thing: the member is in good standing. No other value changed meaning. |
| 2026-08-20 | Speed-to-lead reads for connected software: GET /leads/recent?since= (leads created or changed since a timestamp, LeadSummary shape, cap 200), GET /leads/:memberId (single LeadSummary), GET /leads/:memberId/timeline (contact history + honest coverage — the double-contact guard), and GET /members/:memberId/comms-preferences (consent honestly unknown; channels report reachability). |
| 2026-08-20 | meta.source: "live" now included on every read response — every projection is computed at request time, and the envelope now says so. |
| 2026-08-20 | Roll Call now pushes signed lead lifecycle events (lead.created@v1, lead.status_changed@v1, and trial.booked@v1 / trial.cancelled@v1 for lead bookings) to a configured consumer endpoint. Outbound events are configured per deployment, not per API key — contact us to point them at your software. |
| 2026-08-12 | Scheduled pauses: request_membership_pause accepts an optional payload.startAt (future ISO datetime) — today/absent pauses immediately as before; a future start books a scheduled hold that activates on the day. Both membership reads gain an additive scheduledHolds array (startsAt, resumesAt, days, source) so consumers can tell paused / pause scheduled / no pause apart. New reject reason: invalid_start_date. |
| 2026-08-05 | Big expansion: timetable, single class, staff, funnel stats, member attendance, payment state, and lead/trial status reads; plus three new commands — request_member_note, request_trial_booking, request_cancellation_review. |
| 2026-08-04 | New base URL: https://api.rollcallaccess.com/v1. The previous Railway address keeps working indefinitely — no action needed for existing integrations. |
| 2026-08-04 | Added GET /members/:id/membership/gymmaster — a TEMPORARY check for GymMaster-billed (unpausable) memberships; will be removed with changelog notice after the GymMaster migration completes. |
| 2026-08-04 | Base path moved to /v1 (was /operator/v1) and scopes simplified to read / command — before any consumer connected. Added GET /members/:id/membership/pause-options. |
| 2026-08-04 | v1 launch: member lookup by email, member summary, membership status, and the request_membership_pause command. |
Questions or need an endpoint that isn't here? admin@rollcallaccess.com.