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.

EndpointDoes
GET /api/actionsLists 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
      }
    }
  ]
}
  • scope is read, write or test: whether this key can make changes right now, or only simulate them (test keys).
  • risk is one of read, write, destructive, notifies, money or minors. Risk.
  • input is the JSON Schema of the action’s input. For a change it also has confirm_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 409 with 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

riskMeans
readChanges nothing and contacts no one. Runs at once, on any key.
writeChanges club data in the app, easy to undo (a group, a check-in, a setting).
destructiveDeletes or cancels something that is hard to put back.
notifiesEmails or messages people, which can’t be unsent.
moneyCharges, refunds, or changes what someone pays.
minorsTouches 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." }
StatusWhen
400The input doesn’t fit the schema (the message names the field), or the action can’t do it as asked.
401No 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.
403The 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.
404An unknown action, or an id that isn’t in this club.
409A confirm token that is used, expired, or for another call.
429Over the rate limit. Wait for Retry-After seconds (60).
500Something 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" } } }
  • match takes { "all": true }, or any of query, group and membership (active, inactive, none), the same filters as members_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_book takes member_ids (up to 100) and booking_cancel takes booking_ids (up to 100), with one outcome each.
  • members_add takes people, a list of up to 100 with a name and, if you have them, email and phone. Someone with the same name and email as a member is left as they are, and the result gives each new member’s id.

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.
  • from and to take a date (2026-11-01, where to includes that whole day) or an ISO time, as each action says.
  • A class session is named by its class id and its start exactly as classes_list or class_schedule gives 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.