From 2bfe9e02d5abdb7039ada89878cc3edce158306b Mon Sep 17 00:00:00 2001 From: Live Tennis API Date: Wed, 28 Oct 2026 05:36:26 +0200 Subject: [PATCH] Sign the webhook timestamp with the body (1.13.76) X-LTAPI-Signature signs the raw body only, so a captured delivery verifies again when re-sent under a fresh X-LTAPI-Timestamp. The POST /webhooks description now documents X-LTAPI-Signature-V1 (t=,v1=, HMAC-SHA256 over ".") and the signed delivery_id in each body. It lists the receiver checks and calls the old header deprecated, dropped from 2027-02-01 00:00 UTC. The events enums now match what registration accepts: stoppages is gone from POST /webhooks, and the Webhook schema gains point and status. Generated pages rebuilt with scripts/build_reference.py. --- CHANGELOG.md | 11 ++++++++ docs/auth-quota-and-health.html | 2 +- docs/broadcast-graphics.html | 2 +- docs/changelog.html | 19 +++++++++++-- docs/historical-results-archive.html | 2 +- docs/live-scores.html | 2 +- docs/openapi.json | 15 +++++----- docs/players-and-tournaments.html | 2 +- docs/point-by-point-history.html | 2 +- docs/push-feed-and-webhooks.html | 6 ++-- docs/reference.html | 10 ++++--- docs/shot-level-rally-data.html | 2 +- docs/tennis-odds.html | 2 +- openapi.yaml | 41 +++++++++++++++++++++------- 14 files changed, 84 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d58c48..925d8aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to the specification are recorded here. The API surface is versioned as `v1`. Changes within `v1` are **additive only**; removing a field or changing its type would require `v2`. +## [1.13.76] - 2026-10-28 + +### Added +- **`X-LTAPI-Signature-V1` on every webhook delivery.** The value is `t=,v1=`, and the hex is HMAC-SHA256 of `.` with your webhook secret. The timestamp is inside the signed string, so a captured delivery re-sent later under a new time no longer verifies. Each body also carries a top-level `delivery_id`, unique per delivery, to dedupe on. The `POST /webhooks` description lists the receiver checks: a 5-minute timestamp window, a constant-time compare and a dedupe on `delivery_id`. It also lists `score_withdrawn` among the `X-LTAPI-Event` values, which it had left out. + +### Fixed +- **The webhook `events` enum matches what registration accepts.** `POST /webhooks` listed `stoppages`, which registration refuses with a 400, and the `Webhook` schema listed only `score` and `break_point`. Both now read `score`, `break_point`, `point`, `status`. + +### Deprecated +- **`X-LTAPI-Signature`, the body-only webhook signature.** It does not cover the timestamp. We keep sending it unchanged until 2027-02-01, and deliveries sent from 00:00 UTC on that date do not carry it. Both headers use the same secret, so moving to the new header needs no re-registration. + ## [1.13.75] - 2026-10-28 ### Changed diff --git a/docs/auth-quota-and-health.html b/docs/auth-quota-and-health.html index dc9b198..cbebf30 100644 --- a/docs/auth-quota-and-health.html +++ b/docs/auth-quota-and-health.html @@ -202,7 +202,7 @@
Live Tennis API DocsGet a free key
Explore the documentation
-

Live Tennis API · docs · version 1.13.75

+

Live Tennis API · docs · version 1.13.76

Tennis API authentication, quota and status

How do you authenticate, and how do you see what quota is left? Send your key as X-API-Key on every request. GET /usage returns your own consumption against your plan's quota and works on every tier including FREE, so a client can check its own headroom before a burst. GET /health is the unauthenticated liveness probe.

2 endpoints on this page. Base URL diff --git a/docs/broadcast-graphics.html b/docs/broadcast-graphics.html index 20f077f..5d861ca 100644 --- a/docs/broadcast-graphics.html +++ b/docs/broadcast-graphics.html @@ -202,7 +202,7 @@

