{
  "name": "Credential monitor: catch a token before it dies",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 7
            }
          ]
        }
      },
      "id": "d3c0e1f2-0003-4a10-9c01-000000000001",
      "name": "Daily 07:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [-540, 0]
    },
    {
      "parameters": {
        "jsCode": "// One entry per connected account, not per credential type. If four mailboxes\n// are connected, list four probes. A single dead account inside a set is the\n// failure that hides longest, because the run still reports success.\n//\n// Every probe should be a cheap, read-only endpoint that requires the same\n// scopes the real workflow uses. Hitting an endpoint with narrower scopes\n// passes while the workflow still fails.\n\nreturn [{\n  json: {\n    probes: [\n      {\n        service: 'Google (sales@ mailbox)',\n        url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile',\n        token: '={{ $env.GOOGLE_SALES_ACCESS_TOKEN }}',\n        note: 'Dies on password change, scope change, or app re-consent. Testing-mode apps expire in 7 days.'\n      },\n      {\n        service: 'Microsoft 365 (ops@ mailbox)',\n        url: 'https://graph.microsoft.com/v1.0/me',\n        token: '={{ $env.MS_OPS_ACCESS_TOKEN }}',\n        note: 'Conditional access can revoke this mid-session. Lifetime depends on tenant policy.'\n      },\n      {\n        service: 'Slack (workspace bot)',\n        url: 'https://slack.com/api/auth.test',\n        token: '={{ $env.SLACK_BOT_TOKEN }}',\n        note: 'Returns HTTP 200 with ok:false when revoked. Status code alone will not catch it.'\n      },\n      {\n        service: 'HubSpot (marketing portal)',\n        url: 'https://api.hubapi.com/oauth/v1/access-tokens/REPLACE_WITH_TOKEN',\n        token: '',\n        note: 'This endpoint reports the remaining lifetime, so you can alert before expiry.'\n      }\n    ]\n  }\n}];"
      },
      "id": "d3c0e1f2-0003-4a10-9c01-000000000002",
      "name": "Accounts To Probe",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-320, 0]
    },
    {
      "parameters": {
        "fieldToSplitOut": "probes",
        "options": {}
      },
      "id": "d3c0e1f2-0003-4a10-9c01-000000000003",
      "name": "One Item Per Account",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [-100, 0]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "Authorization", "value": "=Bearer {{ $json.token }}" },
            { "name": "accept", "value": "application/json" }
          ]
        },
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        }
      },
      "id": "d3c0e1f2-0003-4a10-9c01-000000000004",
      "name": "Probe Account",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [120, 0],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Emit ONE item per unhealthy account. Healthy accounts produce nothing, so a\n// quiet morning is a real signal rather than an alert you have learned to skip.\n\nconst out = [];\nconst probes = $('One Item Per Account').all();\n\nfor (let i = 0; i < items.length; i++) {\n  const res = items[i].json || {};\n  const probe = probes[i].json;\n  const code = Number(res.statusCode || 0);\n  const body = res.body || {};\n\n  let state = 'ok';\n  let detail = '';\n\n  if (code === 401) {\n    state = 'dead';\n    detail = 'HTTP 401. The token is expired or revoked. Reconnect the credential in n8n.';\n  } else if (code === 403) {\n    state = 'dead';\n    detail = 'HTTP 403. It authenticated but is missing a scope, or lost access to the resource.';\n  } else if (code === 0 || code >= 500) {\n    state = 'unknown';\n    detail = 'No usable response (status ' + code + '). Could be their outage. Worth a look if it repeats tomorrow.';\n  } else if (body && body.ok === false) {\n    // Slack and several others answer 200 and hide the failure in the body.\n    state = 'dead';\n    detail = 'HTTP 200 with ok:false (' + (body.error || 'no error field') + '). Status code alone would have missed this.';\n  }\n\n  // HubSpot's token introspection reports remaining seconds. Warn early rather\n  // than waiting for the failure, which is the entire point of running this.\n  const expiresIn = Number(body.expires_in || 0);\n  if (state === 'ok' && expiresIn > 0 && expiresIn < 60 * 60 * 24 * 3) {\n    state = 'expiring';\n    detail = 'Valid, but expires in about ' + Math.round(expiresIn / 3600) + ' hours.';\n  }\n\n  if (state !== 'ok') {\n    out.push({\n      json: {\n        service: probe.service,\n        state,\n        statusCode: code,\n        detail,\n        note: probe.note || ''\n      }\n    });\n  }\n}\n\nreturn out;"
      },
      "id": "d3c0e1f2-0003-4a10-9c01-000000000005",
      "name": "Keep Only Failures",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [340, 0]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://hooks.slack.com/services/REPLACE/WITH/YOUR_WEBHOOK",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ($json.state === 'expiring' ? ':hourglass: ' : ':key: ') + $json.service + ' credential ' + $json.state, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: '*' + $json.service + '* is `' + $json.state + '`\\n' + $json.detail } }, { type: 'context', elements: [ { type: 'mrkdwn', text: $json.note } ] } ] }) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "d3c0e1f2-0003-4a10-9c01-000000000006",
      "name": "Alert Slack",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [560, 0],
      "onError": "continueRegularOutput"
    }
  ],
  "connections": {
    "Daily 07:00": {
      "main": [[{ "node": "Accounts To Probe", "type": "main", "index": 0 }]]
    },
    "Accounts To Probe": {
      "main": [[{ "node": "One Item Per Account", "type": "main", "index": 0 }]]
    },
    "One Item Per Account": {
      "main": [[{ "node": "Probe Account", "type": "main", "index": 0 }]]
    },
    "Probe Account": {
      "main": [[{ "node": "Keep Only Failures", "type": "main", "index": 0 }]]
    },
    "Keep Only Failures": {
      "main": [[{ "node": "Alert Slack", "type": "main", "index": 0 }]]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "instanceId": "shared-template"
  }
}
