Skip to the content

Developers

Check a record status where your data already lives.

One issuer. An open vocabulary. No account.

The record status is a flag beside a record about a person, and anyone can check it.

There is one issuer and an open vocabulary. Verifying a status costs nothing and needs no account.

Reading it takes about five minutes, in the database, the warehouse or the spreadsheet where your records already live.

The five values

A status is one of five values. Each has one phrase a page shows for it, quoted here from the vocabulary.

  • verifiedVerified from the commission's document
  • reportedReported on the open web
  • subject_confirmedConfirmed by the athlete
  • subject_correctedCorrected at the athlete's request
  • disputedDisputed

What each value means, what it rests on and the rules that govern it are on the vocabulary page, where every term has a stable address. Read the vocabulary

Install

Two packages with the same three functions, one for each language.

The packages are in private preview.

  • @pansofica/flagfor TypeScript and JavaScript
  • pansofica-flagfor Python

Both carry this license: Apache-2.0. Ask for them through the contact form. Talk to us

Nothing is needed to start. The JavaScript check below works today, with nothing installed: Verify it yourself

Three functions

The same names in both languages, in the casing each language expects. None of them decides anything: the kit carries the status the issuer derived, and never derives one.

  1. 01

    Verify a signed link, offline

    verifyFlagverify_flag

    Checks a signed link on your own machine against the published key set. The one request it ever needs is the key set, and you may pin that instead. A bad link never throws: the result says why the link is not valid.

    TypeScript
    import { fetchKeys, verifyFlag } from '@pansofica/flag';
    
    const keys = await fetchKeys(); // one request, held for an hour
    const verdict = await verifyFlag(link, keys, {
      expect: { slug: 'fixture-alpha' },
    });
    if (verdict.valid) console.log(verdict.claims.status);
    else console.log(verdict.reason); // 'expired', ...
    Python
    from pansofica_flag import fetch_keys, verify_flag
    
    keys = fetch_keys()  # one request, held for an hour
    expect = {"slug": "fixture-alpha"}
    verdict = verify_flag(link, keys, expect=expect)
    if verdict.valid:
        print(verdict.claims.status)
    else:
        print(verdict.reason)  # "expired", ...
  2. 02

    Read the status as it is now

    lookupStatuslookup_status

    Runs on a server. It sends the slug of the public page and nothing else, and returns the status, its words and a signed receipt you can check again later, offline. To read many records, lookupMany goes one at a time, paces itself and honors Retry-After. The signed receipt proves the status of the record it names. It does not prove that the address you asked for became that record: a moved answer is stored with its receipt and put in front of a person.

    TypeScript
    import { lookupMany, lookupStatus } from '@pansofica/flag';
    
    // On a server. The slug is the only thing sent.
    const result = await lookupStatus('fixture-alpha');
    const { status, label } = result.recordStatus;
    row.pansofica_receipt = result.receipt; // keep the signed link
    
    for await (const item of lookupMany(slugs)) {
      if (!item.ok) {
        note(item.slug, item.error);
        continue;
      }
      const found = item.result;
      save(item.slug, found); // every status, disputed too
      // moved: stored like any other answer, AND a person looks at it
      if (found.moved) recordMoveForReview(item.slug, found);
    }
    Python
    from pansofica_flag import lookup_many, lookup_status
    
    # On a server. The slug is the only thing sent.
    result = lookup_status("fixture-alpha")
    flag = result.record_status  # status, label, last reviewed
    row["pansofica_receipt"] = result.receipt  # keep the signed link
    
    for item in lookup_many(slugs):
        if not item.ok:
            note(item.slug, item.error)
            continue
        found = item.result
        save(item.slug, found)  # every status, disputed too
        if found.moved:  # stored like any other, AND a person looks
            record_move_for_review(item.slug, found)
  3. 03

    Show it in the words of the vocabulary

    renderBadgestatusLinerender_badgestatus_line

    renderBadge draws the same typeset badge the service draws, for every value alike. statusLine gives the words, and adds "when signed" with the date when the status comes from a signed link and not from a lookup.

    TypeScript
    import { renderBadge, statusLine } from '@pansofica/flag';
    
    const svg = renderBadge('disputed'); // typeset like every value
    
    statusLine('subject_confirmed');
    // Confirmed by the athlete
    // verdict: what verifyFlag returned
    if (verdict.valid) {
      statusLine('subject_confirmed', {
        whenSigned: verdict.issuedAt,
      });
      // Confirmed by the athlete (when signed, 2026-03-16)
    }
    Python
    from pansofica_flag import render_badge, status_line
    
    svg = render_badge("disputed")  # typeset like every value
    
    status_line("subject_confirmed")
    # Confirmed by the athlete
    # verdict: what verify_flag returned
    if verdict.valid:
        status_line("subject_confirmed",
                    when_signed=verdict.issued_at)
        # Confirmed by the athlete (when signed, 2026-03-16)

