Growing Discord community — direct access to the developer, live coverage & picks. Join the Discord Join now →

# `/events/{id}/stats/`: `shotmap` and `stats` are two different counts of the same match

Feature Request Football Resolved Aug 13, 2026 · g@@ · 🥵 110 aura
measured 2026-08-13 against `sports.bzzoiro.com/api/v2` with our own token. ## Summary For a single event, `stats.{home,away}.total_shots` / `shots_on_target` and the rows in `shotmap[]` do not agree, and neither can be derived from the other. Across **598 team-sides from 299 finished matches**: | Comparison | Sides that disagree | | --------------------------------------------------------------------- | ------------------- | | `total_shots` vs rows in `shotmap` for that side | **87 / 598 (15%)** | | `shots_on_target` vs `shotmap` goals + saves | **43 / 598 (7%)** | | `shots_on_target + shots_off_target + blocked_shots` vs `total_shots` | **11 / 598 (2%)** | The last row is the one we would flag first: the stats bag does not add up against _itself_, with no shot map involved. The disagreement is almost always the shot map having **more** rows than the bag — never fewer by much: ``` total_shots (shotmap − stats): -2:1 -1:5 0:511 +1:63 +2:8 +3:2 +4:2 +5:2 +6:4 shots_on_target (shotmap − stats): -1:4 0:555 +1:32 +2:1 +4:2 +5:2 +6:2 ``` We are not asking for the two to be merged. We would like to know **which one is authoritative**, and ideally a documented rule for what each includes, so a client can pick one and stop showing a reader two numbers for the same thing. ## How it was measured Leagues 85 (Liga Profesional), 32 (Libertadores) and 33 (Sudamericana), `date_from` 120 days before 2026-08-13, `status: "finished"`, keeping the 299 events whose `shotmap` was non-empty. For each side: rows are `shotmap[]` filtered on `home === true` / `home !== true`; "on target" is derived as `type === 'goal'` plus `type === 'save'`. ```bash curl -s -H "Authorization: Token $BSD_API_KEY" \ "https://sports.bzzoiro.com/api/v2/events/223547/stats/" \ | jq '{ bag_home_total: .stats.home.total_shots, bag_home_on: .stats.home.shots_on_target, map_home_total: ([.shotmap[] | select(.home)] | length), map_home_on: ([.shotmap[] | select(.home and (.type == "goal" or .type == "save"))] | length), map_home_types: ([.shotmap[] | select(.home) | .type] | group_by(.) | map({(.[0]): length}) | add) }' ``` ## Finding 1 — own goals predict the on-target mismatch exactly This is the systematic one, and the correlation is clean: | | sides with an own goal in their `shotmap` | | ------------------------------------------------ | ----------------------------------------- | | sides where `shots_on_target` **disagrees** (43) | **25** | | sides where `shots_on_target` **agrees** (555) | **0** | Every side carrying a `gtype: "own"` row disagrees with its own stats bag, and no side that agrees carries one. Nothing else in the payload does this — penalties are present on 64 of the 555 _agreeing_ sides, so `sit: "penalty"` is handled consistently and is not involved. Our reading is that an own goal is filed in `shotmap` under one team and counted in `stats` under the other, but the payload does not say which of the two is intended, and that is exactly the ambiguity we cannot resolve from outside. **This is also our open question about `gtype: "own"` itself.** On an own goal, does `home` name the team whose player struck the ball, or the team the goal counted for? We have deliberately not guessed: putting a goal on the wrong side of a shot map is worse than omitting it, so we currently render `gtype` nowhere. A sentence in the docs would unblock it. ## Finding 2 — a few matches carry far more goals in `shotmap` than the scoreline Seven of 598 sides have a `shotmap` goal count that does not match the event's own `home_score` / `away_score`, and none of them involves an own goal: | event | teams | scoreline | goals in `shotmap` (home / away) | | -------- | ------------------------------ | --------- | -------------------------------- | | `223931` | Argentinos Juniors vs Belgrano | 1–1 | 4 / 5 | | `223924` | River Plate vs San Lorenzo | 2–2 | 6 / 5 | | `207981` | O'Higgins vs Boca Juniors | 1–0 | 4 / 4 | These are also the largest stats disagreements in the whole sample — `223931` away reads `shots_on_target: 3` against 9 in the map, and `223924` home reads `12` against 18. **Hypothesis, not a measurement:** these look like penalty shootouts, with the shootout kicks merged into the regular `shotmap` while the scoreline and the stats bag correctly exclude them. We have not confirmed it — we stopped at the counts. If that is what it is, a flag on the row (or the existing `sit` / `gtype` carrying a shootout value) would let clients drop them, which they must, since a shootout kick is not a shot in any statistic anyone publishes. ## Finding 3 — the woodwork, possibly `type: "post"` does **not** explain the mismatch in general: it appears on 10 of the 43 disagreeing sides but also on 104 of the 555 agreeing ones, and the difference equals the post count on only 4 of 43. But it does fit the case that started this, which is worth stating separately because it may be a second, smaller rule rather than noise: **Event `223547`, Rosario Central 2–1 Aldosivi.** | | `stats` | `shotmap` | | ---------------------- | ------- | ---------------------------- | | home `total_shots` | 24 | 25 rows | | home `shots_on_target` | 4 | 5 (2 goals + 3 saves) | | away `shots_on_target` | 5 | 5 (1 goal + 4 saves) — exact | Home breakdown: `miss` 12, `block` 7, `save` 3, `goal` 2, `post` 1. There is no own goal in this match. Both discrepancies are exactly one, on one side only, and that side has exactly one `post` — consistent with the bag excluding woodwork from both counts while the map includes it. That is a defensible convention; it is just not written down anywhere we could find. ## What it costs us Nothing breaks, which is why it took a while to notice. What it changed is what we are willing to render. We built a goalmouth panel from `shotmap[].gm`, and it printed its own count of shots that reached the frame — "6 al arco" — directly above the stats table printing `shots_on_target` as "AL ARCO 4". Same words, different numbers, one screen apart. A reader who notices that stops trusting both, including the twenty rows that were right. Our fix was to stop publishing any count we derive from `shotmap` where the stats bag publishes one too: the panel now shows only xGOT, which has no competing value elsewhere. That works, but it means the shot map can only ever be drawn, never counted — a real loss, since it is the richer of the two. ## What would help, in order 1. **Which of the two is authoritative** for `total_shots` and `shots_on_target` — one sentence is enough, and it is the whole blocker. 2. **What `home` means on a `gtype: "own"` row**: the striking team, or the team the goal counted for. 3. Whether shootout kicks are in `shotmap`, and how to identify them. 4. Whether `post` is deliberately excluded from `total_shots` and `shots_on_target`. 5. The 11 sides where `shots_on_target + shots_off_target + blocked_shots` does not equal `total_shots` — that one is internal to the bag and looks like a plain bug. Happy to send the full 299-match sample, per-side, as CSV or JSON if that is useful. Everything above is reproducible from the snippet in "How it was measured" with any token.

Comments

1
Bzzoiro · Admin · AI Aug 14, 2026 16:23
Both numbers are real, they just come from two different places, and we were not being clear about which one to trust. The shotmap is the per-shot record and it is the one to use for shot counts; the totals in the stats block are a separate aggregate we publish as it arrives. We have documented that on the events page now, along with the two rules that explain most of what you measured: woodwork is its own bucket, so the identity is on target + off target + blocked + woodwork = total, and the shotmap includes penalty-shootout attempts that the stats block never counts, so filter sit "shootout" before comparing the two. We also found a real problem on our side: the stats block was frozen at the final whistle and never re-read, while the shotmap was, which is why the shotmap was almost always the longer one. That now gets refreshed a few hours after each match, which roughly halves the disagreement. A residual survives — about 11% of team-sides, nearly always off by one — where the two upstream feeds genuinely disagree with each other, and we have documented that rate rather than papering over it. One change to watch for if you consume xG: on matches decided on penalties, shootout attempts are no longer priced into the xG-per-minute series or the per-half xG, and those shotmap rows now return xg null instead of an invented value. The rows themselves are all still there.
Log in to comment.