Sport1 API

REST endpoints that back the React Native (Expo) app and the embeddable sport
widgets. Everything is registered under wp-json/ in three namespaces:

NamespacePurpose
app/v2Current app API. Flat content[] component list.
app/v1Older app API (nested sections[]) plus article/video/config/widget routes that have no v2 equivalent.
sport1/v1Menu & navigation used by the website and the app.

All routes are GET and public (permission_callback => '__return_true').
Files live in api/v1/, api/v2/ and are wired up in sport1-api.php.

In the examples below, replace $HOST with the environment host.


Conventions

Common query parameters (supported where noted per endpoint):

ParamDescription
nocache=1Bypass the payload cache and the per-block query caches.
debug=1Append a _debug object (build time, DB queries, component count). Never cached.
config={JSON}URL-encoded personalisation contexts. See Request config.

Caching. Payloads are stored in the app_api object-cache group with the TTL
from config/app-config.json (cache.ttl). app/v2/page/{id} additionally
supports conditional GET and answers 304 Not Modified when the client’s
If-Modified-Since still matches post_modified_gmt.

Component shape. app/v2 responses carry a flat content array. Every entry
is { type, data, contentIndex, contentTarget, contentInSectionIndex }, where
type is a component name such as NEWSTICKER, NEWSTICKER_GROUP, HEADING,
TEASER_POST or LINK_LIST. contentTarget groups entries that came from the
same section; contentInSectionIndex is the position inside that group.


app/v2

GET app/v2/category

Flat list of all top-level categories (channels). Each item carries id, name,
slug, permalink, apiUrl, imageUrl, type and numberOfChildren.

Params: nocache.

curl "https://$HOST/wp-json/app/v2/category"

GET app/v2/category/id/{id}

Canonical category payload: term data, parent, children (top-level only) and
the rendered content for the category page. If an editor has set an override
page for the term, responds 302 to app/v2/page/{pageId} instead.

Params: fetchContent (default true — set 0 to skip building content),
nocache, debug.

curl "https://$HOST/wp-json/app/v2/category/id/1234"
curl "https://$HOST/wp-json/app/v2/category/id/1234?fetchContent=0"

GET app/v2/category/id/{id}/news

The category’s „Weitere News“ feed on its own — the target of a newsticker’s
„Alle News“ link. Renders the category-page-newsticker-entries-list pattern, so
content holds the same paginated NEWSTICKER_GROUP component the category page
uses (follow its data.loadMoreUrl for the next batch). type is
CATEGORY_NEWS.

Params: nocache, debug.

curl "https://$HOST/wp-json/app/v2/category/id/1234/news"

GET app/v2/category/{slug} and GET app/v2/category/{parent}/{slug}

Slug lookups. Both resolve the term and 302 to the canonical
app/v2/category/id/{id} route, forwarding any query string, so there is exactly
one cacheable URL per category.

curl -iL "https://$HOST/wp-json/app/v2/category/fussball"
curl -iL "https://$HOST/wp-json/app/v2/category/fussball/bundesliga"

GET app/v2/page/{page}

Single dispatcher for every app page. {page} is either a numeric page ID or one
of the virtual pages:

{page}Result
123That page’s content, plus topChips when it is the configured home page.
home302 to the configured home page ID (preserves config).
news302 to the configured news page ID (preserves config).
favoritenVirtual page built from the client’s favourites (config required for personalised output).
liveVirtual „live“ (Ergebnisse) page; optional live/{sport} narrows it to one sport.
datenVirtual stats page. Always takes a sub-path — see daten pages.

Params: nocache, debug, config. Numeric pages support 304.

curl "https://$HOST/wp-json/app/v2/page/456"
curl -iL "https://$HOST/wp-json/app/v2/page/home"
curl -iL "https://$HOST/wp-json/app/v2/page/news"
curl "https://$HOST/wp-json/app/v2/page/live"
curl "https://$HOST/wp-json/app/v2/page/live/fussball"

daten pages

