---
title: "Register an outbound webhook (ULTRA, direct keys only)"
method: POST
path: "/webhooks"
---

# Register an outbound webhook (ULTRA, direct keys only)

`POST /webhooks`

## Request

- Base URL: `https://api.livetennisapi.com/api/public/v1`
- URL: `https://api.livetennisapi.com/api/public/v1/webhooks`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-API-Key`

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`).

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.

## Request body

- object
  - `url` string, required: HTTPS only, publicly routable
  - `events` string[]: `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"`.

## Response `201`

Created — includes `secret` (shown exactly once)

- Webhook
  - `id` integer
  - `url` string
  - `events` string[]
  - `enabled` boolean: Set to false automatically after 25 consecutive delivery failures — delete and re-register to resume.
  - `created_at` string, date-time, nullable
  - `last_delivery_at` string, date-time, nullable
  - `consecutive_failures` integer
  - `last_error` string, nullable
  - `secret` string: Present ONLY on the 201 registration response — shown exactly once
  - `secret_note` string

## Other responses

- `400`: Bad query parameter
- `401`: Missing, unknown, or disabled credentials
- `403`: Your tier doesn't unlock this endpoint
- `409`: Webhook limit reached (3 per key) — delete an existing webhook first
- `429`: Rate 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.

---

[API](https://skmtc.dev/livetennisapi/apis/live-tennis-api.md) · [All operations](https://skmtc.dev/livetennisapi/apis/live-tennis-api/llms.txt) · [OpenAPI document](https://skmtc.dev/livetennisapi/apis/live-tennis-api/revisions/7881947e8e43?raw)
