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 documentreportedReported on the open websubject_confirmedConfirmed by the athletesubject_correctedCorrected at the athlete's requestdisputedDisputed
Install
Two packages with the same three functions, one for each language.
The packages are in private preview.
@pansofica/flagfor TypeScript and JavaScriptpansofica-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.
- 01
Verify a signed link, offline
verifyFlagverify_flagChecks 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.
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', ...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", ... - 02
Read the status as it is now
lookupStatuslookup_statusRuns 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.
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); }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) - 03
Show it in the words of the vocabulary
renderBadgestatusLinerender_badgestatus_linerenderBadge 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.
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) }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.
- 01Fetch the public keys and pick the one whose kid matches the header of the link.
- 02Check the EdDSA signature of the link with that key.
- 03Check that exp has not passed.
- 04Compare slug and sub with the record you are looking at. A link for another record says nothing about this one.
GET https://api.pxl8.io/sports-data/fighters/record-status/link?slug=SLUGconst 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;
}
}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 FalseRecipes
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.
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.
models: - name: crm_athletes_flagged columns: - name: pansofica_status tests: - pansofica_flag.pansofica_status_in_vocabulary - pansofica_flag.pansofica_disputed_has_receiptSnowflake 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.
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 runCSV 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.
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 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.
<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>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 usCarry the flag honestly
A system that shows the record status keeps these eight rules.
- 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.
- 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.
- 03
Say when you last checked.
A status shown without the time it was last checked reads as current when it may not be.
- 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.
- 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.
- 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.
- 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.
- 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.