app/v2/page/daten/... serves the stats pages. Each response is a
VIRTUAL_PAGE whose content holds exactly one WIDGET component:

{
  "type": "WIDGET",
  "data": {
    "widget": "standings",
    "widgetUrl": "https://$HOST/wp-json/app/v1/sport-widget/standings?config=%7B...%7D",
    "widgetHeight": null
  }
}

widgetHeight is a fixed pixel height for widgets that have one; today only
result-teaser does (94), so every daten view reports null and the client sizes
the WebView itself.

The client renders widgetUrl in a WebView — it is a ready-made
app/v1/sport-widget/{view} URL with sportId,
competitionId, seasonId and friends already resolved from the term’s
competition metadata. title is the upper-cased view, slug/permalink mirror
the public /daten/... path.

Three path shapes are accepted:

ShapeUsed for
daten/{sport}/{competition}/{view}The normal case.
daten/{sport}/{competition}/live-ticker/{matchId}Ticker for one specific match.
daten/{sport}/ranglisteSport-wide ranking; the only 2-segment form.

Supported {view} values and the widget each maps to:

{view}WidgetNotes
tabellestandingspositionType from the competition’s first standing type, else LEAGUE.
spielplangameplan
ergebnissegameplanSame widget as spielplan, different viewType label.
kalenderkalenderFalls back competition → category → sport when resolving the target.
fahrerwertungrankingspositionType=PERSON, limit=100.
teamwertungrankingspositionType=TEAM, limit=100.
ranglisterankingspositionType from the term meta, limit=20. Needs sport_id + position_type; tennis additionally needs category_id.
live-tickerlive-tickerWhole competition, or a single match when {matchId} is appended.

Params: nocache, debug.

# {sport}/{competition}/{view}
curl "https://$HOST/wp-json/app/v2/page/daten/fussball/bundesliga/tabelle"
curl "https://$HOST/wp-json/app/v2/page/daten/fussball/bundesliga/spielplan"
curl "https://$HOST/wp-json/app/v2/page/daten/fussball/bundesliga/ergebnisse"
curl "https://$HOST/wp-json/app/v2/page/daten/fussball/bundesliga/kalender"
curl "https://$HOST/wp-json/app/v2/page/daten/fussball/bundesliga/live-ticker"

# Motorsport standings split by driver / constructor
curl "https://$HOST/wp-json/app/v2/page/daten/motorsport/formel-1/fahrerwertung"
curl "https://$HOST/wp-json/app/v2/page/daten/motorsport/formel-1/teamwertung"

# Single match ticker
curl "https://$HOST/wp-json/app/v2/page/daten/fussball/bundesliga/live-ticker/opta_match_2345678"

# Sport-wide ranking (2-segment form)
curl "https://$HOST/wp-json/app/v2/page/daten/tennis/rangliste"

curl "https://$HOST/wp-json/app/v2/page/daten/fussball/bundesliga/tabelle?nocache=1&debug=1"

Errors, all 404 unless noted:

CodeWhen
invalid_pathThe sub-path doesn’t match one of the three shapes above (including a bare page/daten).
invalid_view{view} is not in the table; the message lists the supported values.
not_foundNo competition category for {sport}/{competition}, or the season / calendar / ranking target couldn’t be resolved.
virtual_pages_unavailable500 — sport1-virtual-pages is inactive, so the metadata resolver is missing.

Competition metadata is read via sport1_vp_get_category_meta( $sport, $competition )
from sport1-virtual-pages. The 2-segment rangliste form asks for the sport’s
_default competition directly; the retry against _default after an empty
lookup only kicks in for a single-match live-ticker, which needs no competition
of its own. Every other view returns not_found when the
{sport}/{competition} pair has no metadata.

GET app/v2/newsticker/more

Next page of a NEWSTICKER_GROUP feed. Normally you don’t build this URL by
hand — take data.loadMoreUrl from the component and follow it.