Live Tennis API DocsGet a free key
Explore the documentation
-

Live Tennis API · docs · version 1.13.75

+

Live Tennis API · docs · version 1.13.76

Tennis scores for on-air graphics

How do you put live tennis scores into a broadcast graphics template? GET /broadcast/match/{matchId} returns ONE flat object for one match and GET /broadcast/live the same object for every live match in a single call. Both are ULTRA. A graphics template binds each field to a fixed path, so three things are contractual: the object is flat, every key is present on every read (null, never missing), and the score is already in display form — p1_points reads "40" or "AD" and set_line is one string. Built for 1 Hz: every response carries a strong ETag, so If-None-Match makes an unchanged state a 304 with no body. Detect a new state on sequence, never on served_at, which changes on every read.

2 endpoints on this page. Base URL diff --git a/docs/changelog.html b/docs/changelog.html index cc52594..cb5bf38 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -4,7 +4,7 @@ Live Tennis API — Changelog - + @@ -14,7 +14,7 @@ @@ -204,8 +204,21 @@

Live Tennis API · docs

Changelog

-

Every change to the specification, dated and versioned. The API surface is v1; changes within it are additive only. Current version 1.13.75, last change . Also as the full reference, OpenAPI YAML / JSON, and the dated facts file.

+

Every change to the specification, dated and versioned. The API surface is v1; changes within it are additive only. Current version 1.13.76, last change . Also as the full reference, OpenAPI YAML / JSON, and the dated facts file.

+

1.13.76

+

Added

+
    +
  • X-LTAPI-Signature-V1 on every webhook delivery. The value is t=<unix seconds>,v1=<hex>, and the hex is HMAC-SHA256 of <t>.<raw request body> with your webhook secret. The timestamp is inside the signed string, so a captured delivery re-sent later under a new time no longer verifies. Each body also carries a top-level delivery_id, unique per delivery, to dedupe on. The POST /webhooks description lists the receiver checks: a 5-minute timestamp window, a constant-time compare and a dedupe on delivery_id. It also lists score_withdrawn among the X-LTAPI-Event values, which it had left out.
  • +
+

Fixed

+
    +
  • The webhook events enum matches what registration accepts. POST /webhooks listed stoppages, which registration refuses with a 400, and the Webhook schema listed only score and break_point. Both now read score, break_point, point, status.
  • +
+

Deprecated

+
    +
  • X-LTAPI-Signature, the body-only webhook signature. It does not cover the timestamp. We keep sending it unchanged until 2027-02-01, and deliveries sent from 00:00 UTC on that date do not carry it. Both headers use the same secret, so moving to the new header needs no re-registration.
  • +

1.13.75

