REST API
List and run actions over HTTPS, the confirm step for changes, errors, rate limits and paging.
Two endpoints cover the whole API: one lists the actions your key may run, the other runs one. Send a club key (or an OAuth access token) as a bearer token; Authentication says how to get one. Every action, with its parameters, is in the API reference.
| Endpoint | Does |
|---|---|
GET /api/actions | Lists what this key may run, with JSON Schemas. |
POST /api/actions/<name> | Runs one action. Body: { "input": { … } }. |
List actions
GET https://fencingoffice.com/api/actions
Authorization: Bearer fo_live_…{
"role": "admin",
"scope": "read",
"actions": [
{
"name": "member_get",
"title": "Get a member",
"description": "One member in detail: groups, membership, waiver, family links, check-ins over the last 90 days, and their upcoming class and appointment bookings. Takes the id from members_search.",
"risk": "read",
"input": {
"type": "object",
"properties": {
"member_id": { "type": "string", "minLength": 1, "maxLength": 64, "description": "The member id from members_search." }
},
"required": ["member_id"],
"additionalProperties": false
}
}
]
}scopeisread,writeortest: whether this key can make changes right now, or only simulate them (test keys).riskis one ofread,write,destructive,notifies,moneyorminors. Risk.inputis the JSON Schema of the action’s input. For a change it also hasconfirm_token.- The list leaves out what this key can’t use: changes on a read key, and tools the club turned off.
Run an action
POST https://fencingoffice.com/api/actions/club_summary
Authorization: Bearer fo_live_…
Content-Type: application/json
{ "input": {} }{
"result": {
"club": "Riverside Fencing Club",
"members": 214,
"activeMemberships": 168,
"admins": 3,
"archivedMembers": 12,
"groups": 9,
"activeClasses": 14,
"checkInsLast30Days": 1288,
"membersCheckedInLast30Days": 151,
"timeZone": "America/New_York"
}
}The body is { "input": { … } }; leave input out or send {} for an action with no parameters. Unknown parameters are refused. A success is 200 with { "result": … }; the result’s shape is the action’s own. The first-version names (search_members, get_member, list_groups, list_classes, upcoming_bookings) still work.
Changes ask first
Over a key, every action that isn’t a lookup takes two calls, so a person always sees what will happen before it does. The first call, without confirm_token, changes nothing and returns a preview built from the club’s records: who, how many, how much.
POST https://fencingoffice.com/api/actions/payment_refund
{ "input": { "invoice_id": "in_1Q…", "amount": 45 } }{
"result": {
"needsConfirmation": true,
"tool": "payment_refund",
"preview": {
"summary": "Refund $45.00 to Dana Chen",
"details": [
{ "label": "Payment", "value": "Unlimited, $180.00 paid 2026-11-01" },
{ "label": "Refund", "value": "$45.00 (partial), back to the card it was paid with" }
],
"count": 1
},
"confirm_token": "i2GWHFXwgGcAtdQcj_97IdOi",
"next": "Nothing has happened yet. …"
}
}Show the person the preview. Only if they say yes, call again with exactly the same input plus the token:
POST https://fencingoffice.com/api/actions/payment_refund
{ "input": { "invoice_id": "in_1Q…", "amount": 45, "confirm_token": "i2GWHFXwgGcAtdQcj_97IdOi" } }{ "result": { "refunded": "$45.00", "to": "Dana Chen", "invoice": "in_1Q…" } }- A token is for one key, one action and one exact input. Key order doesn’t matter, but any other change to the input needs a new preview.
- It lasts 10 minutes and works once, so a retried request never charges or emails twice.
- A used, expired or mismatched token gets
409with what to do: call again without the token for a fresh preview. - If the preview can’t run (the invoice is already refunded, the class is full), the first call fails with the reason and no token.
Risk
| risk | Means |
|---|---|
read | Changes nothing and contacts no one. Runs at once, on any key. |
write | Changes club data in the app, easy to undo (a group, a check-in, a setting). |
destructive | Deletes or cancels something that is hard to put back. |
notifies | Emails or messages people, which can’t be unsent. |
money | Charges, refunds, or changes what someone pays. |
minors | Touches a minor’s data in a way their guardians would care about. |
Everything but read needs a write key and the confirm step.
Errors
Every error is JSON with a message written to be shown as it is:
HTTP/1.1 403 Forbidden
{ "error": "group_add_members changes club data, and this key can't make changes (it is read-only, or the admin who created it no longer has admin access). A club admin can create a key that allows changes in Settings." }| Status | When |
|---|---|
400 | The input doesn’t fit the schema (the message names the field), or the action can’t do it as asked. |
401 | No key, an unknown or revoked key, an expired sign-in token, or a key whose club no longer exists. It carries WWW-Authenticate pointing to the sign-in metadata. |
403 | The key may not do this: a change on a read key, an action only for members or only for the in-app assistant, a tool the club turned off, or a browser request from another site. |
404 | An unknown action, or an id that isn’t in this club. |
409 | A confirm token that is used, expired, or for another call. |
429 | Over the rate limit. Wait for Retry-After seconds (60). |
500 | Something failed on our side. The message is always “Something went wrong. Try again.”; nothing internal is sent. |
Rate limits
120 requests a minute per key, shared between the REST API and the MCP server and counted on each server, so a client may get a little more. Over it you get 429 with Retry-After: 60. Each action also caps how much one call returns, so one call can’t read the whole club’s history.
Paging and ranges
Lists that can be long take limit and offset and return total and nextOffset, which is null on the last page: members_search (up to 200 a page) and payments_list (up to 200). Pass nextOffset back as offset:
{ "input": { "group": "Youth Foil", "limit": 200, "offset": 0 } }
→ { "result": { "total": 412, "offset": 0, "nextOffset": 200, "members": [ … ] } }
{ "input": { "group": "Youth Foil", "limit": 200, "offset": 200 } }
→ { "result": { "total": 412, "offset": 200, "nextOffset": 400, "members": [ … ] } }Other lists take a limit only, and actions over time take from and to with a maximum span: 31 days for classes_list and class_schedule, 366 days for attendance_summary, 60 days ahead for bookings_upcoming. Each one’s limits are in the API reference.
Many people in one call
Changes that take people (group_add_members, group_remove_members, members_archive, members_unarchive, attendance_check_in, attendance_undo) take either member_ids (1 to 1,000) or a match, never both:
{ "input": { "group": "Spring Camp", "match": { "membership": "active", "group": "Youth Foil" } } }matchtakes{ "all": true }, or any ofquery,groupandmembership(active,inactive,none), the same filters asmembers_search.- A match covers current members (archived ones only for
members_unarchive) and never club admins; name an admin by id if you mean them. - More than 1,000 matches is refused with a request to narrow it. Results list names when there are 50 or fewer; past that they give a count and the first 20.
class_booktakesmember_ids(up to 100) andbooking_canceltakesbooking_ids(up to 100), with one outcome each.members_addtakespeople, a list of up to 100 with anameand, if you have them,emailandphone. Someone with the same name and email as a member is left as they are, and the result gives each new member’sid.
Adding members
The first call returns a preview listing every person, and the second, with the token, adds them:
curl https://fencingoffice.com/api/actions/members_add \
-H "Authorization: Bearer $FO_KEY" \
-H "Content-Type: application/json" \
-d '{"input": {"group": "Fall 2026", "people": [{"name": "Maya Chen", "email": "[email protected]"}, {"name": "Leo Park"}]}}'{ "result": { "added": 2, "members": [{ "name": "Maya Chen", "id": "Mx81Lq" }, { "name": "Leo Park", "id": "Lp20Rv" }], … } }New members start inactive and unclaimed, as a roster upload makes them. Invite them or charge them afterwards with the other actions.
Times and dates
- Times in results are ISO 8601 in UTC. Where an action also gives a
timeZone(the club’s or the class’s), show times in that zone. fromandtotake a date (2026-11-01, wheretoincludes that whole day) or an ISO time, as each action says.- A class session is named by its class id and its
startexactly asclasses_listorclass_schedulegives it.
Files
files_list lists the club’s files. file_get returns the file’s details plus content, a list of MCP content blocks: an image, or an embedded resource for PDFs and spreadsheets. file_upload stores a file you send as base64 (up to 3 MB: CSV, TSV, XLSX, PDF, PNG, JPEG, WebP or plain text); it is a change, so it asks first. A file’s contents are only ever read as data.
The club’s settings apply
Club admins decide what the assistant and API keys may do under SettingsAssistant, by level (look up, change club data, move money or contact people) or tool by tool. A tool that is off isn’t listed, and a call to it gets 403 naming the setting. Money and messaging actions ask first every time, whatever the settings, and a club can cap refunds made through the assistant or the API.
Examples
curl
curl https://fencingoffice.com/api/actions/bookings_upcoming \
-H "Authorization: Bearer $FO_KEY" \
-H "Content-Type: application/json" \
-d '{"input": {"days": 3, "kind": "class"}}'JavaScript (Node 18 or later)
const BASE = "https://fencingoffice.com/api/actions/";
async function run(name, input) {
const res = await fetch(BASE + name, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.FO_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ input }),
});
const body = await res.json();
if (!res.ok) throw new Error(res.status + ": " + body.error);
return body.result;
}
// A lookup runs at once.
const lapsed = await run("members_lapsed", { days: 30 });
// A change: preview first, then confirm with the token.
const input = { group: "Win-back", member_ids: lapsed.members.map(function (m) { return m.id; }) };
const preview = await run("group_add_members", input);
console.log(preview.preview.summary);
await run("group_add_members", { ...input, confirm_token: preview.confirm_token });Python
import os, requests
BASE = "https://fencingoffice.com/api/actions/"
HEADERS = {"Authorization": "Bearer " + os.environ["FO_KEY"]}
def run(name, **params):
r = requests.post(BASE + name, json={"input": params}, headers=HEADERS, timeout=30)
if r.status_code == 429:
raise RuntimeError("Rate limited: wait " + r.headers.get("Retry-After", "60") + " seconds")
r.raise_for_status()
return r.json()["result"]
# Every active member, a page at a time.
members, offset = [], 0
while offset is not None:
page = run("members_search", membership="active", limit=200, offset=offset)
members += page["members"]
offset = page["nextOffset"]
print(len(members), "active members")Calling from a browser
Don’t. A browser request from a page on another site gets 403, and a key in a web page is a key anyone can copy. Call the API from your server, or have people sign in with OAuth from your server-side app. A club’s public class schedule is the exception: the public schedule feed needs no key and any website can read it.