Repository navigation
Expand file tree
/
Copy pathpush-feed-and-webhooks.html
More file actions
226 lines (203 loc) · 36.6 KB
/
Copy pathpush-feed-and-webhooks.html
File metadata and controls
226 lines (203 loc) · 36.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tennis WebSocket feed and webhooks — Live Tennis API</title>
<meta name="description" content="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">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://docs.livetennisapi.com/push-feed-and-webhooks.html">
<meta property="og:type" content="article">
<meta property="og:title" content="Tennis WebSocket feed and webhooks">
<meta property="og:url" content="https://docs.livetennisapi.com/push-feed-and-webhooks.html">
<meta property="og:image" content="https://docs.livetennisapi.com/banner.jpg">
<link rel="icon" href="favicon.ico" sizes="any">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"TechArticle",
"headline":"Tennis WebSocket feed and webhooks",
"description":"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",
"url":"https://docs.livetennisapi.com/push-feed-and-webhooks.html",
"inLanguage":"en",
"isPartOf":{"@type":"WebSite","name":"Live Tennis API","url":"https://livetennisapi.com"},
"publisher":{"@type":"Organization","@id":"https://livetennisapi.com/#org","name":"JSB Holdings LLC","alternateName":"Live Tennis API","url":"https://livetennisapi.com","logo":"https://docs.livetennisapi.com/icon-256.png"}}
</script>
<link rel="preload" href="fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin fetchpriority="high">
<link rel="preload" href="fonts/space-grotesk-latin-700.woff2" as="font" type="font/woff2" crossorigin fetchpriority="high">
<link rel="preload" href="fonts/jetbrains-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin fetchpriority="low">
<style>
/* Self-hosted webfonts (latin subsets), shared by index.html and reference.html.
Both pages previously shipped `document.fonts.size === 0` — reference.html
rendered entirely in the system UI stack and Space Grotesk, the product's
display face, appeared on neither page.
These are served from this origin, so the docs have no font dependency they
cannot verify: nothing is fetched from Google Fonts or any other third party,
and a blocked CDN cannot change how the page reads.
Family names must stay EXACTLY 'Inter' / 'Space Grotesk' / 'JetBrains Mono' —
they are the names the rest of the product's stylesheets use, and the ones
Scalar is handed via --scalar-font / --scalar-font-code.
Only the weights actually used are shipped (180 KB total):
Inter 400/500/600/700 body, UI, Scalar's semibold + bold
Space Grotesk 500/700 display: headings
JetBrains Mono 400/500 code, numerals */
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/inter-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/inter-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; font-display: swap;
src: url('fonts/inter-latin-600.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/inter-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/space-grotesk-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/space-grotesk-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/jetbrains-mono-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/jetbrains-mono-latin-500.woff2') format('woff2'); }
</style>
<style>
/* Self-hosted webfonts (latin subsets), shared by index.html and reference.html.
Both pages previously shipped `document.fonts.size === 0` — reference.html
rendered entirely in the system UI stack and Space Grotesk, the product's
display face, appeared on neither page.
These are served from this origin, so the docs have no font dependency they
cannot verify: nothing is fetched from Google Fonts or any other third party,
and a blocked CDN cannot change how the page reads.
Family names must stay EXACTLY 'Inter' / 'Space Grotesk' / 'JetBrains Mono' —
they are the names the rest of the product's stylesheets use, and the ones
Scalar is handed via --scalar-font / --scalar-font-code.
Only the weights actually used are shipped (180 KB total):
Inter 400/500/600/700 body, UI, Scalar's semibold + bold
Space Grotesk 500/700 display: headings
JetBrains Mono 400/500 code, numerals */
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/inter-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/inter-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; font-display: swap;
src: url('fonts/inter-latin-600.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/inter-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/space-grotesk-latin-500.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 700; font-display: swap;
src: url('fonts/space-grotesk-latin-700.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/jetbrains-mono-latin-400.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/jetbrains-mono-latin-500.woff2') format('woff2'); }
</style><style>
/* Documentation shares the product palette while keeping reference content compact. */
:root{--bg:#0b100e;--panel:#131c16;--struct:#2d3b31;--text:#f0f4ee;--muted:#afbeb2;--ours:#b7f875;--accent:#b7f875;--r:8px;color-scheme:dark}
*{box-sizing:border-box}body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;margin:0}a{color:var(--accent);text-underline-offset:4px}a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid #d1ffa7;outline-offset:4px}img{max-width:100%}.docs-site-header{display:flex;align-items:center;gap:24px;justify-content:space-between;max-width:1280px;margin:auto;padding:22px 36px;border-bottom:1px solid var(--struct);position:relative;z-index:2}.docs-brand{font:700 17px/1.3 'Space Grotesk',Inter,sans-serif;display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);white-space:nowrap}.docs-brand span{font:400 12px/1.5 Inter,sans-serif;color:var(--muted);border-left:1px solid var(--struct);padding-left:12px;margin-left:5px}.docs-desktop-nav{display:flex;gap:26px}.docs-desktop-nav a{font-size:13px;text-decoration:none;min-height:44px;display:flex;align-items:center;color:var(--muted)}.docs-desktop-nav a:hover,.docs-desktop-nav a[aria-current=page]{color:var(--accent)}.docs-key{display:inline-flex;align-items:center;gap:20px;min-height:44px;padding:8px 15px;background:var(--accent);color:var(--bg);font-size:12px;text-decoration:none;border-radius:7px}.docs-topic-menu{max-width:1208px;margin:0 auto;border-bottom:1px solid var(--struct);color:var(--muted)}.docs-topic-menu summary{cursor:pointer;min-height:44px;padding:14px 0;font-size:12px}.docs-topic-menu nav{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 24px;padding-bottom:16px}.docs-topic-menu a{display:flex;align-items:center;min-height:44px;font-size:12px;color:var(--muted);text-decoration:none}.docs-topic-menu a:hover,.docs-topic-menu a[aria-current]{color:var(--accent)}
.docs-page .wrap{max-width:1120px;padding:58px 36px 80px;min-width:0}.docs-page .wrap>header{padding-bottom:28px;margin-bottom:32px;border-bottom:1px solid var(--struct)}.docs-page h1{font:700 clamp(34px,4.2vw,58px)/1.09 'Space Grotesk',Inter,sans-serif;letter-spacing:-.05em;max-width:22ch;margin:12px 0 24px;overflow-wrap:anywhere}.docs-page h2{font-size:26px;letter-spacing:-.03em;line-height:1.25;margin-top:48px}.docs-page h3{font-size:20px;letter-spacing:-.02em;line-height:1.4;overflow-wrap:anywhere}.docs-page h4{color:var(--muted);font-size:11px;letter-spacing:.12em;margin-top:28px}.docs-page .meta,.docs-page .eyebrow{color:var(--muted);font-size:13px}.docs-page .wrap>header>.meta:first-child,.docs-page .eyebrow{text-transform:uppercase;font-size:10px;letter-spacing:.15em;color:var(--accent)}.docs-page .wrap>header>p:not(.meta){max-width:76ch;line-height:1.8}.docs-page .pagenav{gap:8px 12px}.docs-page .pagenav a{padding:7px 12px;border:1px solid var(--struct);border-radius:6px;min-height:40px;font-size:12px;text-decoration:none}.docs-page .pagenav a:hover{background:var(--panel);border-color:#637958}.docs-page .banner{border-radius:8px;padding:18px 22px;background:var(--panel);border-color:var(--struct);border-left:3px solid var(--accent)}.docs-page .banner p{font-size:14px;margin:0;line-height:1.7}.docs-page .toc{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px}.docs-page .toc li{font-size:13px}.docs-page .toc li a{font-size:14px;min-height:46px}.docs-page .op{margin-top:42px;padding-top:4px;min-width:0}.docs-page pre{border-radius:10px;padding:22px;font-size:12px;line-height:1.8;max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere}.docs-page pre code{font-size:inherit}.docs-page code{overflow-wrap:anywhere;word-break:normal}.docs-page .scrollx{border:1px solid var(--struct);border-radius:8px;margin:14px 0 18px;max-width:100%;min-width:0}.docs-page .scrollx table{margin:0;min-width:540px}.docs-page table th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;background:var(--panel);padding:12px 14px}.docs-page table td{font-size:13px;padding:13px 14px}.docs-page table tr:last-child td{border-bottom:0}.docs-page .annot{border-radius:8px;padding:18px}.docs-page footer{margin-top:60px}.docs-page footer hr{border:0;border-top:1px solid var(--struct);margin:20px 0}.docs-changelog article{border-top:1px solid var(--struct);padding:24px 0;max-width:80ch}.docs-changelog article h2{border:0;margin-top:0;display:flex;align-items:baseline;gap:20px;flex-wrap:wrap}.docs-changelog time{font:400 12px/1.5 Inter,sans-serif;color:var(--muted)}
/* A useful landing page stays visible before the optional explorer is loaded. */
.docs-home #static-intro{position:relative;inset:auto;max-width:1208px;max-height:none;overflow:visible;min-height:0;padding:64px 0 48px;margin:auto;background:none;z-index:0}.docs-home #static-intro[hidden]{display:none}.docs-home .docs-hero{display:grid;grid-template-columns:1.2fr 1fr;gap:70px;align-items:center;margin-bottom:68px}.docs-home .docs-eyebrow{color:var(--accent);font-size:10px;letter-spacing:.18em;text-transform:uppercase;margin:0 0 22px}.docs-home #static-intro h1{font:700 clamp(40px,5vw,70px)/1.04 'Space Grotesk',Inter,sans-serif;letter-spacing:-.055em;color:var(--text);margin:0 0 24px}.docs-home .docs-lede{font-size:16px;line-height:1.8;color:var(--muted);max-width:55ch}.docs-home .docs-actions{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin:28px 0 14px}.docs-home #open-explorer{padding:13px 18px;font:500 13px/1.5 Inter,sans-serif;border:0;border-radius:8px;background:var(--accent);color:var(--bg);min-height:48px;cursor:pointer}.docs-home .docs-actions>a{font-size:13px;min-height:44px;display:inline-flex;align-items:center}.docs-home .docs-code{padding:24px;background:var(--panel);border:1px solid #3b4d3e;border-radius:12px;min-width:0;box-shadow:0 24px 80px #0005}.docs-home .docs-code-label{font-size:10px;letter-spacing:.14em;color:var(--muted);display:flex;justify-content:space-between;text-transform:uppercase;border-bottom:1px solid var(--struct);padding-bottom:18px;margin-bottom:22px}.docs-home .docs-code pre{font:12px/1.9 'JetBrains Mono',monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;color:var(--text)}.docs-home .docs-code .code-accent{color:var(--accent)}.docs-home .docs-code>p{font-size:12px;line-height:1.7;color:var(--muted);border-top:1px solid var(--struct);padding-top:16px;margin:22px 0 0}.docs-home .docs-section-title{display:flex;align-items:baseline;justify-content:space-between;gap:20px;margin:0 0 20px}.docs-home #static-intro h2{font:700 27px/1.2 'Space Grotesk',Inter,sans-serif;letter-spacing:-.035em;margin:0}.docs-home .docs-section-title p{font-size:12px;color:var(--muted);margin:0}.docs-home .docs-topics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.docs-home .docs-topics>a{border:1px solid var(--struct);border-radius:9px;display:flex;flex-direction:column;gap:14px;padding:22px;text-decoration:none;min-width:0;color:var(--text);background:#0f1712}.docs-home .docs-topics>a:hover{background:var(--panel);border-color:#6c885c}.docs-home .docs-topics strong{font-size:14px;font-weight:500;line-height:1.4}.docs-home .docs-topics span{font-size:12px;line-height:1.7;color:var(--muted)}.docs-home .docs-topics small{font-size:10px;color:var(--accent);font-family:'JetBrains Mono',monospace}.docs-home .docs-resources{display:flex;gap:20px;flex-wrap:wrap;border-top:1px solid var(--struct);padding:24px 0;margin-top:32px}.docs-home .docs-resources a{font-size:12px;min-height:32px;display:flex;align-items:center}.docs-home .docs-clients{display:flex;flex-wrap:wrap;gap:12px 24px;color:var(--muted);font-size:12px}.docs-home .docs-clients code{color:var(--text);font-size:11px}.docs-home .docs-corner{display:none}.docs-home #boot{position:relative;inset:auto;max-width:1208px;margin:0 auto;padding:20px;min-height:0;border-bottom:1px solid var(--struct);pointer-events:none}.docs-home #boot[hidden]{display:none}.docs-home #boot a{pointer-events:auto}.docs-home .skip-link{position:absolute;left:-9999px;z-index:20;background:var(--panel);padding:14px;color:var(--accent)}.docs-home .skip-link:focus{left:8px;top:8px}
@media(max-width:1280px){.docs-topic-menu{margin:0 36px}.docs-home #static-intro{padding:52px 36px}.docs-home .docs-hero{gap:36px}}
@media(max-width:900px){.docs-desktop-nav{gap:16px}.docs-home .docs-hero{grid-template-columns:1fr;gap:28px;margin-bottom:44px}.docs-home .docs-code{max-width:620px}.docs-home .docs-topics{grid-template-columns:repeat(2,minmax(0,1fr))}.docs-home .docs-section-title{display:block}.docs-home .docs-section-title p{margin-top:10px}.docs-page .wrap{padding-top:36px}}
@media(max-width:640px){.docs-site-header{padding:16px 20px;gap:12px;flex-wrap:wrap}.docs-brand{font-size:14px;gap:8px}.docs-brand img{width:22px;height:22px}.docs-brand span{font-size:10px;padding-left:8px;margin-left:0}.docs-desktop-nav{display:none}.docs-key{font-size:10px;gap:8px;padding:7px 10px}.docs-topic-menu{margin:0 20px}.docs-topic-menu nav{grid-template-columns:1fr}.docs-page .wrap{padding:30px 20px 60px}.docs-page h1{font-size:36px}.docs-page .toc{grid-template-columns:1fr}.docs-page h3{font-size:18px}.docs-page .pagenav a{min-height:44px}.docs-page pre{padding:16px;font-size:11px}.docs-page .wrap>header>p:not(.meta){font-size:15px}.docs-home #static-intro{padding:38px 20px}.docs-home .docs-lede{font-size:15px}.docs-home .docs-code{padding:18px}.docs-home .docs-code pre{font-size:11px}.docs-home .docs-topics>a{padding:18px 15px}.docs-home .docs-resources{gap:8px 20px}.docs-home .docs-resources a{min-height:44px}.docs-home .docs-actions{gap:8px 18px}}
@media(max-width:360px){.docs-brand span{display:none}.docs-home .docs-topics{grid-template-columns:1fr}.docs-page h1{font-size:32px}}
@media(prefers-reduced-motion:no-preference){a,button{transition:color .15s,background .15s,border-color .15s}}
.docs-page p,.docs-page li,.docs-page a,.docs-page td{overflow-wrap:anywhere}.docs-page .scrollx{width:100%;overflow-x:auto}
</style>
</head>
<body class="docs-page docs-topic">
<a class="skip-link" href="#main">Skip to content</a>
<header class="docs-site-header"><a class="docs-brand" href="./"><img src="logo.svg" width="28" height="28" alt="">Live Tennis API <span>Docs</span></a><nav class="docs-desktop-nav" aria-label="Documentation"><a href="./">Overview</a><a href="./reference.html">API reference</a><a href="./changelog.html">Changelog</a></nav><a class="docs-key" href="https://livetennisapi.com/subscribe/free">Get a free key <span aria-hidden="true">↗</span></a></header><details class="docs-topic-menu"><summary>Explore the documentation</summary><nav aria-label="Documentation topics"><a href="./">Overview</a><a href="./reference.html">API reference</a><a href="./changelog.html">Changelog</a><a href="./live-scores.html">Live tennis scores API</a><a href="./players-and-tournaments.html">Tennis players, tournaments and rankings API</a><a href="./tennis-odds.html">Tennis odds API — markets and price ticks</a><a href="./point-by-point-history.html">Point-by-point tennis data API</a><a href="./historical-results-archive.html">Historical tennis results API — 1968 onward</a><a href="./shot-level-rally-data.html">Shot-by-shot tennis rally and charting API</a><a href="./broadcast-graphics.html">Tennis scores for on-air graphics</a><a href="./push-feed-and-webhooks.html" aria-current="page">Tennis WebSocket feed and webhooks</a><a href="./auth-quota-and-health.html">Tennis API authentication, quota and status</a></nav></details>
<div class="wrap">
<header>
<p class="meta">Live Tennis API · docs · version 1.13.60</p>
<h1>Tennis WebSocket feed and webhooks</h1>
<p><strong>How do you receive tennis data as it happens instead of polling?</strong> Two ways, both ULTRA. <code>GET /ws-token</code> mints a short-lived token for the high-fan-out push feed, which streams score and point changes as they are written. <code>POST /webhooks</code> registers an outbound HTTP callback for the same events; <code>GET /webhooks</code> lists your registrations and never returns the signing secret. Webhooks require a direct key, not a marketplace one.</p>
<p class="meta">4 endpoints on this page. Base URL
<code>https://api.livetennisapi.com/api/public/v1</code>; authenticate with the <code>X-API-Key</code> header. Plans involved:
ULTRA. A free key needs no card.</p>
<nav class="pagenav" aria-label="Related pages">
<a href="./reference.html">Full reference — all endpoints</a>
<a href="./">Interactive reference</a>
<a href="./openapi.yaml">OpenAPI spec</a>
<a href="./changelog.html">Changelog</a>
<a href="https://livetennisapi.com/pricing">Pricing</a>
<a href="https://livetennisapi.com/subscribe/free">Free key</a>
</nav>
</header>
<main id="main">
<h2 id="endpoints">The endpoints</h2>
<p>Each one below lists its plan tier, every parameter, the response shape and an example. They
are the same entries as in <a href="./reference.html#endpoints">the full reference</a>, which
carries all 48 operations of the API on one page.</p>
<section class="op" id="createWebhook">
<h3><span class="method">POST</span> <code>/webhooks</code></h3>
<p class="summary">Register an outbound webhook (ULTRA, direct keys only)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>createWebhook</code></p>
<p>We POST the same frames the WebSocket sends to your HTTPS endpoint on every live score commit. Up to 3 webhooks per key (<code>409 webhook_limit</code> past that). The response is the ONLY time the signing secret is shown — store it.
Each delivery carries <code>X-LTAPI-Signature</code> (<code>sha256=<hex></code> — HMAC-SHA256 of the RAW request body with your webhook secret; verify with a constant-time compare), <code>X-LTAPI-Timestamp</code> (Unix seconds at send time — reject stale replays at your edge) and <code>X-LTAPI-Event</code> (the frame type: <code>score</code>, <code>break_point</code>, <code>break_point_result</code> or <code>point</code>).
Delivery is best-effort, at-most-once, no replay: one attempt per frame with a ~3s timeout and redirects disabled. Every <code>score</code> frame is the complete current score, so a missed delivery self-corrects on the next commit. A <code>point</code> frame is an EVENT, not a state — a missed one does NOT self-correct; recover it with <code>GET /matches/{matchId}/points?after_seq=</code> and dedup by <code>seq</code>. After 25 consecutive failures the webhook is disabled automatically (<code>enabled:false</code>, <code>last_error</code> set — visible in <code>GET /webhooks</code>); delete and re-register to resume.</p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="POST /webhooks responses"><table><caption class="vh">POST /webhooks — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>201</code></td><td>Created — includes <code>secret</code> (shown exactly once)</td></tr><tr><td><code>400</code></td><td>Bad query parameter</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>409</code></td><td>Webhook limit reached (3 per key) — delete an existing webhook first</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/webhooks \
-H "Authorization: Bearer twjp_..."</code></pre>
</section><section class="op" id="listWebhooks">
<h3><span class="method">GET</span> <code>/webhooks</code></h3>
<p class="summary">List your webhooks (ULTRA, direct keys only; never includes the secret)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>listWebhooks</code></p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /webhooks responses"><table><caption class="vh">GET /webhooks — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Your webhooks</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /webhooks response fields"><table><caption class="vh">GET /webhooks — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>data</code></td><td>array of object</td><td></td></tr><tr><td><code>meta</code></td><td>object</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/webhooks \
-H "Authorization: Bearer twjp_..."</code></pre>
</section><section class="op" id="deleteWebhook">
<h3><span class="method">DELETE</span> <code>/webhooks/{webhookId}</code></h3>
<p class="summary">Remove one of your webhooks (ULTRA, direct keys only)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>deleteWebhook</code></p>
<h4>Parameters</h4><div class="scrollx" tabindex="0" role="region" aria-label="DELETE /webhooks/{webhookId} parameters"><table><caption class="vh">DELETE /webhooks/{webhookId} — parameters</caption><thead><tr><th scope="col">Name</th><th scope="col">In</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">Notes</th></tr></thead><tbody><tr><td><code>webhookId</code></td><td>path</td><td>integer</td><td>yes</td><td></td></tr></tbody></table></div>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="DELETE /webhooks/{webhookId} responses"><table><caption class="vh">DELETE /webhooks/{webhookId} — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Deleted</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>404</code></td><td>No such resource, or no data yet</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="DELETE /webhooks/{webhookId} response fields"><table><caption class="vh">DELETE /webhooks/{webhookId} — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>deleted</code></td><td>integer</td><td></td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/webhooks/{webhookId} \
-H "Authorization: Bearer twjp_..."</code></pre>
</section><section class="op" id="createWsToken">
<h3><span class="method">GET</span> <code>/ws-token</code></h3>
<p class="summary">Mint a connection token for the high-fan-out push feed (ULTRA)</p>
<p class="meta">Plan required: <strong>ULTRA</strong> · operationId: <code>createWsToken</code></p>
<p>Returns a short-lived signed token plus the push WebSocket URL and the channel vocabulary: <code>match:{match_id}</code> per-match streams and <code>slate:all</code> for every live score frame. Frames are the same allowlist score objects the polling endpoints return. This is a separate surface from the native <code>/ws</code> feed described above — same ULTRA gate, built for high fan-out (no shared connection ceiling), and the recommended home for continuous/production streaming.
The endpoint speaks the **Centrifugo client protocol** (v2, JSON). Easiest path: the official Python (<code>livetennisapi</code> ≥ 1.4.0) and JS (≥ 1.5.0) SDKs ship a built-in <code>PushStream</code> client — no extra dependency. Raw protocol, if you prefer your own client: (1) open a WebSocket to <code>ws_url</code>; (2) send <code>{"connect": {"token": "<token>"}, "id": 1}</code> — the token goes INSIDE this JSON frame, never as a raw first message; (3) subscribe per channel with <code>{"subscribe": {"channel": "slate:all"}, "id": 2}</code>; (4) publications arrive as <code>{"push": {"channel": ..., "pub": {"data": <frame>}}}</code>; (5) the server's heartbeat is an empty JSON object <code>{}</code> — reply with <code>{}</code> promptly or you will be disconnected. Messages may batch several newline-delimited JSON objects. Tokens are short-lived and the connection closes around token expiry: mint a fresh token on EVERY reconnect and re-subscribe.
The <code>channels</code> object lists only channels that will actually deliver for your key right now (a channel name in this response is a promise). Where enabled server-side, additional channel families appear: <code>point:match:{match_id}</code> / <code>point:slate</code> (per-point events), listed — as <code>point_match</code> / <code>point_slate</code> in the vocabulary — only for keys whose plan carries the point surface, and <code>signal:match:{match_id}</code> / <code>signal:slate</code> (derived <code>break_point</code>, <code>break_point_result</code> and <code>divergence</code> events, and since 2026-09-12 the stoppage family: <code>medical_timeout_start/end</code>, <code>trainer_called/_end</code>, <code>toilet_break_start/end</code>, <code>stoppage_start/end</code>, <code>pause_start/end</code>). A family absent from the response will not deliver for your key right now. Deliberately separate channels: a <code>slate:all</code> subscriber asked for score states and never starts receiving events unasked. Point and signal frames are events, not states — a missed point does NOT self-correct on the next frame; recover it via <code>GET /matches/{matchId}/points?after_seq=</code> and dedup by <code>seq</code>.
WITHDRAWN STATES, AND HOW TO GET ONE BACK. This feed publishes every state we accept and runs no trust deference, while <code>GET /matches/{matchId}/score</code> runs the legality gate and can serve an older state than the newest row. So a state you receive here can later be one we do not stand behind, and when that happens a <code>score_withdrawn</code> frame follows the state's own <code>score</code> frame on the same channel. Eight keys, and no replacement score:
``<code>json { "type": "score_withdrawn", "match_id": 4711, "kind": "withdrawn",
"superseded_sequence": 118, "reason": "illegal_completed_set",
"safe_to_resume": true, "ts": "2026-09-25T10:00:03.108Z",
"published_at": "2026-09-25T10:00:03.115Z" }
</code>`<code>
</code>superseded_sequence<code> is the </code>sequence<code> you hold and are to discard. The state to resume from is the one on the next </code>score<code> frame, or on a fresh </code>GET /matches/{matchId}/score<code>. </code>kind<code> is </code>withdrawn<code> (discard), </code>deferred<code> (the refused state may be early and is pending the next accept, so hold it) or </code>withheld<code> (nothing publishable, </code>games<code> is null). </code>ts<code> is when the judgement was made, </code>published_at<code> when this feed was handed the frame. A given (match, </code>sequence<code>) is withdrawn at most once per publishing process and there is no replay.
A consumer that was disconnected recovers the judgement from the durable record, added 2026-09-25: </code>GET /matches?withdrawn_since=<instant><code> for the matches that had one, </code>GET /matches/{matchId}/withdrawals<code> for the whole record on a match (each row carrying the frame exactly as it went out, and the </code>sequence<code> served in the refused state's place), and </code>GET /matches/{matchId}/score?sequence=N<code> to ask about the one sequence you hold. Records are kept 90 days. The live </code>verdict` on the score read is the same judgement on demand, but it is recomputed over the newest state, so it stops answering for N as soon as the next state is accepted. The record does not.</p>
<h4>Responses</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /ws-token responses"><table><caption class="vh">GET /ws-token — responses</caption><thead><tr><th scope="col">Status</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td><code>200</code></td><td>Connection token, push URL and channel vocabulary</td></tr><tr><td><code>401</code></td><td>Missing, unknown, or disabled credentials</td></tr><tr><td><code>403</code></td><td>Your tier doesn't unlock this endpoint</td></tr><tr><td><code>429</code></td><td>Rate limit exceeded (Retry-After header present). Three body shapes, told apart by <code>error</code> and <code>scope</code>: the per-MINUTE limit (<code>rate_limited</code>, with <code>upgrade_url</code>, <code>tier</code> and <code>price</code> naming the next tier up); the per-DAY quota (<code>rate_limited</code> with <code>scope: "day"</code>, <code>limit_per_day</code>, and <code>resets_at</code> — the absolute ISO instant the daily window resets); and <code>abuse_throttled</code> with <code>retry_at_epoch</code> — 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.</td></tr></tbody></table></div>
<h4>Response fields</h4><div class="scrollx" tabindex="0" role="region" aria-label="GET /ws-token response fields"><table><caption class="vh">GET /ws-token — response fields</caption><thead><tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Description</th></tr></thead><tbody><tr><td><code>token</code></td><td>string</td><td></td></tr><tr><td><code>expires_in</code></td><td>integer</td><td></td></tr><tr><td><code>ws_url</code></td><td>string</td><td>The push WebSocket URL to connect to with the token.</td></tr><tr><td><code>channels</code></td><td>object</td><td>Channel vocabulary — <code>match</code> is the per-match pattern (<code>match:{id}</code>), <code>slate</code> is the every-live-score channel (<code>slate:all</code>). A channel listed here will actually deliver for your key; one missing will not.</td></tr></tbody></table></div>
<h4>Example</h4><pre><code>curl https://api.livetennisapi.com/api/public/v1/ws-token \
-H "Authorization: Bearer twjp_..."</code></pre>
</section>
</main>
<footer>
<hr>
<nav class="pagenav" aria-label="Other topics">
<a href="./live-scores.html">Live tennis scores API</a>
<a href="./players-and-tournaments.html">Tennis players, tournaments and rankings API</a>
<a href="./tennis-odds.html">Tennis odds API — markets and price ticks</a>
<a href="./point-by-point-history.html">Point-by-point tennis data API</a>
<a href="./historical-results-archive.html">Historical tennis results API — 1968 onward</a>
<a href="./shot-level-rally-data.html">Shot-by-shot tennis rally and charting API</a>
<a href="./broadcast-graphics.html">Tennis scores for on-air graphics</a>
<a href="./auth-quota-and-health.html">Tennis API authentication, quota and status</a>
</nav>
<p class="meta">Generated from <a href="./openapi.yaml">openapi.yaml</a> by
<a href="https://github.com/livetennisapi/openapi">livetennisapi/openapi</a>. Every endpoint on
this page is also in <a href="./reference.html">the full reference</a>.</p>
<p class="meta">Built by <a href="https://synapsereality.io/work/live-tennis-api/">Synapse Research Ltd</a>.</p>
</footer>
</div>
<script>
(function(){try{navigator.sendBeacon('https://livetennisapi.com/collect',JSON.stringify({kind:'docs_view',path:location.pathname,host:location.host}))}catch(e){}})();
</script>
<script>
(function(){var E={};"AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE IS LI NO".split(" ").forEach(function(c){E[c]=1});fetch("https://livetennisapi.com/cdn-cgi/trace").then(function(r){return r.text()}).then(function(t){var m=t.match(/loc=(\w+)/);if(!m||E[m[1]])return;(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,"clarity","script","xotc2pctsp");try{clarity("set","user_state","docs")}catch(e){}}).catch(function(){})})();
</script>
</body>
</html>