{
  "name": "Post break-point alerts for followed tennis players to Slack",
  "nodes": [
    {
      "parameters": {
        "content": "## Break-point alerts for followed tennis players in Slack\n\n### How it works\nLive Tennis API sends a signed webhook the moment a break point comes up in any live match. A second one follows when the game ends in a hold or a break. This workflow checks the `X-LTAPI-Signature-V1` header, which signs the send time with the raw body. A request that is unsigned, altered or more than five minutes off stops there. A repeated `delivery_id` is dropped, though in queue mode two copies arriving together can both pass. It then reads the match and posts alerts for your players to Slack.\n\n### Setup\n1. Install the verified community node `n8n-nodes-livetennisapi` (tested with 0.2.1). Search the nodes panel for Live Tennis API, or use Settings > Community nodes when you self-host. n8n's [install guide](https://docs.n8n.io/integrations/community-nodes/installation/) has the steps. Create a **Live Tennis API** credential with your API key and select it on every Live Tennis API node. Webhooks need the ULTRA plan, a direct key and an API release that sends `X-LTAPI-Signature-V1`. Before that release every delivery stops at the check.\n2. Create a **Crypto** credential with any temporary HMAC secret and select it on **Sign the time and the raw body**. n8n will not activate the workflow without it.\n3. Add a **Slack** credential. In **Config**, set `followedPlayerIds` and `slackChannel`.\n4. Activate the workflow. Copy the production URL of the webhook node into **Webhook URL**.\n5. Run the bottom branch once. The response holds the signing secret, shown only once. Replace the temporary secret in the **Crypto** credential with it.\n\n### Customization\nLeave `followedPlayerIds` empty to alert on every match. Break points across the whole slate run into the thousands on a busy day, and each delivery is one n8n execution. Deliveries are not retried.\n\nTested on n8n 2.44.6.",
        "height": 730,
        "width": 520
      },
      "id": "14db3a18-1f54-519c-940f-b5be301ad211",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -700,
        80
      ]
    },
    {
      "parameters": {
        "content": "## Check the signature\nHMAC-SHA256 of the time and the raw body from X-LTAPI-Signature-V1, within five minutes. A repeated delivery_id stops here.",
        "height": 300,
        "width": 1320,
        "color": 7
      },
      "id": "6cda7154-b30b-5d23-be74-76619655fecc",
      "name": "Check the signature",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        160
      ]
    },
    {
      "parameters": {
        "content": "## Filter and post\nKeep break-point frames, read the match, keep followed players, post to Slack.",
        "height": 300,
        "width": 1120,
        "color": 7
      },
      "id": "3879d01b-ef91-509c-afd9-c202ac20049a",
      "name": "Filter and post",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1200,
        160
      ]
    },
    {
      "parameters": {
        "content": "## Run once only\nEach run registers another webhook, and one API key can hold three. Copy the secret from the output straight away.",
        "height": 300,
        "width": 240,
        "color": 3
      },
      "id": "24a02a48-7eed-53a6-a3d8-bef10f15f5ec",
      "name": "Run once only",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        280,
        480
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "live-tennis-break-points",
        "options": {
          "rawBody": true
        }
      },
      "id": "cf5f347e-4776-5aef-a136-821e8437674c",
      "name": "Receive Live Tennis API events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -100,
        300
      ],
      "webhookId": "cddfa75f-b4be-59f6-bb39-7650e4c2d3bf"
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {
          "keepSource": "json"
        }
      },
      "id": "1d52528b-e077-55b0-9301-8279f0e44da4",
      "name": "Read the raw body",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        120,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// X-LTAPI-Signature-V1: t=<unix seconds>,v1=<hex>. More than one v1 can appear, for example\n// while a secret is rotated, and any one may match.\nreturn $input.all().map(item => {\n  const header = String((item.json.headers || {})['x-ltapi-signature-v1'] || '');\n  const parts = header.split(',').filter(p => p.includes('='))\n    .map(p => [p.slice(0, p.indexOf('=')).trim(), p.slice(p.indexOf('=') + 1).trim()]);\n  const t = (parts.find(([k]) => k === 't') || [])[1] || '';\n  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);\n  const ok = /^\\d{1,12}$/.test(t) && sigs.length > 0;\n  return { json: { ...item.json, t: ok ? t : '', sigs: ok ? sigs : [],\n    signedPayload: ok ? `${t}.${item.json.rawBody}` : '' } };\n});"
      },
      "id": "57da8fe8-dfbb-54eb-82c5-02a256f625fd",
      "name": "Read the signature header",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        300
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "binaryData": false,
        "type": "SHA256",
        "value": "={{ $json.signedPayload }}",
        "dataPropertyName": "expectedSignature",
        "encoding": "hex"
      },
      "id": "f9ec7abe-b97f-582f-b68d-9db279a24224",
      "name": "Sign the time and the raw body",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 2,
      "position": [
        560,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "718e09c9-d128-5cf4-9311-8d6c8171c16d",
              "leftValue": "={{ $json.sigs.includes($json.expectedSignature) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "06576037-3c48-5b4e-9445-5c4dc73b9a51",
              "leftValue": "={{ $json.t !== '' && Math.abs($now.toSeconds() - Number($json.t)) <= 300 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "80efd778-196f-5654-97a8-dc96bb5929b9",
      "name": "Signature valid and recent?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        780,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// delivery_id sits inside the signed body, so a copy cannot change it. Each id is kept until\n// ten minutes after its t, past the five-minute window. Kept between runs only when the\n// workflow is active.\nconst state = $getWorkflowStaticData('global');\nconst seen = state.deliveries || {};\nconst nowS = Math.floor(Date.now() / 1000);\nfor (const id of Object.keys(seen)) if (seen[id] < nowS) delete seen[id];\nconst out = [];\nfor (const item of $input.all()) {\n  let body;\n  try { body = JSON.parse(item.json.rawBody); } catch (e) { continue; }\n  const id = body && body.delivery_id;\n  if (typeof id !== 'string' || !id || seen[id]) continue;\n  seen[id] = Number(item.json.t) + 600;\n  out.push({ json: { body } });\n}\nstate.deliveries = seen;\nreturn out;"
      },
      "id": "1605f306-6444-5e7b-b2a6-4ee6f88d0555",
      "name": "Drop repeated deliveries",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1000,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "85dd0ba5-b317-52dd-8c65-0cdca2c16426",
              "name": "followedPlayerIds",
              "value": "34, 13, 55, 889",
              "type": "string"
            },
            {
              "id": "93634b75-516c-5cd6-a8aa-9229b96403e2",
              "name": "slackChannel",
              "value": "#tennis-alerts",
              "type": "string"
            }
          ]
        },
        "options": {},
        "includeOtherFields": true
      },
      "id": "985607b6-17a9-5992-8c73-c5c613d62170",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1220,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "f035a41f-a363-56cd-be7d-76a15246cd1c",
              "leftValue": "={{ $json.body.type }}",
              "rightValue": "break_point",
              "operator": {
                "type": "string",
                "operation": "startsWith"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a6749b67-f0a5-5794-8de3-58038f99a79a",
      "name": "Keep break-point events",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        1440,
        300
      ]
    },
    {
      "parameters": {
        "resource": "match",
        "operation": "get",
        "matchId": "={{ $json.body.match_id }}"
      },
      "id": "923d79f4-2207-595f-8207-97e7f402e19c",
      "name": "Get the match",
      "type": "n8n-nodes-livetennisapi.liveTennisApi",
      "typeVersion": 1,
      "position": [
        1660,
        300
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst followed = new Set(String(cfg.followedPlayerIds || '').split(',')\n  .map(s => Number(s.trim())).filter(n => n > 0));\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const m = items[i].json;\n  const p1 = (m.players && m.players.p1) || {}, p2 = (m.players && m.players.p2) || {};\n  if (followed.size && !followed.has(p1.id) && !followed.has(p2.id)) continue;\n  // The webhook frame this match was read for.\n  const f = $('Keep break-point events').itemMatching(i).json.body;\n  const name = side => (side === 1 ? p1.name : p2.name);\n  const where = [m.tournament, m.round].filter(Boolean).join(', ');\n  let text;\n  if (f.type === 'break_point') {\n    const n = f.break_points > 1 ? `${f.break_points} break points` : 'Break point';\n    text = `${n} for ${name(f.returner)} against ${name(f.server)}: ${f.point}, ` +\n      `games ${f.game}, sets ${f.set}. ${where}.`;\n  } else {\n    text = f.outcome === 'broken'\n      ? `${name(f.server)} was broken. ${p1.name} v ${p2.name}, ${where}.`\n      : `${name(f.server)} held serve. ${p1.name} v ${p2.name}, ${where}.`;\n  }\n  out.push({ json: { text } });\n}\nreturn out;"
      },
      "id": "2499c402-4794-5567-a121-956396af7036",
      "name": "Keep followed players and write the alert",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1880,
        300
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Config').first().json.slackChannel }}"
        },
        "text": "={{ $json.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "73dbbe6e-bc6a-5af9-9cff-e0595e05f6b4",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2100,
        300
      ],
      "webhookId": "8ab74ddc-1200-5755-9138-d7c93eb441b5"
    },
    {
      "parameters": {},
      "id": "ccf41d15-1bd9-5c7d-ac4e-47290cdfa233",
      "name": "Run once to register the webhook",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -100,
        640
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "6ec00cc0-af36-5314-93bb-2f40908287c6",
              "name": "webhookUrl",
              "value": "https://YOUR-N8N-HOST/webhook/live-tennis-break-points",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "68e7bccd-70e2-554a-9f12-43e1700b4dbc",
      "name": "Webhook URL",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        120,
        640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.livetennisapi.com/api/public/v1/webhooks",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "liveTennisApiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ url: $json.webhookUrl, events: ['break_point'] }) }}",
        "options": {}
      },
      "id": "9fffdbaf-eca5-5c34-b896-3d307fca76da",
      "name": "Register the webhook",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        340,
        640
      ]
    }
  ],
  "connections": {
    "Receive Live Tennis API events": {
      "main": [
        [
          {
            "node": "Read the raw body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the raw body": {
      "main": [
        [
          {
            "node": "Read the signature header",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the signature header": {
      "main": [
        [
          {
            "node": "Sign the time and the raw body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sign the time and the raw body": {
      "main": [
        [
          {
            "node": "Signature valid and recent?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid and recent?": {
      "main": [
        [
          {
            "node": "Drop repeated deliveries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drop repeated deliveries": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Keep break-point events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep break-point events": {
      "main": [
        [
          {
            "node": "Get the match",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get the match": {
      "main": [
        [
          {
            "node": "Keep followed players and write the alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep followed players and write the alert": {
      "main": [
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run once to register the webhook": {
      "main": [
        [
          {
            "node": "Webhook URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook URL": {
      "main": [
        [
          {
            "node": "Register the webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
