Skip to content
MMA Calendar

UFC & MMA Blog · Explainers

UFC API in JavaScript & TypeScript: a fight-week dashboard

Build a fight-week dashboard in Node or TypeScript with the MMA Fight Data API: next UFC card, records, round stats, rankings history and webhooks.

Author
Mate Bersenadze
Published
Oct 9, 2026
Read time
12 min read
Tags
UFCFight ApiJavascriptTypescriptUFC StatsRankingsBrendan Allen
Post on X

The Python crowd got their tutorial months ago. This one is for everybody else: the people building a Discord bot in Node, a Next.js fight-week page, a Raycast extension, a Slack command that answers "when does the main card start?" without anyone opening a browser.

The problem has not changed. The UFC does not publish a public API. What exists is an undocumented feed behind ufc.com that moves whenever the site is rebuilt, and a statistics site that a few hundred npm packages scrape until the day a table gains a column. So this guide uses the MMA Fight Data API from UFCalendar instead: one REST API over UFC, PFL, OKTAGON, BKFC and RIZIN, with an official TypeScript client. By the end you will have a small fight-week dashboard that prints the next UFC card in any time zone, the main event's records and career rates, a finished bout round by round, a fighter's ranking history, and a webhook that tells you when the card changes.

Every output below is real API data, captured on 9 October 2026, the week of UFC Fight Night: Allen vs Duncan.

Step 0: a key and two lines of setup

Sign in at ufcalendar.com/account/api and start the trial. You get a working key on the spot: one day, 100 requests, every endpoint, no card. After that, plans start at $19 a month for 30,000 requests (Pro is $49 for 200,000, Business $149 for 1,000,000), and the quotas are hard caps, so a runaway loop gets a 429 instead of an invoice.

npm install @ufcalendar/sdk
export UFCALENDAR_API_KEY=ufcalendar_...

You need Node 20 or newer. The snippets use top-level await, so either save them as .mts files and run them with npx tsx file.mts, or set "type": "module" in your package.json.

One rule before any code: keep the key on the server. The API is happy to answer a browser, but a key in client-side JavaScript is a key in everyone's DevTools. In Next.js, call it from a Route Handler or a Server Component and send your page only the fields it renders.

Step 1: plain fetch first, so nothing is magic

The whole API is HTTPS with a bearer header. Every JSON response is the same envelope, { data, meta }, and every error is { error: { code, message, request_id } }. Here is the entire client in a dozen lines:

const API = 'https://api.ufcalendar.com/v1';

async function get(path, params = {}) {
  const url = new URL(`${API}/${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.UFCALENDAR_API_KEY}` },
  });
  const body = await res.json();
  if (!res.ok) {
    const e = body.error ?? {};
    throw new Error(`${res.status} ${e.code}: ${e.message} (request ${e.request_id})`);
  }
  return body; // always { data, meta }
}

const { data: events } = await get('events', { org: 'ufc', limit: 3 });
for (const ev of events) console.log(ev.starts_at, ev.title, `[${ev.tz}]`);
2026-10-10T21:00:00.000Z UFC Fight Night: Allen vs Duncan [America/Los_Angeles]
2026-10-13T23:00:00.000Z Dana White's Contender Series 96 [America/Los_Angeles]
2026-10-17T21:00:00.000Z UFC Fight Night: Buckley vs Malott [America/Edmonton]

A bare /v1/events?org=ufc is the upcoming calendar, soonest first. All times are UTC; tz is the venue's IANA time zone. Lists carry meta.pagination.next_cursor, and you pass it back as ?cursor= until it comes back null. That is everything the SDK does for you, so from here on I will use the SDK.

Step 2: the next card, in anyone's time zone

The SDK's list methods are async generators that follow the cursor for you, and limit stops them after N rows, so a one-row "what's next" costs exactly one request. include: ['eta'] adds an estimated walkout time to every bout, re-anchored as the night runs.

import { FightAPI, type EventSummary } from '@ufcalendar/sdk';

const api = new FightAPI(); // reads UFCALENDAR_API_KEY from the environment

const when = (iso: string | null | undefined, timeZone: string) =>
  iso
    ? new Intl.DateTimeFormat('en-US', {
        weekday: 'short', hour: 'numeric', minute: '2-digit', timeZone, timeZoneName: 'short',
      }).format(new Date(iso))
    : 'TBA';

