
Last night, during the Wrexham vs Southampton match, our vidiprinter confidently announced that Jack Stephens had picked up a second yellow card and been sent off. Except he hadn't. He'd only been booked once. That kind of bug erodes trust in a live broadcast platform fast, so I wanted to track it down before the next set of fixtures.
The Investigation
The obvious starting point was: where does our code decide what kind of card a player received?
We process SportMonks match events in three places β the REST API route (page load), the WebSocket hook (real-time updates), and the ad hijack component (contextual advertising triggers). Each one checks the event type_id field to classify goals, cards, and other incidents.
Here's what I found in our code:
// What our code said:
const isSecondYellow = typeId === 19 && additionLower.includes("2nd yellowcard");
const isCard = typeId === 20 || typeId === 21 || isSecondYellow;
Three problems in two lines.
What Was Wrong
1. Type IDs 20 and 21 Were Swapped
Our code treated type_id 20 as "Yellow/Red" (second yellow) and type_id 21 as "Straight Red". The actual SportMonks mapping from their docs:
- 20 = REDCARD (straight red ejection)
- 21 = YELLOWREDCARD (second yellow β sending off)
These were backwards. A second yellow would have displayed without the "(2nd π‘)" label, and a straight red would have been misclassified internally.
2. The Fragile addition Field Check
This was the actual cause of the Jack Stephens bug. The code checked whether event.addition contained the text "2nd yellowcard" on any type_id 19 (yellow card) event and promoted it to a red card. SportMonks uses the addition field for contextual text, not type classification β it's metadata, not a type override. When they put descriptive text like "2nd Yellowcard" into the addition field of a regular booking, our code treated it as a sending off.
The fix: type_id 19 is always a yellow card. Period. If SportMonks means a second yellow, they use type_id 21.
3. Penalty Shootout IDs Were Also Wrong
While auditing, I discovered our penalty shootout type_ids were incorrect too:
- Our code: 24 = scored, 25 = missed
- SportMonks reality: 23 = PENALTY_SHOOTOUT_GOAL, 22 = PENALTY_SHOOTOUT_MISS
We haven't had a live penalty shootout yet (the penalties include isn't even in the API call), but this would have been a nasty surprise when it happened.
The Fix Across the Pipeline
Four files needed updating to make the entire event pipeline consistent:
Backend (live_delta_poller/index.py): Replaced the old string-based SIGNIFICANT_EVENT_TYPES set (which was defined but never actually used for filtering) with an integer-based SIGNIFICANT_TYPE_IDS set using the correct SportMonks type_ids.
REST API route (live-ticker/route.ts): Fixed the type_id mapping β 20 = REDCARD, 21 = YELLOWREDCARD. Removed the addition field text check entirely. Updated penalty shootout IDs to 22/23.
WebSocket hook (use-live-ticker.ts): Same type_id fixes, plus added event ID deduplication β the applyEvent() function now checks if an event with the same SportMonks ID already exists before appending. This prevents duplicate entries if the same event arrives via multiple WebSocket deliveries.
Ad hijack component (broadcast-ads.tsx): This was a bonus find β the component was only listening for the legacy NEW_EVENT delta type, but the poller now sends COMPOSITE_UPDATE. Goal and red card ad hijacks would never have triggered via WebSocket. Fixed to extract events from both formats.
What I Learned
The root cause wasn't complex β it was a mapping that was assumed to be correct but never verified against the API documentation. SportMonks has a dedicated page listing all event type_ids. We've now added the full reference table to our CLAUDE.md so that any future work on event handling starts from the authoritative source.
The broader lesson: when you're building on a third-party API, document the contract in your own codebase. Don't rely on comments like // 20=Yellow/Red β link to the source of truth.
What's Next
The event handling is now clean across the full pipeline. The next live matchday should show cards correctly. The penalty shootout fix is ready for when we add the penalties include to the API call.
Phase 3 (WebGL Digital Humans) is the next major milestone β building browser-rendered 3D avatars driven by the showrunner scripts.