Changed

    diff --git a/docs/historical-results-archive.html b/docs/historical-results-archive.html index bd52ef2..89ac62c 100644 --- a/docs/historical-results-archive.html +++ b/docs/historical-results-archive.html @@ -202,7 +202,7 @@
    Live Tennis API DocsGet a free key
    Explore the documentation
    -

    Live Tennis API · docs · version 1.13.75

    +

    Live Tennis API · docs · version 1.13.76

    Historical tennis results API — 1968 onward

    How far back does the historical tennis data go, and what is in it? The results archive covers 1968 to 2022: GET /history/archive/matches for results, /archive/players for bios, /archive/career for career aggregates and GET /h2h for head-to-head across both the archive and our own completed matches. All BASIC. Matches from 2013 onward additionally carry a reconstructed point-by-point tape at /archive/matches/{archiveId}/tape (ULTRA).

    6 endpoints on this page. Base URL diff --git a/docs/live-scores.html b/docs/live-scores.html index b6f485b..3ddcecd 100644 --- a/docs/live-scores.html +++ b/docs/live-scores.html @@ -202,7 +202,7 @@

    Live Tennis API DocsGet a free key
    Explore the documentation
    -

    Live Tennis API · docs · version 1.13.75

    +

    Live Tennis API · docs · version 1.13.76

    Live tennis scores API

    How do you read live tennis scores from the API? Poll GET /matches?status=live for the slate, then GET /matches/{matchId}/score for the lowest-latency read of one match. Both are on the FREE plan. Scores carry sets, games, points and the server; /events and /points give the same match as a stream of changes rather than a snapshot, so a client can catch up without re-reading state.

    11 endpoints on this page. Base URL diff --git a/docs/openapi.json b/docs/openapi.json index ac80527..e301981 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Live Tennis API", - "version": "1.13.75", + "version": "1.13.76", "contact": { "name": "Live Tennis API", "url": "https://livetennisapi.com" @@ -3056,7 +3056,7 @@ "/webhooks": { "post": { "summary": "Register an outbound webhook (ULTRA, direct keys only)", - "description": "We POST the same frames the WebSocket sends to your HTTPS endpoint on every live score commit. Up to 3 webhooks per key (`409 webhook_limit` past that). The response is the ONLY time the signing secret is shown — store it.\n\nEach delivery carries `X-LTAPI-Signature` (`sha256=` — HMAC-SHA256 of the RAW request body with your webhook secret; verify with a constant-time compare), `X-LTAPI-Timestamp` (Unix seconds at send time — reject stale replays at your edge) and `X-LTAPI-Event` (the frame type: `score`, `break_point`, `break_point_result` or `point`).\n\nDelivery is best-effort, at-most-once, no replay: one attempt per frame with a ~3s timeout and redirects disabled. Every `score` frame is the complete current score, so a missed delivery self-corrects on the next commit. A `point` frame is an EVENT, not a state — a missed one does NOT self-correct; recover it with `GET /matches/{matchId}/points?after_seq=` and dedup by `seq`. After 25 consecutive failures the webhook is disabled automatically (`enabled:false`, `last_error` set — visible in `GET /webhooks`); delete and re-register to resume.", + "description": "We POST the same frames the WebSocket sends to your HTTPS endpoint on every live score commit. Up to 3 webhooks per key (`409 webhook_limit` past that). The response is the ONLY time the signing secret is shown — store it.\n\nEach delivery body is the frame plus a top-level `delivery_id`, a string unique per delivery. Verify the `X-LTAPI-Signature-V1` header, `t=,v1=`. The hex is HMAC-SHA256, keyed with your webhook secret, of the string `.`. Accept the request if any `v1` value matches. Then run these receiver checks. Reject a `t` more than 5 minutes from your clock. Compare with a constant-time function, over the raw bytes before any JSON parsing. Dedupe on `delivery_id` and keep each id until `t` + 5 minutes on your clock. With several workers, record it with an atomic insert-if-absent. `X-LTAPI-Timestamp` repeats `t`, and `X-LTAPI-Event` names the frame type: `score`, `score_withdrawn`, `break_point`, `break_point_result`, `point` or `status`.\n\nDeprecated: `X-LTAPI-Signature` (`sha256=`, HMAC-SHA256 of the raw body alone). It does not cover the timestamp, so a captured delivery still verifies when re-sent later under a fresh `X-LTAPI-Timestamp`. We keep sending it unchanged until 2027-02-01. Deliveries sent from 00:00 UTC on that date do not carry it. Both headers use the same secret. Once your receiver checks `X-LTAPI-Signature-V1`, require it and never fall back to the old header: a fallback lets a re-stamped replay through.\n\nDelivery is best-effort, at-most-once, no replay: one attempt per frame with a ~3s timeout and redirects disabled. Every `score` frame is the complete current score, so a missed delivery self-corrects on the next commit. A `point` frame is an EVENT, not a state — a missed one does NOT self-correct; recover it with `GET /matches/{matchId}/points?after_seq=` and dedup by `seq`. After 25 consecutive failures the webhook is disabled automatically (`enabled:false`, `last_error` set — visible in `GET /webhooks`); delete and re-register to resume.", "operationId": "createWebhook", "requestBody": { "required": true, @@ -3080,14 +3080,13 @@ "score", "break_point", "point", - "status", - "stoppages" + "status" ] }, "default": [ "score" ], - "description": "`score` = every accepted live-score commit; `break_point` = the derived `break_point` / `break_point_result` frames; `point` = one POST per live point (the `PointFrame` shape), where that surface is enabled and the match has `pbp_coverage: \"point\"`." + "description": "`score` = every accepted live-score commit; `break_point` = the derived `break_point` / `break_point_result` frames; `point` = one POST per live point (the `PointFrame` shape), where that surface is enabled and the match has `pbp_coverage: \"point\"`. `status` = one POST per admin-status change, the WebSocket `status` frame. Any other value is a 400." } } } @@ -8267,7 +8266,9 @@ "type": "string", "enum": [ "score", - "break_point" + "break_point", + "point", + "status" ] } }, @@ -8300,7 +8301,7 @@ }, "secret": { "type": "string", - "description": "Present ONLY on the 201 registration response — shown exactly once" + "description": "Present ONLY on the 201 registration response — shown exactly once. Signs every delivery: `X-LTAPI-Signature-V1` is HMAC-SHA256 of `.` with this secret." }, "secret_note": { "type": "string" diff --git a/docs/players-and-tournaments.html b/docs/players-and-tournaments.html index fafc4bb..3be4a3b 100644 --- a/docs/players-and-tournaments.html +++ b/docs/players-and-tournaments.html @@ -202,7 +202,7 @@

    Live Tennis API DocsGet a free key
    Explore the documentation
    -

    Live Tennis API · docs · version 1.13.75

    +

    Live Tennis API · docs · version 1.13.76

    Tennis players, tournaments and rankings API

    How do you look up a player, a tournament or a ranking? GET /players searches by name and GET /players/{playerId} returns one player's bio, current ranking and cached statistics — both FREE. GET /players/{playerId}/stats (PRO) is the player's win/loss record over our completed matches, split by surface, level and season, with ULTRA adding summed serve and return statistics. GET /tournaments is the catalogue that Match.tournament_id joins against, so a match can be resolved to its event without a second lookup. GET /rankings serves rank-ordered listings on PRO. The per-player as-of records, including the Elo tape, are ULTRA.

    8 endpoints on this page. Base URL diff --git a/docs/point-by-point-history.html b/docs/point-by-point-history.html index 48ec0ec..fd0cf8d 100644 --- a/docs/point-by-point-history.html +++ b/docs/point-by-point-history.html @@ -202,7 +202,7 @@

    Live Tennis API DocsGet a free key
    Explore the documentation
    -

    Live Tennis API · docs · version 1.13.75

    +

    Live Tennis API · docs · version 1.13.76

    Point-by-point tennis data API

    Which endpoints return point-by-point tennis data, and how complete is it? GET /history/matches/{matchId} returns the tape for one completed match: the ordered score states we hold for it. The model's win probability sits on the rows that were observed live, and coverage is declared per match. GET /history/matches lists completed matches with their tape coverage, and GET /history/coverage is the measured completeness rollup per tour and draw bucket — a number we publish rather than estimate, with its own as_of. GET /history/packages serves the same data as pre-built monthly bulk files.

    8 endpoints on this page. Base URL diff --git a/docs/push-feed-and-webhooks.html b/docs/push-feed-and-webhooks.html index dec24bb..efbdc72 100644 --- a/docs/push-feed-and-webhooks.html +++ b/docs/push-feed-and-webhooks.html @@ -202,7 +202,7 @@

    Live Tennis API DocsGet a free key
    Explore the documentation
    -

    Live Tennis API · docs · version 1.13.75

    +

    Live Tennis API · docs · version 1.13.76

    Tennis WebSocket feed and webhooks

    How do you receive tennis data as it happens instead of polling? Two ways, both ULTRA. GET /ws-token mints a short-lived token for the high-fan-out push feed, which streams score and point changes as they are written. POST /webhooks registers an outbound HTTP callback for the same events; GET /webhooks lists your registrations and never returns the signing secret. Webhooks require a direct key, not a marketplace one.

    4 endpoints on this page. Base URL @@ -228,7 +228,9 @@

    POST /webhooks

    Plan required: ULTRA · operationId: createWebhook

    We POST the same frames the WebSocket sends to your HTTPS endpoint on every live score commit. Up to 3 webhooks per key (409 webhook_limit past that). The response is the ONLY time the signing secret is shown — store it. -Each delivery carries X-LTAPI-Signature (sha256=<hex> — HMAC-SHA256 of the RAW request body with your webhook secret; verify with a constant-time compare), X-LTAPI-Timestamp (Unix seconds at send time — reject stale replays at your edge) and X-LTAPI-Event (the frame type: score, break_point, break_point_result or point). +Each delivery body is the frame plus a top-level delivery_id, a string unique per delivery. Verify the X-LTAPI-Signature-V1 header, t=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256, keyed with your webhook secret, of the string <t>.<raw request body>. Accept the request if any v1 value matches. Then run these receiver checks. Reject a t more than 5 minutes from your clock. Compare with a constant-time function, over the raw bytes before any JSON parsing. Dedupe on delivery_id and keep each id until t + 5 minutes on your clock. With several workers, record it with an atomic insert-if-absent. X-LTAPI-Timestamp repeats t, and X-LTAPI-Event names the frame type: score, score_withdrawn, break_point, break_point_result, point or status. + +Deprecated: X-LTAPI-Signature (sha256=<hex>, HMAC-SHA256 of the raw body alone). It does not cover the timestamp, so a captured delivery still verifies when re-sent later under a fresh X-LTAPI-Timestamp. We keep sending it unchanged until 2027-02-01. Deliveries sent from 00:00 UTC on that date do not carry it. Both headers use the same secret. Once your receiver checks X-LTAPI-Signature-V1, require it and never fall back to the old header: a fallback lets a re-stamped replay through. Delivery is best-effort, at-most-once, no replay: one attempt per frame with a ~3s timeout and redirects disabled. Every score frame is the complete current score, so a missed delivery self-corrects on the next commit. A point frame is an EVENT, not a state — a missed one does NOT self-correct; recover it with GET /matches/{matchId}/points?after_seq= and dedup by seq. After 25 consecutive failures the webhook is disabled automatically (enabled:false, last_error set — visible in GET /webhooks); delete and re-register to resume.

    Responses

    POST /webhooks — responses
    StatusMeaning
    201Created — includes secret (shown exactly once)
    400Bad query parameter
    401Missing, unknown, or disabled credentials
    403Your tier doesn't unlock this endpoint
    409Webhook limit reached (3 per key) — delete an existing webhook first
    429Rate limit exceeded (Retry-After header present). Three body shapes, told apart by error and scope: the per-MINUTE limit (rate_limited, with upgrade_url, tier and price naming the next tier up); the per-DAY quota (rate_limited with scope: "day", limit_per_day, and resets_at — the absolute ISO instant the daily window resets); and abuse_throttled with retry_at_epoch — a 24-hour block applied to clients that keep hammering far past their cap, which a well-behaved retry loop never sees. Fix the loop rather than retrying through it.
    diff --git a/docs/reference.html b/docs/reference.html index d4aabe0..d4abd7d 100644 --- a/docs/reference.html +++ b/docs/reference.html @@ -207,7 +207,7 @@

    Live Tennis API — Full Reference

    -

    Version 1.13.75 · OpenAPI 3.1.0

    +

    Version 1.13.76 · OpenAPI 3.1.0