let next: EventSummary | undefined;
for await (const ev of api.events({ org: 'ufc', limit: 1 })) next = ev;
if (!next) throw new Error('Nothing booked');

const card = await api.event(next.slug, { include: ['eta'] });
const tz = card.tz ?? 'UTC'; // venue time zone; swap in your user's

console.log(`${card.title} - ${card.venue?.name}, ${card.venue?.city}`);
console.log(`Prelims ${when(card.prelims_at, tz)} | Main card ${when(card.main_card_at, tz)}\n`);
for (const f of card.card) {
  if (f.status === 'cancelled') continue;
  const rounds = f.scheduled_rounds === 5 ? ', 5 rds' : '';
  console.log(`${when(f.eta, tz).padEnd(15)} ${f.fighter_a?.name} vs ${f.fighter_b?.name} (${f.weight_class}${rounds})`);
}
UFC Fight Night: Allen vs Duncan - Meta Apex, Las Vegas
Prelims Sat, 2:00 PM PDT | Main card Sat, 5:00 PM PDT

Sat, 6:48 PM PDT Brendan Allen vs Christian Leroy Duncan (Middleweight, 5 rds)
Sat, 6:21 PM PDT Matheus Camilo vs Jai Herbert (Lightweight)
Sat, 5:54 PM PDT Loopy Godinez vs Ketlen Souza (Women's Strawweight)
Sat, 5:27 PM PDT Andre Fili vs Kai Kamaka III (Featherweight)
Sat, 5:00 PM PDT Malcolm Wellmaker vs Otari Tanzilovi (Bantamweight)
Sat, 4:42 PM PDT Julius Walker vs Gerald Meerschaert (Light Heavyweight)
...
Sat, 2:00 PM PDT Ernesta Kareckaite vs Melissa Gatto (Women's Flyweight)

Change tz to 'Europe/London' and the same data prints for a British audience. Two details worth keeping: filter status === 'cancelled' (scratched bouts stay on the card so old links keep working), and treat eta as an estimate, not a schedule. Each bout also carries card_section, is_main, is_title and a division_slug that matches the rankings vocabulary. The schedule guide covers the rest of the event shape.

Step 3: records and career rates for the main event

fighter(slug) returns the bio, a records map (pro_mma is the full professional record, ufc the record inside the promotion) and one stats panel of per-minute rates, plus next_fight and last_fight. Read stats.basis before you quote a rate: it says how many bouts the average covers.

for (const slug of ['brendan-allen', 'christian-leroy-duncan']) {
  const f = await api.fighter(slug);
  const s = f.stats;
  console.log(
    f.name.padEnd(24),
    `pro ${f.records.pro_mma?.value}`.padEnd(12),
    `UFC ${f.records.ufc?.value}`.padEnd(11),
    `SLpM ${s?.slpm}  acc ${s?.str_acc}  TD/15 ${s?.td_avg}  subs/15 ${s?.sub_avg}`,
    `(${s?.basis.bouts} bouts)`,
  );
}
Brendan Allen            pro 27-7-0   UFC 15-4-0  SLpM 3.75  acc 52%  TD/15 1.52  subs/15 1.1 (20 bouts)
Christian Leroy Duncan   pro 15-2-0   UFC 8-2-0   SLpM 4.45  acc 58%  TD/15 0.43  subs/15 0.0 (10 bouts)

The strings are display-ready ("52%"); stats.values holds the same numbers as numbers (0.52) for charts. You can read the main-event story straight off that: Duncan is the busier, more accurate striker, Allen the one with the takedown and submission threat. Brendan Allen's fighter page is built from the same rows.

Step 4: a finished bout, round by round

last_fight gives you the id of a fighter's most recent completed bout. From there, fightRounds(id) returns one row per fighter per round, and fightStats(id) returns the per-fight totals with the head, body and leg split, landed and attempted.

const allen = await api.fighter('brendan-allen');
const id = allen.last_fight!.fight_id;
const bout = await api.fight(id);
const name = (fid: unknown) => (fid === bout.fighter_a?.id ? bout.fighter_a?.name : bout.fighter_b?.name);
console.log(`${bout.fighter_a?.name} vs ${bout.fighter_b?.name}: ${name(bout.result?.winner_fighter_id)} by ${bout.result?.method}, ${bout.bonus}\n`);

const rounds = await api.fightRounds(id);
console.table(rounds.map((r) => ({
  round: r.round,
  fighter: name(r.fighter_id),
  sig: `${r.sig_strikes_landed}/${r.sig_strikes_attempted}`,
  kd: r.knockdowns,
  td: `${r.takedowns_landed}/${r.takedowns_attempted}`,
  ctrl_s: r.control_time_sec,
})));

for (const t of await api.fightStats(id)) {
  console.log(`${name(t.fighter_id)}: head ${t.head_landed}/${t.head_attempted}, body ${t.body_landed}/${t.body_attempted}, leg ${t.leg_landed}/${t.leg_attempted}`);
}
Brendan Allen vs Edmen Shahbazyan: Brendan Allen by Decision - Unanimous, Fight of the Night

┌─────────┬───────┬────────────────────┬─────────┬────┬───────┬────────┐
│ (index) │ round │ fighter            │ sig     │ kd │ td    │ ctrl_s │
├─────────┼───────┼────────────────────┼─────────┼────┼───────┼────────┤
│ 0       │ 1     │ 'Brendan Allen'    │ '20/50' │ 0  │ '0/2' │ 16     │
│ 1       │ 1     │ 'Edmen Shahbazyan' │ '22/38' │ 0  │ '0/0' │ 0      │
│ 2       │ 2     │ 'Brendan Allen'    │ '28/64' │ 0  │ '0/1' │ 2      │
│ 3       │ 2     │ 'Edmen Shahbazyan' │ '32/59' │ 0  │ '0/0' │ 0      │
│ 4       │ 3     │ 'Brendan Allen'    │ '41/80' │ 0  │ '1/1' │ 10     │
│ 5       │ 3     │ 'Edmen Shahbazyan' │ '36/83' │ 0  │ '0/1' │ 8      │
└─────────┴───────┴────────────────────┴─────────┴────┴───────┴────────┘
Brendan Allen: head 61/160, body 10/16, leg 18/18
Edmen Shahbazyan: head 83/171, body 4/4, leg 3/5

This is why round data matters. Shahbazyan out-landed Allen 90 to 89 over the fight and won the head-strike count 83 to 61, yet Allen took a unanimous decision. The answer is in the rows: 28 body and leg strikes to 7, the only takedown of the fight, more than three times the control time, and a 41-strike third round. If you want the official cards next to that, fightScorecards(id) returns each judge's round-by-round score. The stats API guide lists every column.

Two things to know. Join rows on fighter_id, never on position: corner (a or b) follows the bout's corner order. And always map winner_fighter_id through the corners rather than trusting a name; names collide more often than you would think.

Step 5: rankings on any date since 2013

The official UFC rankings are archived point in time: 545 snapshots since 4 February 2013, stored only when the board changed, champion at rank 0. Ask for a date and you get the board that was valid that day. A fighter's full history is one more call; the SDK walks every page for you.

const then = await api.rankings('ufc', { date: '2023-03-01' });
const mw = then.divisions.find((d) => d.division === 'middleweight')!;
console.log(then.snapshot_date, mw.entries.slice(10, 13).map((e) => `#${e.rank} ${e.name}`).join(', '));

const history = (await api.fighterRankings('brendan-allen'))
  .filter((r) => r.board === 'official' && r.division === 'middleweight') as { snapshot_date: string; rank: number }[];
const first = history.at(-1)!; // newest first, so the last row is the debut
const best = Math.min(...history.map((r) => r.rank));
const bestSince = history.filter((r) => r.rank === best).at(-1)!.snapshot_date;
console.log(`First ranked ${first.snapshot_date} at #${first.rank}; best #${best}, first reached ${bestSince}; ${history.length} boards`);
2023-02-27 #10 Dricus Du Plessis, #11 Nassourdine Imavov, #12 Brendan Allen
First ranked 2023-02-27 at #12; best #4, first reached 2026-02-24; 157 boards

Allen entered the middleweight top 15 at #12, one spot behind Imavov and two behind a future champion, and he walks out on Saturday at #4 against the #10. Every entry on the current board also carries movement and is_new against the previous snapshot, which is all you need for the arrows on a rankings widget. The rankings API guide has the JSON.

Step 6: a webhook when the card changes

Cards move all week. Polling for that burns quota; a webhook does not. On Pro plans and up, register an https endpoint once and choose the event kinds you want: event.announced, fight.result, card.changed, event.completed or odds.moved.

const endpoint = await api.createWebhookEndpoint('https://bot.example.com/ufcalendar', ['card.changed', 'fight.result']);
console.log(endpoint.id, endpoint.secret); // the whsec_ secret is shown ONCE; store it now

Each delivery is a POST with X-UFCalendar-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>" keyed with that secret. Verify it against the raw bytes, before you parse anything:

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();
const SECRET = process.env.UFCALENDAR_WEBHOOK_SECRET!;

function verify(header: string | undefined, raw: string): boolean {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; // replay window
  const expected = createHmac('sha256', SECRET).update(`${m[1]}.${raw}`).digest('hex');
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(m[2], 'hex'));
}

app.post('/ufcalendar', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(req.get('x-ufcalendar-signature'), raw)) return res.sendStatus(401);
  const { id, type, data } = JSON.parse(raw); // dedupe on id
  if (type === 'card.changed') console.log(data.event.title, data.change.kind, data.change.before ?? data.change.after);
  res.sendStatus(204);
});

