This guide is for a federation that wants to show its associations, clubs, competitions and standings on its own website. It assumes you can already sign requests, see HMAC authentication. Every endpoint below links to the API reference.
Create the key for your federation organization, and enable the descendants option so it also sees the associations and clubs below it. Grant at least the scopes organizations:read, competitions:read and standings:read; add teams:read and players:read if the portal lists them. Check the key with GET /ping: resourceType is organization and resourceId is your federation id.
Without the descendants option the key sees only its own organization, and any request with includeDescendants=true answers 403 forbidden.
GET /{alias}/{id} returns the federation itself (use the federations alias). GET /{alias}/{id}/tree returns the hierarchy below it.
depth is 1 to 3, default 2. At the default, a federation returns its associations and their clubs.includeDescendants=true is 403 forbidden.truncated: true at the top level. Treat a truncated tree as a preview and switch to the paginated lists in the worked example.parentId and locationparentId is null when the parent is outside the key's scope. A key created on an association shows that association with parentId: null even though it has a federation above it. Do not assume null means "top of the whole organization".id, type, name, logo, parentId, description and season only, so a map view needs your own data.All five lists live under the organization you address and return the pagination envelope (default limit 25, max 100):
GET /{alias}/{id}/associationsGET /{alias}/{id}/clubsGET /{alias}/{id}/competitionsGET /{alias}/{id}/teamsGET /{alias}/{id}/playersincludeDescendantsWithout it, a list returns only the direct children of the organization you address. Send includeDescendants=true to include everything below the addressed organization (within your key's scope). It needs the key's descendants flag, otherwise the answer is 403 forbidden.
Filters are combined with AND. An id must be inside your key's scope and of the right type, otherwise the answer is the same 404 notFound as for an unknown id.
| Parameter | Clubs | Competitions | Teams | Players |
|---|---|---|---|---|
associationId | yes | yes | yes | yes |
clubId | – | yes | yes | yes |
competitionId | – | – | yes | yes |
teamId | – | yes | – | yes |
season | – | yes | yes | yes |
status | active | inactive | active | completed | active | inactive | active | inactive |
season covers the live season only; archived seasons are not searched on organization lists.
scopeTooLarge (422)With includeDescendants=true, a scope above 5000 organizations is refused with 422 scopeTooLarge:
{ "error": { "code": "scopeTooLarge", "message": "...", "details": { "max": 5000 } } }
Recovery:
associations first.associationId=<id> (clubs, competitions and teams all accept it).page and limit until hasMore is false.On GET /{alias}/{id}/players with includeDescendants=true and more than 50 organizations in scope, you must send one of clubId, associationId, competitionId or teamId. Otherwise:
{ "error": { "code": "scopeTooLarge", "message": "...", "details": { "max": 50, "requiresOneOf": ["clubId", "associationId", "competitionId", "teamId"] } } }
Read details.requiresOneOf rather than hard-coding the list, and loop over clubs or associations as above.
The program below does four things: reads the tree (and notes truncated), lists the associations, lists the clubs of each association with associationId, then lists the active competitions of each association and fetches their standings with GET /tournaments/{id}/standings. It uses the signing helpers from sign.js, sign.php and sign.py; place the file next to them in developers/snippets/ (they are imported, not copied). Set FOURLEAGUE_KEY_ID, FOURLEAGUE_SECRET and FOURLEAGUE_ORG_ID (your federation id).
developers/snippets/federation-portal.js'use strict';
// Federation portal walk-through, Node.js 18+. Reuses the signing helper from sign.js (same folder).
const crypto = require('crypto');
const { sign } = require('./sign');
const BASE = 'https://api.4league.app';
const FEDERATION_ID = process.env.FOURLEAGUE_ORG_ID; // the organization your key is scoped to
// One signed GET. `params` is sent exactly as it is signed.
async function get(path, params = {}) {
const query = new URLSearchParams(params).toString();
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(16).toString('hex'); // new nonce on every attempt
const { signature } = sign({ secret: process.env.FOURLEAGUE_SECRET, method: 'GET', path, query, timestamp, nonce });
const res = await fetch(BASE + path + (query ? '?' + query : ''), {
headers: {
'X-4League-Key': process.env.FOURLEAGUE_KEY_ID,
'X-4League-Timestamp': timestamp,
'X-4League-Nonce': nonce,
'X-4League-Signature': signature,
},
});
const text = await res.text();
let body;
try { body = JSON.parse(text); } catch (e) { throw new Error(`HTTP ${res.status}: non-JSON response`); }
if (!res.ok) throw Object.assign(new Error((body.error && body.error.code) || `HTTP ${res.status}`), { status: res.status, error: body.error });
return body;
}
// Walk every page of a list endpoint.
async function* pages(path, params = {}) {
for (let page = 1; ; page++) {
const body = await get(path, { ...params, page, limit: 100 });
yield* body.data;
if (!body.hasMore) return;
}
}
(async () => {
// 1. The tree: federation, associations, clubs (depth 2 is the default).
const tree = await get(`/v1/federations/${FEDERATION_ID}/tree`, { depth: 2 });
if (tree.truncated) console.warn('Tree was cut at the node cap, use the list endpoints below.');
// 2. Associations under the federation.
for await (const assoc of pages(`/v1/federations/${FEDERATION_ID}/associations`, { includeDescendants: 'true' })) {
console.log('Association', assoc.name);
// 3. Clubs of that association. Narrowing by associationId keeps the scope small.
for await (const club of pages(`/v1/federations/${FEDERATION_ID}/clubs`, { includeDescendants: 'true', associationId: assoc.id })) {
console.log(' Club', club.name);
}
// 4. Competitions of that association, then standings of each.
for await (const comp of pages(`/v1/federations/${FEDERATION_ID}/competitions`, { includeDescendants: 'true', associationId: assoc.id, status: 'active' })) {
const { data } = await get(`/v1/tournaments/${comp.id}/standings`);
console.log(' Competition', comp.name, data.standings.length, 'rows');
}
}
})().catch((e) => {
if (e.error && e.error.code === 'scopeTooLarge') console.error('Narrow the request:', e.error.details);
else console.error(e);
process.exit(1);
});
developers/snippets/federation-portal.php<?php
// Federation portal walk-through, PHP 7.4+. Reuses the signing helper from sign.php (same folder).
require __DIR__ . '/sign.php';
const FL_BASE = 'https://api.4league.app';
// One signed GET. $params is sent exactly as it is signed.
function fl_get(string $path, array $params = []): array
{
$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16)); // new nonce on every attempt
$sig = fl_sign(getenv('FOURLEAGUE_SECRET'), 'GET', $path, $query, $timestamp, $nonce)['signature'];
$ch = curl_init(FL_BASE . $path . ($query !== '' ? '?' . $query : ''));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-4League-Key: ' . getenv('FOURLEAGUE_KEY_ID'),
'X-4League-Timestamp: ' . $timestamp,
'X-4League-Nonce: ' . $nonce,
'X-4League-Signature: ' . $sig,
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = $raw === false ? null : json_decode($raw, true);
if (!is_array($body) || $status >= 400) {
throw new RuntimeException("HTTP $status: " . ($raw === false ? curl_error($ch) : json_encode($body['error'] ?? $raw)));
}
return $body;
}
// Walk every page of a list endpoint.
function fl_pages(string $path, array $params = []): Generator
{
for ($page = 1;; $page++) {
$body = fl_get($path, $params + ['page' => $page, 'limit' => 100]);
yield from $body['data'];
if (!$body['hasMore']) {
return;
}
}
}
$fed = '/v1/federations/' . getenv('FOURLEAGUE_ORG_ID'); // the organization your key is scoped to
// 1. The tree: federation, associations, clubs (depth 2 is the default).
$tree = fl_get($fed . '/tree', ['depth' => 2]);
if (!empty($tree['truncated'])) {
echo "Tree was cut at the node cap, use the list endpoints below.\n";
}
// 2. Associations under the federation.
foreach (fl_pages($fed . '/associations', ['includeDescendants' => 'true']) as $assoc) {
echo "Association {$assoc['name']}\n";
// 3. Clubs of that association. Narrowing by associationId keeps the scope small.
foreach (fl_pages($fed . '/clubs', ['includeDescendants' => 'true', 'associationId' => $assoc['id']]) as $club) {
echo " Club {$club['name']}\n";
}
// 4. Competitions of that association, then standings of each.
foreach (fl_pages($fed . '/competitions', ['includeDescendants' => 'true', 'associationId' => $assoc['id'], 'status' => 'active']) as $comp) {
$rows = fl_get('/v1/tournaments/' . $comp['id'] . '/standings')['data']['standings'];
echo " Competition {$comp['name']} " . count($rows) . " rows\n";
}
}
developers/snippets/federation-portal.py"""Federation portal walk-through, Python 3. Reuses the signing helper from sign.py (same folder)."""
import json
import os
import secrets
import time
import urllib.error
import urllib.request
from urllib.parse import urlencode
from sign import sign
BASE = 'https://api.4league.app'
FEDERATION_ID = os.environ['FOURLEAGUE_ORG_ID'] # the organization your key is scoped to
def get(path, params=None):
"""One signed GET. `params` is sent exactly as it is signed."""
query = urlencode(params or {})
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16) # new nonce on every attempt
_, signature = sign(os.environ['FOURLEAGUE_SECRET'], 'GET', path, query, timestamp, nonce)
req = urllib.request.Request(BASE + path + ('?' + query if query else ''), headers={
'X-4League-Key': os.environ['FOURLEAGUE_KEY_ID'],
'X-4League-Timestamp': timestamp,
'X-4League-Nonce': nonce,
'X-4League-Signature': signature,
})
try:
with urllib.request.urlopen(req) as r:
return json.load(r)
except urllib.error.HTTPError as e:
try:
raise RuntimeError(json.load(e)['error']) from None
except (ValueError, KeyError):
raise RuntimeError('HTTP %d: unexpected error body' % e.code) from None
def pages(path, params=None):
"""Walk every page of a list endpoint."""
page = 1
while True:
body = get(path, {**(params or {}), 'page': page, 'limit': 100})
yield from body['data']
if not body['hasMore']:
return
page += 1
fed = '/v1/federations/' + FEDERATION_ID
# 1. The tree: federation, associations, clubs (depth 2 is the default).
tree = get(fed + '/tree', {'depth': 2})
if tree.get('truncated'):
print('Tree was cut at the node cap, use the list endpoints below.')
# 2. Associations under the federation.
for assoc in pages(fed + '/associations', {'includeDescendants': 'true'}):
print('Association', assoc['name'])
# 3. Clubs of that association. Narrowing by associationId keeps the scope small.
for club in pages(fed + '/clubs', {'includeDescendants': 'true', 'associationId': assoc['id']}):
print(' Club', club['name'])
# 4. Competitions of that association, then standings of each.
for comp in pages(fed + '/competitions', {'includeDescendants': 'true', 'associationId': assoc['id'], 'status': 'active'}):
standings = get('/v1/tournaments/%s/standings' % comp['id'])['data']['standings']
print(' Competition', comp['name'], len(standings), 'rows')
Each request stays inside the rate limit and monthly quota described in the API reference; a loop over many associations makes many requests, so cache results on your side.