Every request to the 4league /v1 API is signed with HMAC-SHA256 using your key's secret. This page is the complete contract: headers, the canonical string, test vectors and common failures.
Server-to-server only. /v1 sends no CORS headers, so browsers cannot call it. Your secret must never ship in a web page or mobile app; sign on your own backend.
Send all four on every request. Each header must be a single value (no commas, no repeats) of at most 200 characters.
| Header | Format | Notes |
|---|---|---|
X-4League-Key | [A-Za-z0-9_-]{1,100} | Your key id. |
X-4League-Timestamp | Unix time in seconds | Accepted when within ±300 seconds of server time (inclusive, both directions). |
X-4League-Nonce | [A-Za-z0-9_.~-]{1,128} | Single use per key. Generate a new nonce on every attempt, including retries after a 403 or 429. |
X-4League-Signature | 64 hex characters | Lowercase hex HMAC-SHA256 of the canonical string, keyed with your secret. |
The API only accepts requests without a body (GET and HEAD). The body hash is still part of the canonical string.
Six fields joined by a single line feed (\n, no trailing newline):
METHOD\nPATH\nSORTED_ENCODED_QUERY\nTIMESTAMP\nNONCE\nSHA256hex(body)
The signature is hex(HMAC_SHA256(secret, canonical)), with the canonical string encoded as UTF-8.
/v1, without the query. It is never decoded. A trailing slash counts (/v1/matches/ and /v1/matches sign differently). The only change is that the hex digits of existing percent-escapes are upper-cased (%2f becomes %2F).?:
& and drop empty parts.=. A bare key (a) becomes a=.A-Za-z0-9-._~ stay unencoded. ! ' ( ) * and + are encoded. A + is a literal plus, not a space. Hex digits are upper-case.B sorts before a). Duplicate keys are kept.key=value with &. No query gives an empty line.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.The server answers 400 (not 401) for malformed percent-encoding (a % not followed by two hex digits) and for any URL longer than 2048 bytes.
The table below is generated from hmac-vectors.json (raw JSON), the same file the backend tests run against. Check that your implementation reproduces both the canonical string and the signature for every row.
All vectors work offline only. Their timestamp (1760000000) is long outside the ±300 s window, so the live API rejects them. Use them to compare strings, not to call the API.
| # | Case | Method | Path and query as sent | Nonce | Body / SHA-256 | Canonical string | Signature | |
|---|---|---|---|---|---|---|---|---|
| 1 | empty query Signing |
GET |
/v1/matches |
nonce-001 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/matches\n \n 1760000000\n nonce-001\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
903040be05e734c2b295e25ea802bdfbc6e1cce4406a3afe0d361ee7ff45e7d6 |
|
| 2 | repeated keys Signing |
GET |
/v1/matches?a=2&a=1 |
nonce-002 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/matches\n a=1&a=2\n 1760000000\n nonce-002\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
76274b346898d08032f7608b950a5ebc01ac9d4b34e2b52edf59f823e0e12861 |
|
| 3 | bare key Signing |
GET |
/v1/matches?a |
nonce-003 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/matches\n a=\n 1760000000\n nonce-003\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
4ed3288f2dda1c8d1eaf39729643c65f198e4a27629409fafa0a8fec014d47e8 |
|
| 4 | space as %20 Signing |
GET |
/v1/teams?q=a%20b |
nonce-004 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams\n q=a%20b\n 1760000000\n nonce-004\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
6b3730554fa4ef46117bb60187dd3d9ef65cf7c85ddd645593f21fd3b00928b0 |
|
| 5 | plus is literal plus Signing |
GET |
/v1/teams?q=a+b |
nonce-005 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams\n q=a%2Bb\n 1760000000\n nonce-005\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
48e8997268263e2612419ca151e60adfe5b20b9212c588ff8af539c5c41703ca |
|
| 6 | space vs plus vs %20 mixed ordering Signing |
GET |
/v1/teams?q=a%20b&q=a+b&q=a%20b |
nonce-006 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams\n q=a%20b&q=a%20b&q=a%2Bb\n 1760000000\n nonce-006\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
230e08300afa936ad9dd6f0539392c56a4a81516dcbe309f908384e3cb4bf5cd |
|
| 7 | reserved !*'() Signing |
GET |
/v1/teams?q=!*'() |
nonce-007 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams\n q=%21%2A%27%28%29\n 1760000000\n nonce-007\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
690554aa74fb6c8a42d76769e20829965fbe6c26ed0ef8ac597b3acf5d0cb331 |
|
| 8 | unreserved ~-._ Signing |
GET |
/v1/teams?q=~-._ |
nonce-008 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams\n q=~-._\n 1760000000\n nonce-008\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
67f11cab7f8384659aeadd21184230da224c0dee2ba50077153e76db644cca1a |
|
| 9 | utf-8 multibyte Signing |
GET |
/v1/teams?q=%C8%99coal%C4%83&n=caf%C3%A9 |
nonce-009 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams\n n=caf%C3%A9&q=%C8%99coal%C4%83\n 1760000000\n nonce-009\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
9d9706b14c6582e24b6b34da13768dd43ef1c033a2adb3081d48ef7a5b1b3ffb |
|
| 10 | already-encoded %2F in value and lowercase hex Signing |
GET |
/v1/teams?path=a%2fb |
nonce-010 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams\n path=a%2Fb\n 1760000000\n nonce-010\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
380fd52b283d6c12fa4f20989b807093de6edd6629e2ade9882a2777f3b86926 |
|
| 11 | encoded %2f in path (hex uppercased) Signing |
GET |
/v1/teams/a%2fb |
nonce-011 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/teams/a%2Fb\n \n 1760000000\n nonce-011\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
668f5b9470ad297b4c4980c9c047f062b0f148348ec17a89739bade79732703c |
|
| 12 | trailing slash Signing |
GET |
/v1/matches/?a=1 |
nonce-012 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/matches/\n a=1\n 1760000000\n nonce-012\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
137fd995b0670929107f66b9c320f9f4a3167abed0df10ebb4921388befb353c |
|
| 13 | HEAD method Signing |
HEAD |
/v1/matches?limit=5 |
nonce-013 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
HEAD\n /v1/matches\n limit=5\n 1760000000\n nonce-013\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
2064573c401f55f5b1bc8afbc021ce2d0464b7ddb26f315e84982abd7227e263 |
|
| 14 | empty body hash (explicit) Signing |
GET |
/v1/players?page=2&limit=10 |
nonce-014 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/players\n limit=10&page=2\n 1760000000\n nonce-014\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
9854a8d069df657609e4081eebf857c4ea8b6d470929e00a2d9b15290a19bc2e |
|
| 15 | sort by encoded key then value Signing |
GET |
/v1/matches?b=2&a=3&a=1&B=0&a=%7E |
nonce-015 |
emptye3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
GET\n /v1/matches\n B=0&a=1&a=3&a=~&b=2\n 1760000000\n nonce-015\n e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
c6712f89abf14c4f38e66145a7701637819ba023de8fc3475583e077b6a1c0ee |
|
| 16 | POST with JSON body Canonicalization only |
POST |
/v1/matches |
nonce-016 |
{"name":"Cup","teams":[1,2]}82574f474fed0a660c9bb9231dd8cedeeb3a821366a740254a741b2e8bce0a40 |
POST\n /v1/matches\n \n 1760000000\n nonce-016\n 82574f474fed0a660c9bb9231dd8cedeeb3a821366a740254a741b2e8bce0a40 |
3ee3f45e38b3470eb09e9a039f3034b139b1514510ae575578594385a88b38c9 |
|
| 17 | PUT with multibyte UTF-8 body Canonicalization only |
PUT |
/v1/teams?id=7 |
nonce-017 |
{"name":"Fotbal Clubul Școala Ștefan ⚽ café"}814f3910f94697d5efbc75c84b6c0deed320fbdb0c9152d26a64dc910f4c0c63 |
PUT\n /v1/teams\n id=7\n 1760000000\n nonce-017\n 814f3910f94697d5efbc75c84b6c0deed320fbdb0c9152d26a64dc910f4c0c63 |
637aafea5bd33f4d50ff80f3ec72939d3f86822d0a4b8a92999f9354b7734c7b |
Each tab signs GET https://api.4league.app/v1/ping with a full canonical-query helper. Set FOURLEAGUE_KEY_ID and FOURLEAGUE_SECRET and run the file. The same files are tested against every vector above.
developers/snippets/sign.js'use strict';
// 4league /v1 request signing, Node.js (built-in crypto only).
const crypto = require('crypto');
// RFC 3986: only A-Z a-z 0-9 - . _ ~ stay literal. encodeURIComponent leaves !'()* alone, so fix those.
const encode = (s) =>
encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
// Percent-decode to UTF-8. '+' is a literal plus, never a space.
const decode = (s) => decodeURIComponent(s);
function canonicalQuery(rawQuery) {
return rawQuery
.split('&')
.filter(Boolean)
.map((part) => {
const i = part.indexOf('=');
const k = i < 0 ? part : part.slice(0, i);
const v = i < 0 ? '' : part.slice(i + 1); // bare key "a" becomes "a="
return [encode(decode(k)), encode(decode(v))];
})
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join('&');
}
// The path is never decoded; only the hex digits of existing escapes are upper-cased.
const canonicalPath = (p) => p.replace(/%[0-9a-fA-F]{2}/g, (m) => m.toUpperCase());
function sign({ secret, method, path, query = '', timestamp, nonce, body = '' }) {
const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
const canonical = [method.toUpperCase(), canonicalPath(path), canonicalQuery(query), timestamp, nonce, bodyHash].join('\n');
const signature = crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
return { canonical, signature };
}
module.exports = { sign, canonicalQuery };
if (require.main === module) {
const timestamp = String(Math.floor(Date.now() / 1000)); // seconds, not milliseconds
const nonce = crypto.randomBytes(16).toString('hex'); // new nonce on every attempt
const { signature } = sign({ secret: process.env.FOURLEAGUE_SECRET, method: 'GET', path: '/v1/ping', timestamp, nonce });
fetch('https://api.4league.app/v1/ping', {
headers: {
'X-4League-Key': process.env.FOURLEAGUE_KEY_ID,
'X-4League-Timestamp': timestamp,
'X-4League-Nonce': nonce,
'X-4League-Signature': signature,
},
}).then(async (r) => console.log(r.status, await r.text()));
}
developers/snippets/sign.php<?php
// 4league /v1 request signing, PHP 7.4+ (hash_hmac + rawurlencode).
// rawurlencode is RFC 3986: only A-Z a-z 0-9 - . _ ~ stay literal, '+' becomes %2B.
// rawurldecode never turns '+' into a space.
function fl_canonical_query(string $rawQuery): string
{
$pairs = [];
foreach (explode('&', $rawQuery) as $part) {
if ($part === '') {
continue;
}
$kv = explode('=', $part, 2);
$k = rawurlencode(rawurldecode($kv[0]));
$v = rawurlencode(rawurldecode($kv[1] ?? '')); // bare key "a" becomes "a="
$pairs[] = [$k, $v];
}
usort($pairs, function ($a, $b) {
return strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1]);
});
return implode('&', array_map(function ($p) {
return $p[0] . '=' . $p[1];
}, $pairs));
}
// The path is never decoded; only the hex digits of existing escapes are upper-cased.
function fl_canonical_path(string $path): string
{
return preg_replace_callback('/%[0-9a-fA-F]{2}/', function ($m) {
return strtoupper($m[0]);
}, $path);
}
function fl_sign(string $secret, string $method, string $path, string $query, string $timestamp, string $nonce, string $body = ''): array
{
$canonical = implode("\n", [
strtoupper($method),
fl_canonical_path($path),
fl_canonical_query($query),
$timestamp,
$nonce,
hash('sha256', $body),
]);
return ['canonical' => $canonical, 'signature' => hash_hmac('sha256', $canonical, $secret)];
}
if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$timestamp = (string) time(); // seconds
$nonce = bin2hex(random_bytes(16)); // new nonce on every attempt
$sig = fl_sign(getenv('FOURLEAGUE_SECRET'), 'GET', '/v1/ping', '', $timestamp, $nonce)['signature'];
$ch = curl_init('https://api.4league.app/v1/ping');
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,
],
]);
echo curl_exec($ch), "\n";
}
developers/snippets/sign.py"""4league /v1 request signing, Python 3 (hmac + hashlib + urllib)."""
import hashlib
import hmac
import os
import re
import secrets
import time
import urllib.request
from urllib.parse import quote, unquote
def encode(s):
# safe='' encodes everything but A-Za-z0-9 _ . - ~ (RFC 3986 unreserved), including / ! ' ( ) *
return quote(s, safe='')
def canonical_query(raw_query):
pairs = []
for part in raw_query.split('&'):
if not part:
continue
k, _, v = part.partition('=') # bare key "a" becomes "a="
# unquote (not unquote_plus): '+' is a literal plus, never a space
pairs.append((encode(unquote(k)), encode(unquote(v))))
pairs.sort() # by encoded key, then encoded value; duplicates are kept
return '&'.join(f'{k}={v}' for k, v in pairs)
def canonical_path(path):
# never decoded; only the hex digits of existing escapes are upper-cased
return re.sub(r'%[0-9a-fA-F]{2}', lambda m: m.group(0).upper(), path)
def sign(secret, method, path, query, timestamp, nonce, body=''):
body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
canonical = '\n'.join([method.upper(), canonical_path(path), canonical_query(query), timestamp, nonce, body_hash])
signature = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()
return canonical, signature
if __name__ == '__main__':
timestamp = str(int(time.time())) # seconds
nonce = secrets.token_hex(16) # new nonce on every attempt
_, signature = sign(os.environ['FOURLEAGUE_SECRET'], 'GET', '/v1/ping', '', timestamp, nonce)
req = urllib.request.Request('https://api.4league.app/v1/ping', headers={
'X-4League-Key': os.environ['FOURLEAGUE_KEY_ID'],
'X-4League-Timestamp': timestamp,
'X-4League-Nonce': nonce,
'X-4League-Signature': signature,
})
with urllib.request.urlopen(req) as r:
print(r.status, r.read().decode())
developers/snippets/curl.sh#!/usr/bin/env bash
# 4league /v1 ping with curl + openssl. Needs FOURLEAGUE_KEY_ID and FOURLEAGUE_SECRET.
# Only valid for requests without a query string; for anything else use a helper that
# builds the canonical query (Node, PHP or Python tabs).
set -euo pipefail
TS=$(date +%s) # seconds
NONCE=$(openssl rand -hex 16) # new nonce on every attempt
EMPTY_SHA=$(printf '' | openssl dgst -sha256 -hex | awk '{print $NF}')
CANONICAL=$(printf 'GET\n/v1/ping\n\n%s\n%s\n%s' "$TS" "$NONCE" "$EMPTY_SHA")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$FOURLEAGUE_SECRET" -hex | awk '{print $NF}')
curl -sS https://api.4league.app/v1/ping \
-H "X-4League-Key: $FOURLEAGUE_KEY_ID" \
-H "X-4League-Timestamp: $TS" \
-H "X-4League-Nonce: $NONCE" \
-H "X-4League-Signature: $SIG"
HTTP/1.1 200 OK
{
"data": {
"apiVersion": 1,
"serverTime": "2026-10-13T07:22:37.759Z",
"keyId": "key1",
"resourceType": "organization",
"resourceId": "64b000000000000000000001",
"scopes": [
"competitions:read",
"matches:read",
"standings:read",
"teams:read",
"players:read",
"matchStatistics:read",
"organizations:read"
]
}
}
apiVersion: always 1 for /v1.serverTime: server clock (ISO 8601). Compare it with your own clock when you see unauthorized.keyId: the key id you sent in X-4League-Key.resourceType: organization: the kind of resource the key is scoped to.resourceId: the organization the key belongs to. Everything below it is reachable too when the key was created with descendants.scopes: what this key may read. /ping itself needs no scope.Values are from a sample key; yours will differ. A 401 means the signature or timestamp was rejected (see below).
Authentication failures return HTTP 401 with the same message. Do not branch on anything except the status and error.code.
malformedHeader Subject to change{
"error": {
"code": "unauthorized",
"message": "Invalid credentials",
"reason": "malformedHeader"
}
}
The reason field is returned today only for malformed or missing headers. The owner is still deciding whether all 401 bodies should be identical; do not rely on reason.
otherFailures{
"error": {
"code": "unauthorized",
"message": "Invalid credentials"
}
}
Unknown or revoked key, stale timestamp, bad signature, replayed nonce: no reason field.
unauthorizedThe API deliberately does not say which check failed. Work through these in order:
Date.now() in JavaScript and System.currentTimeMillis() in Java return milliseconds. Divide by 1000 and floor. A 13-digit timestamp is wrong.+ must become %2B, spaces %20, !'()* must be encoded, hex upper-case, pairs sorted by encoded key then value.malformedHeader variant.