Verify it yourself

No account, and none of our packages. The JavaScript check needs nothing installed; the Python check has one dependency.

  1. 01Fetch the public keys and pick the one whose kid matches the header of the link.
  2. 02Check the EdDSA signature of the link with that key.
  3. 03Check that exp has not passed.
  4. 04Compare slug and sub with the record you are looking at. A link for another record says nothing about this one.
Get a signed link, by the slug of the public page
GET https://api.pxl8.io/sports-data/fighters/record-status/link?slug=SLUG

From a server: that address answers no other origin. The key set and the verify address answer any origin.

JavaScript: Node 20 or later, or a current browser on an https page
const KEYS_URL = 'https://www.pansofica.com/.well-known/record-status-keys.json';
const ISSUER = 'https://pansofica.com/vocabulary/record-status';
const STATUSES = ['disputed', 'subject_corrected', 'subject_confirmed',
  'verified', 'reported'];
const MAX_LIFE = 90 * 24 * 60 * 60; // seconds: no link lives longer
// a part one character past a multiple of four: the issuer drops that character
const trim = (s) => (s.length % 4 === 1 ? s.slice(0, -1) : s);
const b64 = (s) => atob(trim(s).replace(/-/g, '+').replace(/_/g, '/'));
const bytes = (s) => Uint8Array.from(b64(s), (c) => c.charCodeAt(0));
// ignoreBOM keeps a byte order mark in the text, so JSON.parse refuses it
const utf8 = new TextDecoder('utf8', { ignoreBOM: true });
const json = (s) => JSON.parse(utf8.decode(bytes(s)));
const whole = Number.isSafeInteger;
const text = (v) => v === null || typeof v === 'string';
const name = (v) => typeof v === 'string' && v !== '';

// Verifying many links? Fetch the key set once and pass it as keys.
async function verifyLink(link, slug, sub, keys) {
  try {
    keys ??= (await (await fetch(KEYS_URL)).json()).keys;
    if (typeof link !== 'string' || link.length > 2048) return false;
    // three base64url parts, nothing else: no padding, no space, no + or /
    if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(link)) return false;
    const [head, body, sig] = link.split('.');
    // 64 bytes have ONE spelling: 86 characters, the last one of these four
    if (sig.length !== 86 || !'AQgw'.includes(sig[85])) return false;
    const header = json(head);
    if (header.alg !== 'EdDSA' || typeof header.kid !== 'string') return false;
    const jwk = keys.find((k) => k?.kid === header.kid);
    if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') return false;
    const alg = { name: 'Ed25519' };
    const okp = { kty: jwk.kty, crv: jwk.crv, x: jwk.x };
    const key = await crypto.subtle.importKey('jwk', okp, alg, false, ['verify']);
    const signed = new TextEncoder().encode(`${head}.${body}`);
    if (!(await crypto.subtle.verify(alg, key, bytes(sig), signed))) return false;
    const c = json(body);
    const times = whole(c.iat) && whole(c.exp) && c.exp > c.iat;
    const shaped = times && c.exp - c.iat <= MAX_LIFE && name(c.sub)
      && name(c.slug) && STATUSES.includes(c.status)
      && 'doc' in c && text(c.doc) && 'lastReviewed' in c && text(c.lastReviewed);
    const live = shaped && Date.now() / 1000 < c.exp;
    const same = c.slug === slug && (sub === undefined || c.sub === sub);
    return live && same && c.iss === ISSUER;
  } catch {
    // not a link, no key with that kid, or the key set could not be read
    return false;
  }
}
Python 3.9 or later, one dependency
import base64, json, re, time, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

