Federation portal guide

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.

1. Get an organization-scoped key

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.

2. Read the federation and its tree

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.
  • When the key has the descendants flag the tree includes the levels below the addressed organization. With a key without the flag, includeDescendants=true is 403 forbidden.
  • The tree is capped at 500 nodes. A larger tree is cut and the response carries truncated: true at the top level. Treat a truncated tree as a preview and switch to the paginated lists in the worked example.
  • The tree follows the primary parent only. An organization reachable only through an additional parent can be fetched directly but is not in the tree.

parentId and location

  • parentId 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".
  • Organization location is not exposed by the API. Organization objects carry id, type, name, logo, parentId, description and season only, so a map view needs your own data.

3. List associations, clubs, competitions, teams and players

All five lists live under the organization you address and return the pagination envelope (default limit 25, max 100):

includeDescendants

Without 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.

Narrowing

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.

ParameterClubsCompetitionsTeamsPlayers
associationIdyesyesyesyes
clubId–yesyesyes
competitionId––yesyes
teamId–yes–yes
season–yesyesyes
statusactive | inactiveactive | completedactive | inactiveactive | inactive

season covers the live season only; archived seasons are not searched on organization lists.

4. Handle scopeTooLarge (422)

With includeDescendants=true, a scope above 5000 organizations is refused with 422 scopeTooLarge:

{ "error": { "code": "scopeTooLarge", "message": "...", "details": { "max": 5000 } } }

Recovery:

  1. Stop requesting the whole federation at once. List the associations first.
  2. Repeat the request once per association with associationId=<id> (clubs, competitions and teams all accept it).
  3. Page through each result with page and limit until hasMore is false.
  4. Do not retry the identical request, it fails the same way.

The players-list rule

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.

5. Worked example

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).

Node.js 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);
});

PHP 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";
    }
}

Python 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.