ParamDefaultNotes
categoryId—Required unless allCategories=1.
offset0Pass the previous response’s nextOffset.
count131–50.
allCategoriesfalseQuery across all categories.
excludeCategoryIds—Comma-separated term IDs to drop.

Returns { items, hasMore, nextOffset, loadMoreUrl }; loadMoreUrl is null on
the last page. Responds 400 if neither categoryId nor allCategories is set.

curl "https://$HOST/wp-json/app/v2/newsticker/more?categoryId=1234&offset=13&count=13"
curl "https://$HOST/wp-json/app/v2/newsticker/more?allCategories=1&excludeCategoryIds=99,100"

GET app/v2/search

Typeahead over favourite-able entities: sport categories, competition categories
and team tags. Returns a flat array of favourite chips, each with a fuzzy-match
score (matching tolerates up to 3 edits). Requires sport1-core, otherwise 500.

ParamDefaultNotes
q—Required, at least 2 characters.
limit10Max 15, applied per entity type.
curl "https://$HOST/wp-json/app/v2/search?q=bayern&limit=5"

app/v1

GET app/v1/config

Serves config/app-config.json verbatim — cache TTLs, page IDs and other client
settings. Change the JSON file, no deploy of code needed. Supports ETag /
conditional GET keyed on the file mtime.

curl "https://$HOST/wp-json/app/v1/config"

GET app/v1/article/{id}

Full article for the app: { id, title, excerpt, publishedAt, modifiedAt, permalink, featuredImage, meta: { channels, tags, primaryChannel }, topMedia, authorArea, blocks }. Blocks come from parse_blocks(), layout containers are
flattened, and each remaining block is mapped to a typed object. 404 for
anything that isn’t a published post.

curl "https://$HOST/wp-json/app/v1/article/98765"

GET app/v1/video/{id}

Same shape as the article endpoint, for the video post type.

curl "https://$HOST/wp-json/app/v1/video/98765"

GET app/v1/agf/video/{id}

AGF (audience measurement) metadata for a video. Accepts either a numeric post ID
or the video’s top_video_id UUID, which is looked up against postmeta. 400 on
an id that is neither; 404 when no published video matches or it has no
top_video_id.

curl "https://$HOST/wp-json/app/v1/agf/video/98765"
curl "https://$HOST/wp-json/app/v1/agf/video/3fa85f64-5717-4562-b3fc-2c963f66afa6"

GET app/v1/page/{id} and GET app/v1/home

Predecessor of app/v2/page. Returns nested sections[], each
{ type: 'SECTION', data: { name, patternName, children: [...] } }, instead of a
flat component list. home redirects to the configured home page ID.

curl "https://$HOST/wp-json/app/v1/page/456"

GET app/v1/newsticker/more

v1 equivalent of app/v2/newsticker/more, same parameters.

curl "https://$HOST/wp-json/app/v1/newsticker/more?categoryId=1234&offset=13"

GET app/v1/category/{slug} and GET app/v1/category/{parent}/{slug}

v1 category payload. Instead of rendering components it describes the page:
termId, name, slug, permalink, type, parent, children,
overridePage and sections[], where each section lists its feeds[] with
type (POSTS | NEWSTICKER | NEWSTICKER_GROUP), the resolved posts and
hasMore / nextOffset for paginated ones. Section structure is derived by
parsing the theme’s block patterns, so it tracks pattern edits automatically.

curl "https://$HOST/wp-json/app/v1/category/fussball"
curl "https://$HOST/wp-json/app/v1/category/fussball/bundesliga"

GET app/v1/sport-widget/{view}

The only endpoint that returns HTML, not JSON — a complete, standalone
document intended for an iframe or WebView. It ships SSR markup for fast first
paint, window.Sport1Data for hydration, and the block’s compiled JS/CSS.

{view} is one of gameplan, standings, rankings, result-teaser,
ergebnisse, kalender, live-ticker (liveticker still works as a legacy
alias).