app.listen(3000);

This is a real card.changed from this week, when a featherweight bout came off the UFC 333 main card:

{
  "id": "chg#1200",
  "type": "card.changed",
  "data": {
    "event": { "id": 2530, "slug": "ufc-333-2026-10-24", "title": "UFC 333: Volkanovski vs Evloev", "org": "ufc" },
    "change": {
      "kind": "fight-cancelled",
      "before": {
        "fight_id": 83732, "fighter_a": "Arnold Allen", "fighter_b": "Aaron Pico",
        "fighter_a_id": 27, "fighter_b_id": 576,
        "card_section": "main", "weight_class": "Featherweight"
      },
      "after": null
    },
    "observed_at": "2026-10-07T02:00:44.524Z"
  },
  "sent_at": "..."
}

A failed delivery is retried on the next dispatch tick, so answer with a 2xx fast and do the work afterwards. On the Hobby plan, the same audit trail is a feed you can poll: for await (const c of api.changes({ org: 'ufc', since: '2026-10-01' })) yields every added, cancelled or reinstated bout, swapped opponent and moved start time, newest first.

What else is in the client

The API docs list everything, but four methods earn a mention for a fight-week page. eventWatch(slug, { country: 'GB' }) returns who airs the card in one market. fightScorecards(id) returns the judges' official cards. compare(a, b) puts two fighters side by side, with previous meetings and common opponents. And event(slug, { include: ['odds'] }) adds the UFCalendar consensus line per bout, the average across the sportsbooks we track; it is information only, not betting advice.

