Ball tracker · Native DIV widget
Live ball position in a DIV you own.
The ball tracker SDK is a single ES module. You give it an empty or pre-filled <div> and it renders the score, match status and a stadium view into that element, directly in your page. There is no iframe and no shadow DOM. Create grants and copy ready-made code in Dashboard, Ball tracker, or try the simulated demo.
How it works
- You create a grant for one match ID, one exact site origin and one API key, valid for 1 to 24 hours. You receive a scoped
bt_token once. - The module loads from
/tracker/widget.js, fetches its configuration (free) and then polls a snapshot every 5 seconds. - Team names, score, status, statistics and events are real text nodes in your DOM, styled under a scoped .fabt class prefix so they do not leak into your site.
- The stadium uses a lightweight SVG version of our pitch, keeping the standalone script small and working without a WebGL-capable device.
- Live data appears only when the live service is active and your grant is authorized. The SDK never substitutes simulated match data.
Before you install
- Use active Growth or Pro access and an active API key belonging to the same account. Starter does not include live ball tracking. An embed also requires the live service to be enabled and fresh tracking data for its selected match.
- Open Dashboard → Ball tracker. Select a received stream match ID, choose your API key, enter your website origin, choose an expiry of 1–24 whole hours and explicitly accept usage charges.
- Use the stream match ID, not an ordinary fixture ID from another endpoint. The manager lists up to 200 streams received within the last 15 minutes. Appearing in that list does not guarantee that fresh coordinates are available now. If none are listed, do not guess an ID; contact support.
- Enter only your origin, for example
https://www.example.com, not a page path, query string or fragment. Production grants require HTTPS. Local HTTP origins are allowed only on a development service, not on the production API. - You may have at most 20 active, unexpired grants per account. Revoke unused ones before creating more. One grant is for one match and origin, not one visitor; all its viewers share your account usage allowance.
- Copy the newly generated native DIV snippet and token immediately. The token is displayed once. Grant creation does not activate the live service or prove that its match has coordinates.
Demo and live activation are separate
Match statistics and events
When the match and stream coverage provide them, each snapshot may include three optional fields alongside the score, period, minute and ball position. Coverage differs by competition and fixture, so any field, key or side can be missing on any poll. Build for partial data.
type TrackerStatKey =
| "goals" | "corners" | "freeKicks" | "throwIns" | "offsides" | "penalties"
| "yellowCards" | "redCards" | "substitutions" | "goalKicks"
| "shotsOnTarget" | "shotsOffTarget" | "attacks" | "dangerousAttacks" | "possession";
interface SnapshotExtras {
statistics?: Partial<Record<TrackerStatKey, [number | null, number | null]>>;
events?: Array<{ id: string; minute: string | null; text: string }>; // newest last, max 20
stateCode?: string | null;
}statistics: only the keys that are reported. Each value is[home, away]; either side may benull.possessionis a percentage.events: plain-text commentary, oldest first and newest last, at most 20 entries.minutemay benull. Always rendertextas text, never as HTML.stateCode: an optional raw state code; prefer the human-readablestatefor display.- The widget shows a dash (—) for any statistic that is not reported. It never shows a missing value as 0, and it never fills gaps with simulated data. If no statistics or events are reported, that section says so.
- There is no per-player possession field.
| Fields | Meaning |
|---|---|
goals, corners, freeKicks, throwIns, goalKicks | Reported home/away totals. Goals and goal kicks are distinct fields. |
offsides, penalties, yellowCards, redCards, substitutions | Reported home/away totals, not a complete incident log. |
shotsOnTarget, shotsOffTarget, attacks, dangerousAttacks | Reported team totals; availability varies by match. |
possession | Home/away percentages between 0 and 100; not a named player currently holding the ball. |
Totals retain previously received values across partial stream updates. They are not all re-measured on every poll. The SDK displays the eight most recent commentary entries, newest first, from the at-most-20-entry history. Commentary is not guaranteed for every counted action; it is not a complete match-event archive. A current-event label is the latest reported commentary, not continuous player tracking.
Install with plain HTML and JavaScript
Paste the snippet where the widget should appear. No React or build step is needed on your site.
Replace both placeholders with values from the same newly created grant. The scoped token is not your fapi_ API key. Never put your account API key in a browser snippet. Load the module after the target DIV exists; it must have a non-zero available width.
<div id="fa-ball-tracker">
<!-- Replace with your own server-rendered match summary. -->
<p>Your home team v your away team. Live score and ball position load here.</p>
</div>
<script type="module">
import { mountTracker } from "https://footballapi.net/tracker/widget.js";
const tracker = mountTracker(document.getElementById("fa-ball-tracker"), {
grantId: "GRANT_ID",
token: "bt_YOUR_SCOPED_TOKEN",
});
// Single-page apps: call tracker.destroy() when the element is removed.
</script>React and single-page apps
Load the module at runtime and always call destroy() on unmount. Destroy aborts requests, stops timers and restores the original children of your element.
In Next.js App Router, put this component in a client module with "use client". Do not run mountTracker during server rendering. Keep the target DIV dedicated to the widget and its fallback; do not let React independently update its children while the SDK owns them.
import { useEffect, useRef } from "react";
const SDK = "https://footballapi.net/tracker/widget.js";
export function BallTracker({ grantId, token }: { grantId: string; token: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let handle: { destroy(): void } | undefined;
let cancelled = false;
import(/* @vite-ignore */ /* webpackIgnore: true */ SDK).then(({ mountTracker }) => {
if (!cancelled && ref.current) handle = mountTracker(ref.current, { grantId, token });
}).catch(() => {
// Keep the fallback content; never log the token or request headers.
if (!cancelled) console.warn("Ball tracker could not load. Check CSP and network access.");
});
return () => { cancelled = true; handle?.destroy(); };
}, [grantId, token]);
return (
<div ref={ref}>
<p>Live ball tracker. Score and ball position load here.</p>
</div>
);
}WordPress
- Add a Custom HTML block (not a Paragraph or Code block) and paste the dashboard snippet as it is.
- Some security plugins and editor roles strip
<script type="module">. If the script disappears after saving, add it through your theme or a header/footer snippet plugin, and keep only the<div>in the block. - A cached page still fetches match data in the browser, but its embedded token can expire. Refresh the snippet or page cache when replacing a grant; do not cache a token-bearing page beyond that grant's expiry. Do not let optimisation plugins combine, defer-transform or rewrite the module import.
- If your site uses a CSP plugin, apply the CSP rules below.
SDK API
type TrackerState = "loading" | "live" | "waiting" | "stale" | "error" | "ended" | "destroyed";
function mountTracker(target: HTMLElement, options: {
token: string; // scoped bt_ token, required
grantId?: string; // required unless endpoint is set
endpoint?: string; // e.g. https://footballapi.net/api/ball-tracker/native/GRANT_ID
apiBase?: string; // API origin when using grantId; defaults to the SDK host
nonce?: string; // CSP nonce for the injected <style>
reducedMotion?: boolean; // defaults to prefers-reduced-motion
label?: string; // accessible region name, default "Live ball tracker"
onStateChange?: (state: TrackerState) => void;
}): { readonly state: TrackerState; refresh(): void; destroy(): void };| Option | Notes |
|---|---|
token | Required. Must start with bt_. Sent only in the Authorization header. |
grantId | Grant id from your dashboard. The SDK calls /api/ball-tracker/native/:id on its own host. |
endpoint | Use instead of grantId to pass the full native base URL. HTTPS only (http://localhost allowed). |
apiBase | Optional API origin used with grantId. Defaults to the SDK host; ignored when endpoint is supplied. Not an API path. |
nonce | Copied to the one <style> element the SDK adds to <head>. |
reducedMotion | When true, the ball jumps to new positions without animating. |
label | Accessible widget-region name; defaults to Live ball tracker. |
onStateChange | loading, live, waiting (no ball yet), stale (ball older than 30 s), error (retrying), ended (expired or revoked), destroyed. |
Mount any number of widgets on one page. Each instance uses unique SVG ids, so gradients and clips never collide.
Use one SDK instance per target DIV. handle.state reads the current state; refresh() requests a snapshot immediately when active, but does nothing while paused, ended, destroyed or already fetching. It does not renew a grant and a successful manual refresh is billable. destroy() is safe to call more than once. Invalid target elements, missing tokens and invalid endpoint options throw synchronously.
The widget follows its container width and grows vertically for match data. Size your wrapper with your own CSS rather than a fixed height that clips the event panel. There are no SDK width, height, polling-interval or branding options. Branding comes from the grant. Reduced motion follows the visitor’s system preference unless explicitly overridden; its live status is announced through an accessible status region.
HTTP and snapshot format
The SDK manages these calls; ordinary installations do not need to implement them. Both require the grant’s scoped bearer token and the configured browser origin. Requests use HTTPS GET; CORS preflights use OPTIONS. Grant creation, branding changes and revocation belong to the signed-in dashboard, not these read-only browser routes.
| Route | Successful response |
|---|---|
GET /api/ball-tracker/native/:id/config | grantId, matchId, branding, pollSeconds, expiresAt at the top level. Free; still checks token, origin, key, account and paid access. |
GET /api/ball-tracker/native/:id/snapshot | data contains the normalized snapshot, meta contains live delivery information, and branding is top-level. A successful delivery counts against the linked API key’s account. |
// Illustrative structure only — not a live fixture or usable grant.
// GET https://footballapi.net/api/ball-tracker/native/GRANT_ID/snapshot
// Authorization: Bearer bt_YOUR_SCOPED_TOKEN
{
"data": {
"matchId": "1234567",
"home": { "name": "Home team", "score": 1 },
"away": { "name": "Away team", "score": 0 },
"period": "2",
"minute": "63",
"state": "Corner to the home team",
"ball": { "x": 96, "y": 3, "updatedAt": "2026-11-01T15:03:00.000Z" },
"updatedAt": "2026-11-01T15:03:00.000Z",
"statistics": {
"goals": [1, 0], "corners": [4, 2],
"possession": [56, 44], "throwIns": [null, 12]
},
"events": [
{ "id": "101", "minute": "63", "text": "Corner to the home team" }
],
"stateCode": null
},
"meta": {
"mode": "live", "pollSeconds": 5,
"receivedAt": "2026-11-01T15:03:00.000Z"
},
"branding": {
"sponsors": [{ "name": "Your brand" }],
"boardColor": "#0e2a21"
}
}Names are strings; scores may be null. Period, minute and state may also be null. Period can be a raw code string; do not assume it is a translated half name. Minute is a string, not a timestamp. Ball coordinates are display percentages (0–100 across and down the pitch), not GPS or metres; do not assume the attacking direction from the score or period. ball.updatedAt is the coordinate timestamp used for freshness, whereas updatedAt can advance after a non-position update and meta.receivedAt records relay ingestion. These are ISO timestamps, not the fixture’s kickoff time.
The normalized model allows ball: null, but the current live snapshot route returns 503 instead of a successful snapshot when fresh coordinates are absent. It does not bill that failed request. Optional statistics are therefore not a separate, always-available statistics feed through this route. Raw numeric state codes have no published action-code mapping; do not guess one.
Sponsor boards
type TrackerBranding = {
sponsors: Array<{ name: string; logoUrl?: string }>; // 1 to 6 entries
boardColor: string; // "#RRGGBB"
};- 1 to 6 sponsors; names up to 80 characters. Boards cycle through them in order.
- A
logoUrlmust be anhttps://image URL. Omit it to draw the name as text. If an image fails to load, the name is drawn instead. boardColoris a#RRGGBBhex colour. Text colour adjusts for contrast automatically.- Default boards show FootballAPI.net and Bettor Serve. The Bettor Serve wordmark loads from our SDK host, not from your site.
- Use logos you own or have permission to display. Prefer optimized, wide transparent SVG, PNG or WebP artwork, with enough contrast against the selected board colour.
- Edit branding for any active grant in the dashboard and press Save. Running widgets pick up changes at the next configuration refresh, within 60 seconds.
- No upload is needed: host logos on your own HTTPS CDN. Wide, transparent SVG or WebP logos read best.
Use the palette/edit action on an active grant, then Save. Existing grants can update sponsor boards without a new token; the match, origin, key and expiry are not editable there. For those changes, create a new grant and replace the snippet. Editing one grant does not change your other grants or the public demo. Keep logo URLs publicly readable without login, avoid expiring signed links and check them from the customer website, not only from your own browser session.
Origin, CSP and CORS
The browser Origin must exactly equal the grant origin: scheme, host and port. https://www.example.com and https://example.com are different origins; create one grant for each.
| Resource | Requirement |
|---|---|
/tracker/widget.js | Public ES module with Access-Control-Allow-Origin: *. Allow its host in script-src. Do not proxy it to an HTML page or rewrite it as a classic script. |
/api/ball-tracker/native/:id/* | CORS allowed only for the grant origin. Cross-origin requests send no cookies. Same-origin previews may send their access cookies; tracker authorization always requires the scoped token. Allow the API host in connect-src. |
| Sponsor logos | Allow each logo host in img-src. Logos display in SVG without CORS headers; the 3D preview on footballapi.net needs CORS to draw them, otherwise it uses text. |
| Styles | One <style> element. With a strict style-src, pass the same nonce your page uses. |
script-src 'self' https://footballapi.net 'nonce-RANDOM';
connect-src 'self' https://footballapi.net;
img-src 'self' https://footballapi.net https://cdn.your-sponsor.example;
style-src 'self' 'nonce-RANDOM';This is an example of relevant directives, not a replacement for your whole security policy. Replace RANDOM with a fresh, server-generated nonce for each HTML response. Put that same value on your installation <script nonce="…" type="module"> and pass nonce: "…" to mountTracker so its injected stylesheet is permitted. The nonce does not authorize API requests. Preserve your existing policy, include the SDK host for stadium assets in img-src, and add your actual logo hosts instead of the example CDN.
The browser sets Origin; do not try to override it in JavaScript or use mode: "no-cors". File URLs, CMS preview frames and staging subdomains do not share your production origin. Test on the exact registered website. The SDK polls HTTPS; your site does not need a WebSocket library or a browser proxy.
Sites enforcing require-trusted-types-for 'script' are not currently supported: the SDK inserts escaped SVG markup. Do not weaken an existing Trusted Types policy to install the widget.
Tokens, expiry and revocation
- The bt_ token is scoped to one grant: one match, one origin, one key. It cannot call other endpoints and is not your API key.
- It is shown once. We store only a hash. If you lose it, revoke the grant and create a new one.
- Tokens expire 1 to 24 hours after creation. There is no automatic renewal; create a new grant and update your page for the next match.
- Revoking a grant, revoking its API key or losing paid access stops the widget on its next request. The widget then shows that it has ended and stops polling.
- The native SDK does not persist the token or put it in cookies, localStorage, URLs or logs. Your installation snippet still contains it, and a visitor with developer tools can inspect it. Origin binding is a browser restriction, not proof that a caller owns the site; a non-browser caller can spoof Origin. Keep this scoped token short-lived, never substitute an account API key, and revoke compromised grants.
Polling and usage costs
- Configuration requests (on mount and every 60 seconds while active) are free. The normal snapshot interval is 5 seconds after the previous request completes, not a guaranteed real-time update frequency.
- Each successful snapshot request counts against the linked account's daily allowance, shared with its other API calls and keys. Extra successful requests cost €0.002 each, added to the subscription invoice in EUR. Failed requests are not billed.
- Growth includes 25,000 requests per day; Pro includes 250,000. Daily allowance counters reset at 00:00 UTC. An allowance is not an automatic overage-spending cap; monitor usage in your dashboard.
- Each visitor, browser tab and widget instance polls independently, including when they share a grant. A successful request still counts if the score has not changed. Browser retries or manual refreshes that succeed also count.
- Polling pauses while the tab is hidden. With IntersectionObserver support it also pauses when the widget is more than 120 pixels outside the viewport, resuming when it is near or in view. An initial load may occur before visibility is determined; a request already in flight may finish after the widget is hidden.
- Example: one continuously viewed widget for 90 minutes makes roughly 1,080 successful snapshots at a five-second cadence. If every one is above the daily allowance, that is about €2.16. One hundred such viewers would make roughly 108,000 snapshots (€216 if all are overage). Actual totals depend on visibility, response time, retries and manual refreshes.
- The public simulated demo is free and does not consume API allowance. Live poll billing is separate from whether the demo is running.
Stale and error states
- A ball position older than 30 seconds is hidden and labelled as not updated.
- Network errors, invalid responses, HTTP 429 and server errors hide the ball, keep the last received score and any previously received match data, mark the widget Offline, and retry on its polling schedule. Old statistics or commentary are not new live updates. Nothing is simulated.
- HTTP 401, 402, 403, 404 or 410 ends that SDK instance and stops polling. A missing stream match (404) does not automatically retry when the match later appears; correct the cause and remount the tracker.
- Your original fallback stays visible until data has been received. After a successful snapshot it is hidden; a later temporary outage keeps the last score rather than automatically revealing that fallback. Ended instances show the fallback again. destroy() removes SDK markup and restores your original children.
Troubleshooting
| Symptom or HTTP status | Check and action |
|---|---|
| Module fails to load | Use the exact /tracker/widget.js URL as an ES module. Its response must be JavaScript, not a login screen or your CMS HTML. Check CSP, script sanitisation, optimisation plugins and internet access. Leave your fallback visible. |
| 401 — invalid token | Use the complete token from this grant, not an API key or another grant's token. Tokens cannot be retrieved later; revoke and recreate the grant if lost. Remount with the new values. |
| 403 or browser CORS error | Match the registered scheme, hostname and port, including www. Also check API key revocation, suspension and Growth/Pro access. Browsers may hide the response details when an origin is denied. Do not disable browser security. |
| 404 — missing match or invalid route | Check the grant ID, native base URL and stream match ID. No tracking record may have arrived for that stream. An ordinary fixture ID is not interchangeable. Fix the cause and remount; this SDK instance has stopped. |
| 410 — expired, revoked or removed | Create a new grant, replace both grant ID and token, clear token-bearing page caches and remount. There is no automatic token renewal. |
| 429 — rate limited | Reduce duplicate widgets, tabs and manual refreshes. The SDK retries at its configured cadence; it does not implement Retry-After-based backoff. Custom clients should respect any Retry-After header and your account's request-rate limits. |
| 503 — live service or position unavailable | Check live-feed activation and whether fresh coordinates exist for this stream. A running demo or a listed match is not proof of live readiness. The SDK retries; failed requests are not billed. |
| Offline, network timeout or 5xx | Requests time out after 10 seconds. Check network, CSP connect-src and service availability. The SDK retries on schedule and never substitutes the demonstration. |
| Logo missing or shown as text | Test a public HTTPS image URL without login, with a valid image response and permission from your img-src policy. Expired links, hotlink protection and failed downloads trigger text fallback. Check logo CORS separately for the site's 3D preview. |
| Blank, cramped or clipped widget | Confirm the DIV exists before mounting, has available width and is not clipped by a fixed-height or overflow-hidden wrapper. Avoid mounting twice into one element or having framework renders replace SDK-owned children. |
| No events, player name or a dash in statistics | Coverage varies. Missing values are not zero. The native widget shows only supplied statistics/commentary; named-player possession is not supported. Do not infer it from the last event. |
In browser developer tools, inspect the module, /config and /snapshot requests. Do not share request headers, copied cURL commands, full HAR files or screenshots containing tokens. Support only needs the grant ID, website origin, UTC time, HTTP status and a redacted description.
Customer launch checklist
- Confirm Growth/Pro access, service activation and a selected stream with fresh coordinates. A successful configuration request alone does not verify the live feed.
- Check the exact production origin, correct key, expiry long enough for the event, and an agreed process to replace short-lived grants for later matches.
- Confirm the module is JavaScript, configuration loads and a real snapshot succeeds. Check the score, coordinate freshness and coverage against that match; do not treat the fictional demo as this test.
- Check desktop and mobile layout, readable sponsor logos, missing-data labels, fallback content and reduced-motion behavior.
- Verify your expected audience cost, monitor account usage, and remove duplicate widget instances. Check that route changes destroy old instances.
- Test revocation on a disposable grant, replace any test credentials, clear stale page caches, and keep account API keys out of all browser code.
SEO and fallback HTML
Anything you put inside the <div> before mounting is kept in the DOM as fallback content. Put an indexable summary there from your server, such as the fixture, teams and a link to your match report.
A DIV alone does not guarantee SEO
Support
Questions about grants, branding or CSP: email support@footballapi.net with your grant id. Never send the bt_ token or your API key.