HMAC authentication

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.

Required headers

Send all four on every request. Each header must be a single value (no commas, no repeats) of at most 200 characters.

HeaderFormatNotes
X-4League-Key[A-Za-z0-9_-]{1,100}Your key id.
X-4League-TimestampUnix time in secondsAccepted 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-Signature64 hex charactersLowercase 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.

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.

Rules

  • METHOD: the HTTP method, upper-cased.
  • PATH: the raw request path exactly as sent, starting at /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).
  • SORTED_ENCODED_QUERY, built from the part after ?:
    1. Split on & and drop empty parts.
    2. Split each part at the first =. A bare key (a) becomes a=.
    3. Percent-decode the key and the value, then re-encode both per RFC 3986: only A-Za-z0-9-._~ stay unencoded. ! ' ( ) * and + are encoded. A + is a literal plus, not a space. Hex digits are upper-case.
    4. Sort the pairs by encoded key, then by encoded value (plain code-unit order, so B sorts before a). Duplicate keys are kept.
    5. Join as key=value with &. No query gives an empty line.
  • TIMESTAMP and NONCE: the same strings you send in the headers.
  • SHA256hex(body): lowercase hex SHA-256 of the raw body. For an empty body it is 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.

Test vectors

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.

  • Signing GET and HEAD vectors are what the API accepts.
  • Canonicalization only POST and PUT vectors exist to test body hashing and UTF-8 handling. The API does not accept those methods.

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.

Secret for every vector: test_secret_4league_v1_vectors. Timestamp: 1760000000. 17 vectors.

#CaseMethodPath and query as sentNonceBody / SHA-256Canonical stringSignature
1 empty query
Signing
GET /v1/matches nonce-001 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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 empty
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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

Code examples

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.

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

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

Python 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())

cURL 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"

Sample response

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

The 401 response

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.

Troubleshooting unauthorized

The API deliberately does not say which check failed. Work through these in order:

  • Clock skew. Your clock must be within 300 seconds of real time. Sync with NTP and log the timestamp you send.
  • Milliseconds instead of seconds. Date.now() in JavaScript and System.currentTimeMillis() in Java return milliseconds. Divide by 1000 and floor. A 13-digit timestamp is wrong.
  • Reused nonce. A nonce is accepted once per key, and only after the signature validated. Generate a fresh random nonce for every attempt, including retries after a 403 or 429.
  • Encoding mismatch. Compare your canonical string with the vectors above: + must become %2B, spaces %20, !'()* must be encoded, hex upper-case, pairs sorted by encoded key then value.
  • Path differs from what you sent. Sign the exact path you request, including a trailing slash, and do not decode percent-escapes in it.
  • Signature format. It must be 64 hex characters. Base64 or a truncated digest returns the malformedHeader variant.
  • Wrong secret or revoked key. Check the key id and that the secret belongs to it.