Errors throw FightAPIError with .status, .code and .requestId, and api.lastRateLimit.remaining tells you how much of the month is left. The whole dashboard above costs about ten requests a run, so the trial covers several runs while you decide whether the data fits.

If you would rather let an AI assistant do the querying, the same data is a hosted MMA MCP server that Claude, ChatGPT, Cursor or Codex can call directly.

FAQ

Does the UFC offer an official API for JavaScript?

No. The UFC does not publish a public API in any language. The MMA Fight Data API is an independent REST API from UFCalendar that covers UFC data, with an official TypeScript client (@ufcalendar/sdk). UFCalendar is not affiliated with UFC, Zuffa or TKO.

Does the SDK work in Deno, Bun or edge runtimes?

Yes. It is a dependency-free wrapper over fetch, shipped as ESM and CJS, and you can pass your own fetch in the constructor. Keep the key on the server whatever the runtime.

Can I use it without the SDK?

Yes. Every endpoint is plain HTTPS with Authorization: Bearer <key>, as in Step 1. There is an OpenAPI 3.1 document at https://api.ufcalendar.com/openapi.json if you prefer to generate your own types.

What does it cost after the trial?

The trial is one day and 100 requests, with no card. Paid plans are $19 a month for 30,000 requests, $49 for 200,000 (webhooks start here) and $149 for 1,000,000. Every plan covers all five promotions.

Which promotions are covered?

UFC, including Dana White's Contender Series, plus PFL, OKTAGON, BKFC and RIZIN, all under the same schema and the same key. The developer hub has coverage numbers per promotion.

Ask UFCalendar AI