# `/events/{id}/stats/`: `shotmap` and `stats` are two different counts of the same match
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.