Build on the Crest standard
Two surfaces: the keyless Padel Decoder (open data — venues, tournaments, coaches) and the keyed Crest API for player ratings, history, webhooks. This page covers the keyed surface. The same buyer licence applies whenever you consume Crest data downstream.
Quickstart
- Sign up + create a key in /account/api-keys. Keys start in Free tier; upgrade by emailing [email protected].
- Copy the plaintext key shown once. Format is
crst_xxxxxxxx.<secret>— the prefix is visible in logs; the secret half is bcrypt-hashed in storage. - Authenticate with
Authorization: Bearer crst_xxxxxxxx.<secret>. - Test against the sandbox players before pointing at production handles. The minor + private fixtures specifically verify your code handles the 404-indistinguishable invariant.
Code samples
Three minimal clients hitting the public Crest endpoint. Each handles the 404-indistinguishable invariant correctly (treat “not found” as “no public Crest”, never as “account doesn't exist”).
curl
KEY="crst_xxxxxxxx.your_secret"
curl -sS \
-H "Authorization: Bearer $KEY" \
https://trustpadel.com/api/v1/players/sandbox-gold/rating
# 200 → { "handle": "sandbox-gold", "crest": { ... } }
# 404 → opaque; could be missing, minor, or privateJavaScript / TypeScript
async function getCrest(handle: string) {
const res = await fetch(
`https://trustpadel.com/api/v1/players/${encodeURIComponent(handle)}/rating`,
{
headers: { Authorization: `Bearer ${process.env.TRUSTPADEL_KEY}` },
},
)
if (res.status === 404) return null // missing OR minor OR private — same UI
if (res.status === 429) {
const retry = Number(res.headers.get('Retry-After') ?? 60)
throw new Error(`Rate limited; retry in ${retry}s`)
}
if (!res.ok) throw new Error(`Crest API error: ${res.status}`)
return res.json()
}Python
import os, httpx
def get_crest(handle: str) -> dict | None:
r = httpx.get(
f"https://trustpadel.com/api/v1/players/{handle}/rating",
headers={"Authorization": f"Bearer {os.environ['TRUSTPADEL_KEY']}"},
)
if r.status_code == 404:
return None # missing | minor | private — opaque by design
r.raise_for_status()
return r.json()Webhook signature verification
import crypto from 'node:crypto'
export function verifyCrestWebhook(req, signingSecret) {
const sig = req.headers['x-crest-signature']
if (!sig) return false
const [ts, hex] = sig.split(',')
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false // ±5min
const expected = crypto
.createHmac('sha256', signingSecret)
.update(`${ts}.${req.rawBody}`)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hex, 'hex'))
}Endpoints
GET /api/v1/players/{handle}/rating
Resolve a public handle to its top-discipline Crest + tier. 404 for non-public players (minors, private, non-L2) with no distinction.
GET /api/v1/players/{handle}/crest/{discipline}
Per-discipline Crest:
crest_number,crest_tier,provisional,confidence_band,as_of. Same 404 invariant.GET /api/v1/players/{handle}/history
Last N integrity-clean rating events (mu / sigma / crest deltas). Disputed + sandbagging-flagged events drop until resolved.
history:readscope (Developer+ tier).GET /api/v1/players/{handle}/badge
Embeddable SVG badge — tier colour-keyed. Response is
image/svg+xml; query paramstheme(light/dark),size(small/medium),discipline. Cacheable (5 min TTL). Returns a neutral “Unrated” badge for non-public players (no 404 leak).GET /api/v1/venues/search
Open + keyless. Use for venue discovery — see /decoder for full docs.
All keyed routes count toward your tier rate limit. Keyless /decoder routes don't. Deprecation + Sunset headers signal scheduled changes per RFC 8594 with a 6-month minimum window. Machine-readable spec (OpenAPI 3.1).
Rate limits + tiers
| Tier | Req/min | Req/month | Notes |
|---|---|---|---|
| Free | 60 | 10,000 | No card; sandbox-suitable. |
| Developer | 600 | 1,000,000 | Production integrations + small SaaS volumes. |
| Pro | 3,000 | 10,000,000 | Federation + media + analytics tooling. |
| Enterprise | negotiated | negotiated | Bespoke SLA + dedicated support. Talk to us. |
Tier upgrades route through billing — email [email protected] with your prefix + intended use.
Scopes
Keys carry a scope set. Write scopes (results:write, webhooks:manage) require Developer tier or above. Scope a key tightly: a credential lost from a read-only integration can't mutate data.
player:readResolve handles + read public Crest data per discipline.history:readRead a player's confirmed match history (above the integrity gate).discovery:readSearch rankings, leaderboards, recent activity.badge:readRender the embeddable Crest badge SVG / PNG.results:writeSubmit match results from a verified ingest partner. Developer+ tier.webhooks:manageCreate + manage webhook endpoints. Developer+ tier.
Webhooks
Configure endpoints per key in /account/api-keys. Endpoints must be HTTPS. Delivery is at-least-once with exponential backoff. After repeated failures the endpoint pauses automatically; you resume it from the same page.
Signing
Each request carries an X-Crest-Signature header containing an HMAC-SHA256 over <timestamp>.<raw-body> keyed by the signing secret returned at endpoint creation. Reject requests where the timestamp is more than ±5 minutes from your clock to prevent replays. Always verify the signature before processing the payload.
# Pseudocode
sig_header = req.headers['X-Crest-Signature']
ts, sig = sig_header.split(',')
if abs(now() - int(ts)) > 300: reject
expected = hmac_sha256(signing_secret, f"{ts}.{raw_body}")
if not const_time_eq(expected, sig): rejectEvents
crest.updatedA player's Crest number / tier changed after a confirmed match.result.confirmedA match transitioned to confirmed (all parties agreed, above integrity gate).result.disputedA confirmed match entered dispute and is quarantined from feeds.player.handle_changedA player renamed; their old handle 301s in handle_history.identity.mergedTwo records were resolved into one through the identity-resolution queue.integrity.flag_raisedA Layer-2 or Layer-3 integrity signal fired against a player or match.subscription.entitlement_changedA dataset entitlement was issued / renewed / revoked.
Sandbox players
Six fixture accounts cover the locked invariants. Hit these against your integration before pointing at production. The sandbox-minor and sandbox-private fixtures specifically verify the 404 invariant — your code must render the same UI for both as for a truly absent player.
sandbox-gold
200 OK — adult, Gold tier, ~1750 Crest, public.
sandbox-diamond
200 OK — adult, Diamond tier, ~2400 Crest, public + Trust+ active.
sandbox-provisional
200 OK — provisional flag true, confidence band wide.
sandbox-minor
404 — minor account. NEVER 200. Use this to verify your integration honours the indistinguishable-404 invariant.
sandbox-private
404 — adult with rating_public=false. Same 404 shape as the minor case (the absence-is-indistinguishable rule).
sandbox-disputed
200 OK — but the most recent match is disputed; the matching result.disputed webhook fires on test events.
Locked invariants
These are non-negotiable across every endpoint, every tier, every release. If your integration depends on the inverse of any of these, please choose a different integration.
- Minor + private return indistinguishable 404. No status code, no field, no metadata distinguishes a missing player from a minor or private one. Your UI must not infer existence from latency, header, or response shape.
- Article-9 tags never appear in feeds. Identity, condition, and preference tags are protected and absent from every keyed response. If a field that looks Art-9 ever appears, please report it immediately.
- Provisional Crests are clearly marked. The
provisionalboolean +confidence_bandlet you avoid presenting unstable ratings as firm. - Integrity-gated commercial feeds. Records below the provenance or integrity threshold are absent from the licensed feeds, not flagged in them. Re-pulls after disputes resolve will surface the corrected record.
Questions? [email protected] · Security: [email protected] · Integrity reports: [email protected].