{ "nodes": [ { "id": "a1f2c3d4-0001-4a1b-8c2d-100000000001", "name": "Sticky Note", "type": "n8n-nodes-base.stickyNote", "position": [ 440, 40 ], "parameters": { "width": 520, "height": 520, "content": "## Live tennis match announcements\n\nEvery five minutes, read only the first page of 50 live matches. This requires 288 API requests per day plus manual tests; the documented free allowance of 100/day is insufficient. Select an X-API-Key Header Auth credential and a Discord Bot account/server/channel.\n\nValidate records and collapse same-page IDs before staging node-scoped static history. New IDs persist only when the complete published, triggered workflow succeeds. A partial failed send can cause earlier messages to repeat on the next poll. Manual tests do not persist static data. No workflow retries or exactly-once delivery guarantee. Inspect/reset the 10,000-entry history when needed. See README for pagination and capacity behavior.\n\nOriginal template contributed by the team behind Live Tennis API, with affiliation disclosed." }, "typeVersion": 1 }, { "id": "a1f2c3d4-0002-4a1b-8c2d-100000000002", "name": "Schedule Trigger", "type": "n8n-nodes-base.scheduleTrigger", "position": [ 1020, 280 ], "parameters": { "rule": { "interval": [ { "field": "cronExpression", "expression": "*/5 * * * *" } ] } }, "typeVersion": 1.2 }, { "id": "a1f2c3d4-0003-4a1b-8c2d-100000000003", "name": "Get Live Matches", "type": "n8n-nodes-base.httpRequest", "position": [ 1240, 280 ], "parameters": { "method": "GET", "url": "https://api.livetennisapi.com/api/public/v1/matches", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "status", "value": "live" }, { "name": "limit", "value": "50" }, { "name": "offset", "value": "0" } ] }, "options": { "timeout": 300000, "response": { "response": { "responseFormat": "json" } } } }, "typeVersion": 4.2 }, { "id": "a1f2c3d4-0005-4a1b-8c2d-100000000005", "name": "Stage Unseen Match IDs", "type": "n8n-nodes-base.code", "position": [ 1680, 280 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const items=$input.all(); const state=$getWorkflowStaticData('node');\nconst previous=state.matchIds??[];\nif(!Array.isArray(previous)||previous.some(id=>typeof id!=='string')||new Set(previous).size!==previous.length)throw new Error('Invalid match history.');\nconst seen=new Set(previous), fresh=items.filter(item=>!seen.has(item.json.id));\nif(seen.size+fresh.length>10000)throw new Error('Match history is full; inspect and reset history before continuing.');\nif(fresh.length)state.matchIds=[...previous,...fresh.map(item=>item.json.id)];\nreturn fresh;\n" }, "typeVersion": 2 }, { "id": "a1f2c3d4-0006-4a1b-8c2d-100000000006", "name": "Discord", "type": "n8n-nodes-base.discord", "position": [ 1900, 280 ], "parameters": { "authentication": "botToken", "resource": "message", "operation": "send", "guildId": { "__rl": true, "mode": "id", "value": "YOUR_DISCORD_SERVER_ID" }, "sendTo": "channel", "channelId": { "__rl": true, "mode": "id", "value": "YOUR_DISCORD_CHANNEL_ID" }, "content": "={{ $json.text }}", "options": {} }, "typeVersion": 2 }, { "id": "check-tennis-page", "name": "Check and Format Matches", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1460, 280 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const prepare = function prepareMatches({ body }) {\n if (!body || typeof body !== 'object' || Array.isArray(body) || !Array.isArray(body.data)) {\n throw new Error('Live Tennis API must return an object with a data array.');\n }\n if (body.data.length > 50) throw new Error('The first match page exceeds the configured 50-item limit.');\n const hasMore = body.meta?.has_more ?? null;\n if (hasMore !== null && typeof hasMore !== 'boolean') throw new Error('Invalid match pagination metadata.');\n const matches = [], ids = new Set();\n for (const match of body.data) {\n if (!match || typeof match !== 'object' || Array.isArray(match) || !Number.isSafeInteger(match.id) || match.id < 1) {\n throw new Error('Every match must have a positive integer ID.');\n }\n const p1 = match.players?.p1?.name, p2 = match.players?.p2?.name, status = match.status;\n if ([p1, p2, status].some(value => typeof value !== 'string' || !value.trim())) {\n throw new Error('Every match must have both player names and a status.');\n }\n const text = `🎾 New live match: ${p1} vs ${p2} — ${status}`;\n if (text.length > 2000) throw new Error('A match announcement exceeds Discord’s 2000-character limit.');\n const id = String(match.id);\n if (ids.has(id)) continue;\n ids.add(id);\n matches.push({ id, text });\n }\n return { matches, received: body.data.length, unique: matches.length, hasMore };\n}\n\nreturn prepare({body:$input.first().json}).matches.map(match=>({json:match}));\n" } } ], "pinData": {}, "connections": { "Schedule Trigger": { "main": [ [ { "node": "Get Live Matches", "type": "main", "index": 0 } ] ] }, "Get Live Matches": { "main": [ [ { "node": "Check and Format Matches", "type": "main", "index": 0 } ] ] }, "Check and Format Matches": { "main": [ [ { "node": "Stage Unseen Match IDs", "type": "main", "index": 0 } ] ] }, "Stage Unseen Match IDs": { "main": [ [ { "node": "Discord", "type": "main", "index": 0 } ] ] } }, "name": "Live Tennis Discord Alert", "settings": { "timezone": "UTC", "executionOrder": "v1" }, "active": false }