{
  "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. Create a **Header Auth** credential. Its Name is `X-API-Key` and its Value is your Live Tennis API key. Select it on every HTTP Request 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": "85f9ae84-f8f4-5d06-8db2-a515c4c1fac8",
      "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": "caf42814-063e-57be-a3c7-2ffb2e783a07",
      "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": "4dd7fa11-287d-5694-bdbc-cf70e1f0da7a",
      "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": "b25e8cac-f9e0-5f35-9910-6ec724f64770",
      "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": "e8f69193-4942-5570-be96-768a67b6fdf6",
      "name": "Receive Live Tennis API events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -100,
        300
      ],
      "webhookId": "11b176c8-5f1a-5830-8da2-058b0383b1cd"
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {
          "keepSource": "json"
        }
      },
      "id": "f240696e-31a8-51d5-b451-1d3bd9a9c213",
      "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": "2728a77c-e23e-50ef-a478-3579a355b370",
      "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": "5758fe72-56a1-513a-8fbf-cbaa5a8a878c",
      "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": "0b1968a6-f029-582b-9d50-da84c0666c67",
              "leftValue": "={{ $json.sigs.includes($json.expectedSignature) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "ff761651-c602-56d2-be05-0ba8fe0486da",
              "leftValue": "={{ $json.t !== '' && Math.abs($now.toSeconds() - Number($json.t)) <= 300 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "ff4b3e52-1156-5ec0-89bf-2f17324590a5",
      "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": "c4ee75f4-105c-5c69-9852-168db68b3f67",
      "name": "Drop repeated deliveries",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1000,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "0dc49063-158b-5416-a187-00a025779bf5",
              "name": "followedPlayerIds",
              "value": "34, 13, 55, 889",
              "type": "string"
            },
            {
              "id": "f82384bb-094d-5ace-a83f-d7e27db7e5e8",
              "name": "slackChannel",
              "value": "#tennis-alerts",
              "type": "string"
            }
          ]
        },
        "options": {},
        "includeOtherFields": true
      },
      "id": "47974bba-3ece-59fd-b6a6-0141033570a6",
      "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": "4d42b084-b9f4-5455-bcab-1ec129649cd7",
              "leftValue": "={{ $json.body.type }}",
              "rightValue": "break_point",
              "operator": {
                "type": "string",
                "operation": "startsWith"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "b706c02d-f138-5ce6-8cba-ad47bc1ebf7c",
      "name": "Keep break-point events",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        1440,
        300
      ]
    },
    {
      "parameters": {
        "url": "=https://api.livetennisapi.com/api/public/v1/matches/{{ $json.body.match_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "id": "8fccc957-7a19-555d-a369-4a682a5b6726",
      "name": "Get the match",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "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": "72ba55cf-9d8c-5b6a-af04-c81e16d7ac77",
      "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": "9347b809-0caf-5b40-a16f-3bf97333b2ea",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2100,
        300
      ],
      "webhookId": "f600d450-0fb8-5b7f-8d00-b566b61cc241"
    },
    {
      "parameters": {},
      "id": "b306ba83-bb48-5f1c-8665-a2a832480dac",
      "name": "Run once to register the webhook",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -100,
        640
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "b91f4713-aedb-51af-b4da-ebb3d484116b",
              "name": "webhookUrl",
              "value": "https://YOUR-N8N-HOST/webhook/live-tennis-break-points",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "b294addb-a2cb-5130-aebc-8bdbe3224222",
      "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": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ url: $json.webhookUrl, events: ['break_point'] }) }}",
        "options": {}
      },
      "id": "bce573e5-be78-5d65-8f6a-58d13dd84a43",
      "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
  }
}