KEYS_URL = "https://www.pansofica.com/.well-known/record-status-keys.json"
ISSUER = "https://pansofica.com/vocabulary/record-status"
STATUSES = ("disputed", "subject_corrected", "subject_confirmed",
            "verified", "reported")
MAX_LIFE = 90 * 24 * 60 * 60  # seconds: no link lives longer
# three base64url parts, nothing else: no padding, no space, no + or /
SHAPE = re.compile(r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")

def b64(s):
    # a part one character past a multiple of four: the issuer drops that character
    s = s[:-1] if len(s) % 4 == 1 else s
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def refuse(word):
    # NaN and Infinity are Python's, not JSON's: the issuer refuses them
    raise ValueError(word)

def obj(s):
    # plain utf8 keeps a byte order mark in the text, so json refuses it
    return json.loads(b64(s).decode("utf8", "replace"), parse_constant=refuse)

def whole(v):
    number = isinstance(v, (int, float)) and not isinstance(v, bool)
    return number and v == int(v) and abs(v) < 2**53

def text(c, k):
    return k in c and (c[k] is None or isinstance(c[k], str))

def name(v):
    return isinstance(v, str) and v != ""

# Verifying many links? Fetch the key set once and pass it as keys.
def verify_link(link, slug, sub=None, keys=None):
    try:
        if keys is None:
            with urllib.request.urlopen(KEYS_URL, timeout=10) as res:
                keys = json.load(res)["keys"]
        if not isinstance(link, str) or len(link) > 2048:
            return False
        if not SHAPE.fullmatch(link):
            return False
        head, body, sig = link.split(".")
        # 64 bytes have ONE spelling: 86 characters, the last one of these four
        if len(sig) != 86 or sig[85] not in "AQgw":
            return False
        header = obj(head)
        kid = header.get("kid")
        if header.get("alg") != "EdDSA" or not isinstance(kid, str):
            return False
        jwk = next(k for k in keys if isinstance(k, dict) and k.get("kid") == kid)
        if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519":
            return False
        key = Ed25519PublicKey.from_public_bytes(b64(jwk["x"]))
        key.verify(b64(sig), f"{head}.{body}".encode())
        c = obj(body)
        times = whole(c.get("iat")) and whole(c.get("exp"))
        shaped = times and 0 < c["exp"] - c["iat"] <= MAX_LIFE
        shaped = shaped and name(c.get("sub")) and name(c.get("slug"))
        shaped = shaped and c.get("status") in STATUSES
        shaped = shaped and text(c, "doc") and text(c, "lastReviewed")
        live = shaped and time.time() < c["exp"]
        same = c["slug"] == slug and (sub is None or c["sub"] == sub)
        return bool(live and same and c.get("iss") == ISSUER)
    except Exception:
        # not a link, no key with that kid, or the key set could not be read
        return False

The key set is a JSON Web Key Set, and it answers any origin: https://www.pansofica.com/.well-known/record-status-keys.json

A link that verifies says what the status was when the link was signed. For the status as it is now, use the lookup or the hosted badge.

Recipes

Each one is a complete, tested example for a place where records are kept.

Every recipe is read-only and sends the slug and nothing else: never more than the public page shows. The keyed tier sends the id of the record in place of the slug, and nothing else.

  • Postgres

    A status type that accepts the five values only, the words as a function of the status, a trigger that stamps the time of every check, a view that joins your table to the lookup cache and filters no row, and one script, safe to run on a schedule, that carries the statuses onto your rows.

    SQL
    CREATE TYPE pansofica_status AS ENUM
      ('disputed', 'subject_corrected', 'subject_confirmed',
       'verified', 'reported');
    ALTER TABLE athletes
      ADD COLUMN pansofica_status pansofica_status,
      ADD COLUMN pansofica_receipt text,
      ADD COLUMN status_checked_at timestamptz,
      ADD CHECK (pansofica_status <> 'disputed' OR pansofica_receipt IS NOT NULL);
  • dbt

    A macro that adds the status columns to a model and can drop no row, and generic tests: one fails on a row that says disputed and holds no receipt, one on a status outside the vocabulary, one on a cache key that is not clean.

    YAML
    models:
      - name: crm_athletes_flagged
        columns:
          - name: pansofica_status
            tests:
              - pansofica_flag.pansofica_status_in_vocabulary
              - pansofica_flag.pansofica_disputed_has_receipt
  • Snowflake or Databricks notebook

    One module behind both notebooks. It checks the distinct slugs of your own table in capped, resumable runs inside the limits of the open tier, waits when the service answers 429, and keeps each failure with its reason. With a key it reads by the id of the record, and still fetches the signed link of every disputed row.

    Python
    result = flag_batch.check_batch(
        slugs,             # the slug column: the only thing that leaves
        cache=cache_rows,  # answered inside recheck_after: not asked again
        misses=miss_rows,  # slugs the issuer does not know
    )
    result.rows      # MERGE into pansofica_lookup_cache
    result.failures  # slug and reason: asked again on the next run
  • CSV round trip

    Export, check, import, for a system such as Salesforce that takes a file. Only the slug column is read for the request. A row that cannot be checked goes to a rejects file with its reason, and a disputed row is written like any other. With a pinned key set, a run that is fully cached makes no request at all.

    Shell
    python flag_csv.py check --in export.csv --out import.csv \
        --id-column Id --slug-column Pansofica_Slug__c \
        --rejects rejects.csv --cache cache.json

The Postgres block shows what the recipe installs; the others are excerpts. The full recipes come with the kit.

The hosted badge

A link and an image. Nothing in it runs.

The hosted badge shows the live status, so a dispute reaches every page that embeds it.

HTML
<a href="https://pxl8.io/SLUG?rs=SIGNED_LINK" rel="noopener">
  <img src="https://www.pansofica.com/record-status/badge.svg?t=SIGNED_LINK" alt="Record status" referrerpolicy="no-referrer">
</a>

A badge you render yourself from a signed link says what the status was when the link was signed. Print the date with it.

The image asks for no referrer, so the address of the page that embeds it is not sent with the image request.

Volume

The open lookup tier is rate limited. The kit paces itself. When a limit is reached, lookupMany waits as the Retry-After header says, up to a cap; past the cap the row comes back with the error, and nothing is dropped.

For volume there is keyed access with higher limits. Terms on request.

The keyed answer carries no signed link: fetch the link of a disputed row with the lookup.

Talk to us

Carry the flag honestly

A system that shows the record status keeps these eight rules.

  1. 01

    Never hide a disputed fact.

    Show the record and the word Disputed. Never drop the row, never blank it, and never rank it down to where nobody sees it. Show the word wherever you show the record, in the same place and with the same prominence as any other status. Show every status you read, not only the favorable ones. The status travels with the record in exports and downstream APIs.

  2. 02

    Print the words of the vocabulary, not your own.

    Each value has one phrase. Show that phrase, letter for letter, and add nothing that weakens it.

  3. 03

    Say when you last checked.

    A status shown without the time it was last checked reads as current when it may not be.

  4. 04

    A signed link speaks of then. The lookup speaks of now.

    A signed link says what the status was when it was signed. The lookup and the hosted badge say what it is now. Show the newest status you hold. An older signed link never outranks a newer reading.

  5. 05

    The status says that, never why.

    It says that a record is disputed and nothing of the reason. Do not guess a reason, and do not print one.

  6. 06

    Never type a status by hand.

    No receipt, no claim. A status in your system is one you read from the issuer, never one you typed. A row that says disputed always keeps its signed link.

  7. 07

    Check again on a schedule, and after any complaint.

    A status can change on any day. A complaint is a reason to look now. Past your own re-check interval, say the status is stale; never show a status as current once its signed link has expired. Set that interval in days, not months: the recipes ship with a week. A row with no signed link is stale at the same interval.

  8. 08

    Send the slug and nothing else.

    Never a name, a date of birth, an email address or any other column of your table. The slug is already public. The rest is not yours to send. The keyed tier sends the id of the record in place of the slug, and nothing else.

Corrections belong to the subject. Link to the record page, where the athlete confirms the record or disputes it: https://pxl8.io/SLUG

Terms

  • Use of the lookup and of the addresses on this page is under the terms of service. Lookup only: no mirroring or systematic collection. Data is presented as published by the named athletic commissions and federations; it is not medical or regulatory advice.
  • The kit carries this license: Apache-2.0
  • The vocabulary is open, and every term has a stable address: Record status vocabulary
  • Questions, keyed access and the packages: Talk to us

Tell us where your records live.

We reply by email, from a person who can answer questions about the kit.