Version 1 · for clubs on a paid plan
Put your club's standings on your own website. The API returns read-only JSON for your whole club: every team's standing, every shooter's totals, league standings and your seasons. It's stats only: never emails, phone numbers or other contact details.
A Club Admin turns the API on under Club › Branding & API and copies the token (it starts with tsk_). Anyone with the token can read your club's stats, so treat it like a password. Replace token makes a new one and stops the old one at once; Turn off stops the API. If the club leaves the paid plans, requests get a 403 until it's back on one.
Send the token in the Authorization header. Only GET is supported. Browsers on any website can call it (CORS is open for these endpoints, without cookies), so a leaderboard widget can fetch straight from your site's JavaScript.
fetch("https://ui.trapstats.app/api/v1/org/teams?gameType=singles", {
headers: { Authorization: "Bearer tsk_your_token_here" },
})
.then((r) => r.json())
.then((data) => console.log(data.teams));
curl -H "Authorization: Bearer tsk_your_token_here" https://ui.trapstats.app/api/v1/org/shooters
Heads-up: a token in a web page's JavaScript is visible to anyone who views the source. That's fine for data you're happy to show publicly (it's what the API returns), but if you'd rather keep the token private, call the API from your server and cache the result.
120 requests a minute per token. Responses may be cached for a minute (Cache-Control: max-age=60). Standings change when scores are saved, so polling more often than every minute or so gains nothing.
{
"apiVersion": 1,
"generatedAt": "2026-09-28T19:30:00.000Z",
"organization": { "id": 12, "name": "Riverbend Trap Club" },
...the endpoint's own fields
}
Common query parameters: gameType = singles (default) or doubles (they're never mixed); seasonId = one of your seasons from /org/seasons, to limit to its dates (all time if left out).
The API follows the same rules as your public leaderboard links. A shooter linked to a Trap Stats account that hasn't opted in to showing their name publicly (which includes every account under 18) appears as "Private Member"; a roster entry with no account shows the name the team entered, as on your public pages. A team whose Squad Leader hid it from public scoreboards is left out entirely. Personal practice rounds aren't included, only club team rounds.
Who the token belongs to. Just the common fields above.
{ "seasons": [ { "id": 7, "name": "Fall 2026", "startDate": "2026-09-01", "endDate": "2026-11-30" } ] }
Every team in the club, best total first. Params: seasonId, gameType.
{
"seasonId": null, "gameType": "singles",
"teams": [
{ "rank": 1, "id": 3, "name": "Saturday Squad",
"league": { "id": 1, "name": "Thursday Night League" }, "division": "A",
"totalPoints": 667, "rounds": 13, "average": 23.8, "hasPerfectRound": true }
]
}
Every shooter on the club's teams, best total first. Params: seasonId, gameType.
{
"seasonId": null, "gameType": "singles",
"shooters": [
{ "rank": 1, "id": 41, "name": "Mike Ross", "team": { "id": 3, "name": "Saturday Squad" },
"totalPoints": 236, "rounds": 10, "average": 23.6, "best": 25 }
]
}
{ "leagues": [ { "id": 1, "name": "Thursday Night League", "location": "Riverbend Gun Club", "gameType": "singles" } ] }
One league's team and individual standings. Ties are broken by finished standings shoot-offs, the same way the app does it, so rank can differ from simple total order; shootoffPlace is set for anyone whose tie a shoot-off settled. Params: seasonId, gameType.
{
"league": { "id": 1, "name": "Thursday Night League" }, "seasonId": null, "gameType": "singles",
"teams": [ { "rank": 1, "id": 3, "name": "Saturday Squad", "division": "A", "divisionRank": 1,
"totalPoints": 667, "rounds": 13, "average": 23.8, "shootoffPlace": null } ],
"shooters": [ { "rank": 1, "id": 41, "name": "Mike Ross", "team": { "id": 3, "name": "Saturday Squad" },
"totalPoints": 236, "rounds": 10, "shootoffPlace": null } ]
}
| Status | Meaning |
|---|---|
400 | A bad parameter, e.g. a seasonId that isn't one of your club's. |
401 | No token, or a token that was replaced or turned off. |
403 | The club isn't on a paid plan. |
404 | Unknown endpoint, or a league that isn't your club's. |
429 | More than 120 requests in a minute. |
Every error body is { "error": "…" } with a human-readable message.
Version 1 only ever gains fields: nothing is renamed or removed, so code that reads today's fields keeps working. Ignore fields you don't recognize. A breaking change would come as /api/v2, with /api/v1 kept running alongside it.
Questions? [email protected]