Handling Postponed and Abandoned Matches in Your Data Layer - Sportmonks
ContentsTHE SHORT ANSWERPostponed and abandoned matches are a persistent data problem for any football application. A fixture enters your database with a scheduled kick-off and a state of `NS` (Not Started). . . .

ContentsTHE SHORT ANSWERPostponed and abandoned matches are a persistent data problem for any football application. A fixture enters your database with a scheduled kick-off and a state of `NS` (Not Started). When the match is postponed, the state changes to `POST`. When it is abandoned mid-game, it changes to `ABAN`. Your app must detect these transitions and respond correctly: remove the fixture from live displays, retain the record in your database with the updated state, and monitor the `starting_at` field for a rescheduled date. In the Sportmonks API, every fixture exposes a `state` object with a short code and a human-readable `developer_name`. Polling that field is the basis of any reliable state-change detection strategy. WHY POSTPONED AND ABANDONED MATCHES ARE A DATA PROBLEMA football fixture does not always do what it says on the tin. Bad weather cancels a match. A floodlight failure at 72 minutes forces abandonment. A crowd disturbance triggers suspension. A club withdraws from a competition. Each of these outcomes breaks the assumption your app almost certainly made when you first ingested the fixture: that it would be played to completion at the scheduled time. The downstream effects in your data layer are predictable. Your live score component shows a match that is not happening. Your notification system fires a “kick-off” alert for a game that has been postponed for three weeks. Your statistics aggregator tries to count an abandoned match in final standings. Your user sees inconsistent data and loses trust in your product. The root cause is almost always the same: the application treated fixture state as a one-time write rather than a value that transitions through a defined lifecycle. A fixture is not a static record. It is a state machine. Understanding the full state machine, knowing which transitions are possible, and writing explicit handling code for the abnormal states is the difference between an app that breaks during a storm and one that your users do not even notice had a problem. THE MATCH STATE MACHINEEvery fixture in the Sportmonks API has a current state accessible via the `state` include. The state transitions follow a predictable logic with a handful of abnormal branches. The diagram below maps the full state machine. Normal flow runs down the left. Abnormal exits branch to the right. The “problem” states your application must explicitly handle are `POST`, `SUSP`, `CANC`, `ABAN`, `TBD`, `DELAYED`, `INT`, and `NS_DD`. Normal states (`NS` through `FT`/`AET`) can often be handled with a single code path. The abnormal states each need a deliberate response. STATE REFERENCE TABLEThis table covers every match state in the Sportmonks API. The “Your app’s action” column is a recommended default: your specific product may need adjustments. DETECTING STATE CHANGES: POLLING THE FIXTURES ENDPOINTThe Sportmonks fixture state is available by including `state` in your fixtures request:GET https://api. sportmonks. com/v3/football/fixtures/18535517?include=state Authorization: Bearer YOUR_API_TOKENThe response `state` object returns two fields:{ "data": { "id": 18535517, "name": "Arsenal vs Chelsea", "starting_at": "2026-06-23 19:45:00", "state": { "id": 5, "state": "INPLAY_1ST", "developer_name": "In play, first half", "name": "1st Half", "short_name": "1H", "count_time": true } } }`state. state` is the short code you use in your logic. `state. developer_name` is the human-readable string for logging, debugging, and display. To detect a state change, compare the current `state. state` value against the last value you stored:async function checkFixtureState(fixtureId, lastKnownState) { const response = await fetch( `https://api. sportmonks. com/v3/football/fixtures/${fixtureId}?include=state`, { headers: { 'Authorization': `Bearer ${process. env. SPORTMONKS_API_TOKEN}` } } ); const data = await response. json(); const fixture = data. data; const currentState = fixture. state?. state; if (currentState !== lastKnownState) { return { changed: true, previousState: lastKnownState, currentState: currentState, fixture: fixture }; } return { changed: false, currentState }; }You call this on your polling interval. When `changed` is true, your handler fires. HANDLING A POSTPONED MATCH: CODE EXAMPLEThe most common abnormal state you will encounter is `POST`. Here is a complete JavaScript handler that responds to a postponement correctly:const TERMINAL_STATES = ['FT', 'AET', 'CANC', 'ABAN', 'WO', 'AWARDED']; const POSTPONED_STATES = ['POST']; const LIVE_STATES = ['INPLAY_1ST', 'INPLAY_2ND', 'ET', 'Penalties']; const BREAK_STATES = ['HT', 'PEN_BREAK']; const SUSPENDED_STATES = ['SUSP', 'INT']; class FixtureStateManager { constructor(db, notifier) { this. db = db; // Your database abstraction this. notifier = notifier; // Your notification/display layer } async handleStateTransition(fixtureId, previousState, currentState, fixture) { console. log(`[${fixtureId}] ${previousState} → ${currentState}`); // Always persist the new state - never discard state changes await this. db. updateFixtureState(fixtureId, currentState, { state_changed_at: new Date(). toISOString(), previous_state: previousState }); if (POSTPONED_STATES. includes(currentState)) { await this. handlePostponed(fixtureId, fixture); } else if (currentState === 'ABAN') { await this. handleAbandoned(fixtureId, fixture); } else if (currentState === 'CANC') { await this. handleCancelled(fixtureId, fixture); } else if (SUSPENDED_STATES. includes(currentState)) { await this. handleSuspended(fixtureId, fixture); } else if (TERMINAL_STATES. includes(currentState)) { await this. handleCompleted(fixtureId, fixture); } else if (LIVE_STATES. includes(currentState)) { await this. handleLive(fixtureId, fixture); } } async handlePostponed(fixtureId, fixture) { // 1. Remove from live and upcoming display queues await this. notifier. removeFromLiveFeed(fixtureId); await this. notifier. removeFromUpcoming(fixtureId); // 2. Update UI to show postponed status await this. notifier. showPostponedBadge(fixtureId, { message: 'Match postponed', newDate: fixture. starting_at // May be null until rescheduled }); // 3. Flag for rescheduling monitor - do NOT delete the record await this. db. flagForReschedulingMonitor(fixtureId, { postponed_at: new Date(). toISOString(), original_date: fixture. starting_at, monitoring: true }); // 4. Notify users who have the match in their watchlist await this. notifier. pushToSubscribers(fixtureId, { type: 'postponement', title: `${fixture. name} postponed`, body: 'This match has been postponed. We will update the new date when confirmed. ' }); console. log(`[${fixtureId}] Postponed. Monitoring for reschedule. `); } async handleAbandoned(fixtureId, fixture) { // 1. Remove from live feed immediately await this. notifier. removeFromLiveFeed(fixtureId); // 2. Retain record with abandoned state and partial data warning await this. db. markAsAbandoned(fixtureId, { abandoned_at: new Date(). toISOString(), data_quality: 'partial', stats_reliable: false }); // 3. Mark statistics as partial - do not include in league aggregations await this. db. flagStatsAsPartial(fixtureId); await this. notifier. pushToSubscribers(fixtureId, { type: 'abandonment', title: `${fixture. name} abandoned`, body: 'This match has been abandoned. Statistics may be incomplete. ' }); console. log(`[${fixtureId}] Abandoned. Stats flagged as partial. `); } async handleSuspended(fixtureId, fixture) { // Keep in live feed but show suspended status await this. notifier. showSuspendedBadge(fixtureId); // Extend polling interval to every 2 minutes during suspension await this. db. updatePollingInterval(fixtureId, 120000); console. log(`[${fixtureId}] Suspended. Polling every 2 minutes. `); } async handleCompleted(fixtureId, fixture) { await this. notifier. moveToCompleted(fixtureId); await this. db. stopPolling(fixtureId); console. log(`[${fixtureId}] Completed. Polling stopped. `); } async handleLive(fixtureId, fixture) { await this. notifier. addToLiveFeed(fixtureId, fixture); console. log(`[${fixtureId}] Live. `); } }The key discipline here: **never delete a postponed fixture record**. The fixture ID stays the same when the match is rescheduled. If you delete and re-insert, you lose the continuity. Store the state change with a timestamp. Monitor for the new date. HANDLING RESCHEDULED MATCHESWhen a postponed match receives a new date, the Sportmonks API updates the `starting_at` field on the fixture. The state will typically change from `POST` back to `NS` (or `TBD` if the exact time is not yet confirmed). Your rescheduling monitor needs to poll postponed fixtures at a low frequency (once or twice per day is enough) and check two conditions:1. Has the state changed away from `POST`?2. Has `starting_at` changed from null or from the original date?async function monitorForReschedule(fixtureId) { const response = await fetch( `https://api. sportmonks. com/v3/football/fixtures/${fixtureId}?include=state`, { headers: { 'Authorization': `Bearer ${process. env. SPORTMONKS_API_TOKEN}` } } ); const data = await response. json(); const fixture = data. data; const currentState = fixture. state?. state; const newDate = fixture. starting_at; // Retrieve what we stored when it was first postponed const storedRecord = await db. getFixture(fixtureId); const wasRescheduled = currentState !== 'POST' || (newDate && newDate !== storedRecord. original_date); if (wasRescheduled) { console. log(`[${fixtureId}] Rescheduled to: ${newDate}, state: ${currentState}`); // Update stored record await db. updateFixture(fixtureId, { state: currentState, starting_at: newDate, rescheduled_at: new Date(). toISOString(), monitoring: false }); // Re-add to upcoming fixtures feed await notifier. addToUpcoming(fixtureId, { newDate: newDate, message: `Rescheduled: ${fixture. name} now on ${newDate}` }); // Notify subscribers await notifier. pushToSubscribers(fixtureId, { type: 'reschedule', title: `${fixture. name} rescheduled`, body: `This match has been rescheduled to ${new Date(newDate). toLocaleDateString()}. ` }); } return { rescheduled: wasRescheduled, fixture }; }Run this function on a daily cron job for each fixture ID in your `monitoring: true` pool. When `wasRescheduled` returns true, the fixture rejoins your normal upcoming fixtures pipeline. DATA LAYER BEST PRACTICESSeveral patterns consistently cause problems when teams build without thinking through abnormal match states. Here is what to do instead. Keep the fixture ID consistent. The Sportmonks fixture ID for a postponed match does not change when the match is rescheduled. Do not create a new database record for the rescheduled match. Update the existing one. This keeps your historical state trail intact and prevents duplicate fixture IDs in your system. Store state transitions with timestamps. A field for the current state is not enough. Keep a state history: `{state: “POST”, changed_at: “2026-06-12T17:30:00Z”}`. This lets you answer questions later – “when was this match postponed?”, “how many matches were abandoned in the 2025/26 season?” – without re-querying the API. Flag abnormal states in your statistics tables. If your app aggregates season statistics, goals, assists, or player ratings, you need a column indicating whether the source fixture has a status of `ABAN`, `WO`, or `AWARDED`. Statistics from an abandoned match are partial. A 72-minute goal tally is not the same as a full-match tally. Your aggregation queries should exclude abandoned fixtures unless you specifically want partial data. Do not remove postponed records from your fixture table. Mark them with `state = ‘POST’` and a `monitoring` flag. Your upcoming fixtures query simply filters to `state NOT IN (‘POST’, ‘CANC’)`. The record stays. When the new date arrives, you update in place. If you delete and re-fetch, you lose state history and your users lose any watchlist associations tied to the original fixture ID. Handle `TBD` and `NS_DD` separately from `NS`A fixture with `TBD` or `NS_DD` state has no confirmed time. Displaying it in a time-sorted upcoming fixtures list will produce incorrect orderings. Maintain a separate “unscheduled” bucket or a null-safe sort that puts undated fixtures at the bottom. Apply retry logic on state-fetch requests. During high-traffic match windows (20 simultaneous fixtures on a Sunday afternoon), your polling calls may encounter transient 429 or 503 responses. Exponential backoff with a cap prevents your app from hammering the API during brief rate limit windows. A single failed poll is not a state change. Only trigger state transition logic when you have a confirmed response. WHAT HAPPENS TO STATISTICS FOR ABANDONED MATCHESWhen a match is abandoned, Sportmonks retains whatever data was collected up to the point of abandonment. This means you may receive goals, cards, substitutions, and player statistics covering only a portion of the match. The data is not wrong, it is incomplete. The implications for your data layer depend on how you use match statistics. Goal counts and scorelinesfrom an abandoned match reflect only the goals scored before abandonment. A match abandoned at 72 minutes with the score at 2-1 will show those goals in the API. Do not include this in “clean sheet” calculations, “most goals scored” tables, or any metric that assumes a full 90 minutes. Player ratings and performance metricsfrom an abandoned match are based on a truncated sample. A player rated on 23 minutes of data is not comparable to a player rated on 90 minutes. If you surface per-player statistics aggregated across a season, flag the abandoned match contribution or exclude it. Appearance countsare the most contested. Some competitions count an appearance if the player was named and the match started; others require a minimum number of minutes. Check the rules for the specific league your app covers. The Sportmonks `lineups` include will show you the players who were on the field at the time of abandonment. Result-awarded fixtures(`WO` and `AWARDED` states) may appear with a 3-0 score or equivalent result entered by the competition organiser. These are administrative results, not match statistics. Treat them differently to played results in any aggregation that uses match score or event data. The consistent rule across all abnormal states: store a `data_quality` flag on the fixture record. Values like `complete`, `partial`, `awarded`, and `walkover` give your analytics layer the context to filter appropriately without needing to re-inspect state codes in every query. What is the difference between a postponed and a cancelled match in the Sportmonks API?A postponed match (`POST`) is expected to be rescheduled and played at a later date. The fixture ID remains in the API, the `starting_at` field will be updated when a new date is confirmed, and the state will transition back to `NS` once the date is set. A cancelled match (`CANC`) will not be played. The fixture record stays in the API with the `CANC` state, but you should not expect a `starting_at` update or a return to `NS`. Treat `POST` as "monitor for new date" and `CANC` as "archive and stop checking. "How do I detect that a postponed match has been rescheduled?Poll the fixture endpoint for all fixtures in `POST` state at a low frequency (once or twice per day is enough). Check two conditions: whether the state has changed from `POST` to `NS` or `TBD`, and whether the `starting_at` value has changed from null or from the original date. When either condition is true, update your stored record, remove the monitoring flag, and re-add the fixture to your upcoming fixtures pipeline. The fixture ID does not change, so your existing records and user watchlists remain intact. Should I stop polling when a match enters SUSP or INT state?No. `SUSP` (Suspended) and `INT` (Interrupted) are temporary mid-game states. The match may resume. Extend your polling interval to every 2 to 5 minutes to reduce request volume, but keep polling. Monitor for the state to transition back to `INPLAY_1ST` or `INPLAY_2ND` (if resumed) or forward to `ABAN` or `FT` (if called off or completed in unusual circumstances). Only stop polling when you see a terminal state: `FT`, `AET`, `ABAN`, `CANC`, `WO`, or `AWARDED`. How should I handle match statistics from abandoned matches?Treat statistics from abandoned matches as partial data. Store a `data_quality: 'partial'` flag on the fixture record and propagate it to any aggregated statistics that include events from that match. Do not include abandoned match data in season totals, clean sheet calculations, or performance ratings without filtering or flagging. The Sportmonks API will retain whatever events were recorded before abandonment; the data is accurate for the portion of the match played, but it is not a complete 90-minute dataset.