ParamDefaultNotes
config{}URL-encoded JSON, e.g. {"sportId":"opta:1","competitionId":"opta_22","seasonId":"opta_2025"}.
header01 renders in-block chrome (the ergebnisse „HEUTE LIVE“ header + CalendarBar).
# {"sportId":"opta:1","competitionId":"opta_22","seasonId":"opta_2025"}
curl "https://$HOST/wp-json/app/v1/sport-widget/standings?config=%7B%22sportId%22%3A%22opta%3A1%22%2C%22competitionId%22%3A%22opta_22%22%2C%22seasonId%22%3A%22opta_2025%22%7D"

curl "https://$HOST/wp-json/app/v1/sport-widget/ergebnisse?header=1"

Filters: sport1_widget_bypass_cache (skip transient, send no-store),
sport1_widget_frame_ancestors (CSP frame-ancestors).


sport1/v1 — menus & navigation

Competition metadata (sport_id, competition_id, …) is owned by
sport1-virtual-pages and read through sport1_vp_get_category_meta(); these
endpoints degrade gracefully if that plugin is inactive. Responses are cached in
transients.

GET sport1/v1/top-menu

Top navigation entries. Also reused server-side as the topChips field of the
home page payload.

curl "https://$HOST/wp-json/sport1/v1/top-menu"

GET sport1/v1/side-menu

Side navigation, built from the competitions GraphQL query.

curl "https://$HOST/wp-json/sport1/v1/side-menu"

GET sport1/v1/bottom-menu

Footer menu, from the bottom-menu WP nav menu location.

curl "https://$HOST/wp-json/sport1/v1/bottom-menu"

GET sport1/v1/subnav

Tab bar for a sport or competition page (newsticker, videos, heute-live,
transfermarkt, wettbewerbe, …).

ParamNotes
sportSport slug.
competitionCompetition slug.
curl "https://$HOST/wp-json/sport1/v1/subnav?sport=fussball"
curl "https://$HOST/wp-json/sport1/v1/subnav?sport=fussball&competition=bundesliga"

GET sport1/v1/competition-menu

Menu for one competition. Both params are required.

curl "https://$HOST/wp-json/sport1/v1/competition-menu?sport=fussball&competition=bundesliga"

Request config

config personalises a response. It is URL-encoded JSON with a contexts
object; today only FAVORITEN is understood:

{
  "contexts": {
    "FAVORITEN": {
      "items": [
        { "id": "123", "type": "TEAM" },
        { "id": "456", "type": "CHANNEL" }
      ],
      "postsToShow": 10
    }
  }
}

type is TEAM, CHANNEL or SUBCHANNEL (SPORT → CHANNEL and
COMPETITION → SUBCHANNEL are accepted as aliases). Items with an empty id
or an unknown type are dropped; if nothing survives, the context is ignored and
the response falls back to its default content.

Because the payload varies per client, requests carrying config are not
served from — nor written to — the shared payload cache, and they skip the 304
shortcut.

curl -G "https://$HOST/wp-json/app/v2/page/favoriten" \
  --data-urlencode 'config={"contexts":{"FAVORITEN":{"items":[{"id":"123","type":"TEAM"}]}}}'

Internals

Not endpoints, but shared by them:

FileResponsibility
api/helpers.phpBlock-name collection, reshape CDN URL helpers.
api/rest.phpApp_Rest — conditional GET, 304 handling, response headers.
api/post.phpApp_Post — post/term image resolution.
api/tracking.phpApp_Tracking — tracking payloads per post/category.
api/term_image.phpCategory image lookup.
api/graphql_client.php + api/graphql/*.graphqlSports/competitions GraphQL queries for the menus.
api/v2/request-config.phpApp_Request_Config — parses and validates config.
api/v2/widget-config.phpWidget config normalisation.
api/v2/virtual-page.phpBuilds favoriten / live / daten; has no routes of its own — reached through the app/v2/page/{page} dispatcher.
cache/menu.phpMenu cache invalidation.
config/app-config.jsonCache TTLs, page IDs, limits. Served by app/v1/config.