The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "token-check",
"name": "token-check",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 * * *"
}
]
}
},
"id": "schedule-trigger",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [
240,
200
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "token-check",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
460
]
},
{
"parameters": {
"workflowId": "hmac-verify",
"options": {},
"workflowInputs": {
"value": {
"headers": "={{ $json.headers }}",
"body": "={{ $json.body }}",
"rawBody": "={{ $json.body ? JSON.stringify($json.body) : '' }}"
}
}
},
"id": "hmac-check",
"name": "HMAC Verify",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1,
"position": [
460,
460
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "05af1013-37eb-4119-9314-d3ddfd1f63cd",
"leftValue": "={{ $json.verified }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
}
},
"id": "if-verified",
"name": "Is Verified?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
680,
460
]
},
{
"parameters": {
"jsCode": "// T040-T042: Dual-token tracking, automated refresh, refresh token monitoring\n// LinkedIn access tokens: 60-day TTL\n// LinkedIn refresh tokens: 365-day TTL\n// Consolidated logic shared by Schedule and Webhook paths\n\nconst staticData = $getWorkflowStaticData('global');\n\n// 019: Read hook token from Static Data (not $env)\nconst hookToken = staticData.hookToken || '';\nconst now = new Date();\nconst ONE_DAY_MS = 1000 * 60 * 60 * 24;\n\n// --- Initialize timestamps on first run ---\nif (!staticData.access_token_granted_at) {\n // Migrate from legacy grant_timestamp if present\n if (staticData.grant_timestamp) {\n staticData.access_token_granted_at = staticData.grant_timestamp;\n staticData.refresh_token_granted_at = staticData.grant_timestamp;\n delete staticData.grant_timestamp;\n } else {\n staticData.access_token_granted_at = now.toISOString();\n staticData.refresh_token_granted_at = now.toISOString();\n }\n}\nif (!staticData.refresh_token_granted_at) {\n staticData.refresh_token_granted_at = staticData.access_token_granted_at;\n}\n\n// --- Compute days remaining for both tokens ---\nconst accessGrantDate = new Date(staticData.access_token_granted_at);\nconst refreshGrantDate = new Date(staticData.refresh_token_granted_at);\n\nconst accessDaysSinceGrant = Math.floor((now - accessGrantDate) / ONE_DAY_MS);\nconst refreshDaysSinceGrant = Math.floor((now - refreshGrantDate) / ONE_DAY_MS);\n\nconst access_token_days_remaining = Math.max(60 - accessDaysSinceGrant, 0);\nconst refresh_token_days_remaining = Math.max(365 - refreshDaysSinceGrant, 0);\n\nconst accessExpiryDate = new Date(accessGrantDate);\naccessExpiryDate.setDate(accessExpiryDate.getDate() + 60);\n\nconst refreshExpiryDate = new Date(refreshGrantDate);\nrefreshExpiryDate.setDate(refreshExpiryDate.getDate() + 365);\n\n// --- Initialize retry and flag state ---\nif (typeof staticData.refresh_retry_count !== 'number') {\n staticData.refresh_retry_count = 0;\n}\nif (typeof staticData.refresh_in_progress !== 'boolean') {\n staticData.refresh_in_progress = false;\n}\nif (typeof staticData.refresh_token_expired !== 'boolean') {\n staticData.refresh_token_expired = false;\n}\n\n// --- T042: Refresh token expiry monitoring ---\nlet alert_type = null;\nlet alert_message = null;\nlet alert_severity = 'info';\n\nif (refresh_token_days_remaining <= 0) {\n staticData.refresh_token_expired = true;\n alert_type = 'refresh_token_expired';\n alert_message = 'LinkedIn refresh token has expired. Manual re-authorization required.';\n alert_severity = 'critical';\n} else if (refresh_token_days_remaining <= 30) {\n alert_type = 'refresh_token_expiring';\n alert_message = `LinkedIn refresh token expires in ${refresh_token_days_remaining} days (${refreshExpiryDate.toISOString().split('T')[0]}). Plan re-authorization.`;\n alert_severity = 'warning';\n}\n\n// --- T041: Determine if automated refresh is needed ---\nlet should_refresh = false;\n\nif (access_token_days_remaining <= 7\n && !staticData.refresh_in_progress\n && !staticData.refresh_token_expired\n && refresh_token_days_remaining > 0) {\n should_refresh = true;\n}\n\n// --- T041: Access token alert (only if refresh not possible) ---\nif (access_token_days_remaining <= 7 && !should_refresh && !alert_type) {\n alert_type = 'token_expiring';\n alert_message = `LinkedIn access token expires in ${access_token_days_remaining} days (${accessExpiryDate.toISOString().split('T')[0]}). Automated refresh unavailable \u2014 manual re-authorization required.`;\n alert_severity = 'warning';\n}\n\n// Determine overall status\nlet status = 'healthy';\nif (refresh_token_days_remaining <= 0) {\n status = 'refresh_token_expired';\n} else if (access_token_days_remaining <= 0) {\n status = 'expired';\n} else if (access_token_days_remaining <= 7) {\n status = 'expiring_soon';\n} else if (refresh_token_days_remaining <= 30) {\n status = 'refresh_expiring_soon';\n}\n\n// Set refresh_in_progress flag before passing to refresh node\nif (should_refresh) {\n staticData.refresh_in_progress = true;\n}\n\nreturn [{\n json: {\n status,\n access_token_days_remaining,\n access_token_grant_date: accessGrantDate.toISOString().split('T')[0],\n access_token_expiry_date: accessExpiryDate.toISOString().split('T')[0],\n refresh_token_days_remaining,\n refresh_token_grant_date: refreshGrantDate.toISOString().split('T')[0],\n refresh_token_expiry_date: refreshExpiryDate.toISOString().split('T')[0],\n should_refresh,\n refresh_in_progress: staticData.refresh_in_progress,\n refresh_token_expired: staticData.refresh_token_expired,\n alert_needed: !!alert_type,\n alert_type,\n alert_message,\n alert_severity\n }\n}];"
},
"id": "check-token",
"name": "Check Token",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
200
],
"notes": "T040-T042: Consolidated dual-token check. Computes days remaining for access (60d) and refresh (365d) tokens. Sets should_refresh when access token <= 7 days and refresh token is valid."
},
{
"parameters": {
"jsCode": "// Check Token (Webhook) \u2014 delegates to same logic as Schedule path\n// This node exists because the webhook path needs its own entry point\n// but the logic is identical to the Schedule path's Check Token node.\n\nconst staticData = $getWorkflowStaticData('global');\nconst now = new Date();\nconst ONE_DAY_MS = 1000 * 60 * 60 * 24;\n\n// --- Initialize timestamps on first run ---\nif (!staticData.access_token_granted_at) {\n if (staticData.grant_timestamp) {\n staticData.access_token_granted_at = staticData.grant_timestamp;\n staticData.refresh_token_granted_at = staticData.grant_timestamp;\n delete staticData.grant_timestamp;\n } else {\n staticData.access_token_granted_at = now.toISOString();\n staticData.refresh_token_granted_at = now.toISOString();\n }\n}\nif (!staticData.refresh_token_granted_at) {\n staticData.refresh_token_granted_at = staticData.access_token_granted_at;\n}\n\nconst accessGrantDate = new Date(staticData.access_token_granted_at);\nconst refreshGrantDate = new Date(staticData.refresh_token_granted_at);\n\nconst accessDaysSinceGrant = Math.floor((now - accessGrantDate) / ONE_DAY_MS);\nconst refreshDaysSinceGrant = Math.floor((now - refreshGrantDate) / ONE_DAY_MS);\n\nconst access_token_days_remaining = Math.max(60 - accessDaysSinceGrant, 0);\nconst refresh_token_days_remaining = Math.max(365 - refreshDaysSinceGrant, 0);\n\nconst accessExpiryDate = new Date(accessGrantDate);\naccessExpiryDate.setDate(accessExpiryDate.getDate() + 60);\n\nconst refreshExpiryDate = new Date(refreshGrantDate);\nrefreshExpiryDate.setDate(refreshExpiryDate.getDate() + 365);\n\nlet status = 'healthy';\nif (refresh_token_days_remaining <= 0) {\n status = 'refresh_token_expired';\n} else if (access_token_days_remaining <= 0) {\n status = 'expired';\n} else if (access_token_days_remaining <= 7) {\n status = 'expiring_soon';\n} else if (refresh_token_days_remaining <= 30) {\n status = 'refresh_expiring_soon';\n}\n\nreturn [{\n json: {\n status,\n access_token_days_remaining,\n access_token_grant_date: accessGrantDate.toISOString().split('T')[0],\n access_token_expiry_date: accessExpiryDate.toISOString().split('T')[0],\n refresh_token_days_remaining,\n refresh_token_grant_date: refreshGrantDate.toISOString().split('T')[0],\n refresh_token_expiry_date: refreshExpiryDate.toISOString().split('T')[0],\n refresh_token_expired: !!staticData.refresh_token_expired,\n last_refresh_result: staticData.last_refresh_result || 'not_attempted'\n }\n}];"
},
"id": "check-token-webhook",
"name": "Check Token (Webhook)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
400
],
"notes": "Webhook path: returns dual-token status as JSON response. Read-only \u2014 does not trigger refresh (refresh is schedule-only)."
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify($json) }}",
"options": {
"responseCode": 200
}
},
"id": "respond-success",
"name": "Respond Success",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
1120,
400
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: 'Unauthorized', details: $json.error }) }}",
"options": {
"responseCode": 401
}
},
"id": "respond-401",
"name": "Reject 401",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
900,
580
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "c1a2b3d4-5678-9012-abcd-ef0123456789",
"leftValue": "={{ $json.should_refresh }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
}
},
"id": "if-should-refresh",
"name": "Should Refresh?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
680,
200
],
"notes": "T041: Routes to OAuth refresh when access token <= 7 days and refresh token is valid."
},
{
"parameters": {
"method": "POST",
"url": "https://www.linkedin.com/oauth/v2/accessToken",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
]
},
"sendBody": true,
"contentType": "form-urlencoded",
"bodyParameters": {
"parameters": [
{
"name": "grant_type",
"value": "refresh_token"
},
{
"name": "refresh_token",
"value": "={{ $credentials.linkedInOAuth2Api.refreshToken }}"
},
{
"name": "client_id",
"value": "={{ $credentials.linkedInOAuth2Api.clientId }}"
},
{
"name": "client_secret",
"value": "={{ $credentials.linkedInOAuth2Api.clientSecret }}"
}
]
},
"options": {
"response": {
"response": {
"fullResponse": true
}
},
"timeout": 30000
}
},
"id": "refresh-token-request",
"name": "Refresh Access Token",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
900,
140
],
"notes": "T041: POST to LinkedIn OAuth endpoint to refresh the access token. Uses n8n credential expressions for client_id, client_secret, and refresh_token.",
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// T041: Handle OAuth refresh response\n// Processes success and error cases from the LinkedIn token refresh\n\nconst staticData = $getWorkflowStaticData('global');\nconst input = $input.first().json;\nconst now = new Date();\n\n// Clear refresh_in_progress flag\nstaticData.refresh_in_progress = false;\n\n// Check if the HTTP request succeeded\nconst statusCode = input.statusCode || (input.headers ? 200 : 0);\nconst body = input.body || input;\n\nlet result = {\n refresh_success: false,\n alert_needed: false,\n alert_type: null,\n alert_message: null,\n alert_severity: 'error'\n};\n\nif (statusCode >= 200 && statusCode < 300 && body.access_token) {\n // --- SUCCESS: Update grant timestamp ---\n staticData.access_token_granted_at = now.toISOString();\n staticData.last_refresh_attempt = now.toISOString();\n staticData.last_refresh_result = 'success';\n staticData.refresh_retry_count = 0;\n\n result.refresh_success = true;\n result.alert_needed = false;\n result.new_access_token_expiry = new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];\n\n} else {\n // --- FAILURE: Classify error and determine retry/alert ---\n staticData.last_refresh_attempt = now.toISOString();\n staticData.last_refresh_result = 'failed';\n\n const errorType = body.error || '';\n const errorDescription = body.error_description || '';\n\n if (errorType === 'invalid_grant') {\n // Refresh token revoked or expired \u2014 no retry, alert immediately\n staticData.refresh_token_expired = true;\n staticData.refresh_retry_count = 0;\n result.alert_needed = true;\n result.alert_type = 'token_refresh_failed';\n result.alert_message = `LinkedIn token refresh failed: refresh token revoked or expired. Manual re-authorization required. (${errorDescription})`;\n result.alert_severity = 'critical';\n\n } else if (errorType === 'invalid_client') {\n // Client credentials wrong \u2014 no retry, alert immediately\n staticData.refresh_retry_count = 0;\n result.alert_needed = true;\n result.alert_type = 'token_refresh_failed';\n result.alert_message = `LinkedIn token refresh failed: invalid client credentials. Verify client_id and client_secret in n8n LinkedIn OAuth2 credential. (${errorDescription})`;\n result.alert_severity = 'critical';\n\n } else if (statusCode >= 500) {\n // Server error \u2014 retry up to 3 times\n staticData.refresh_retry_count = (staticData.refresh_retry_count || 0) + 1;\n if (staticData.refresh_retry_count >= 3) {\n result.alert_needed = true;\n result.alert_type = 'token_refresh_failed';\n result.alert_message = `LinkedIn token refresh failed after 3 retries (HTTP ${statusCode}). Server-side error \u2014 will retry on next scheduled run.`;\n result.alert_severity = 'error';\n staticData.refresh_retry_count = 0;\n } else {\n // Will retry on next scheduled run\n result.alert_needed = false;\n result.retry_pending = true;\n result.retry_count = staticData.refresh_retry_count;\n }\n\n } else {\n // Network error or unknown \u2014 retry once then alert\n staticData.refresh_retry_count = (staticData.refresh_retry_count || 0) + 1;\n if (staticData.refresh_retry_count >= 2) {\n result.alert_needed = true;\n result.alert_type = 'token_refresh_failed';\n result.alert_message = `LinkedIn token refresh failed after retry (${errorType || 'network/unknown error'}). ${errorDescription || 'Check network connectivity and LinkedIn API status.'}`;\n result.alert_severity = 'error';\n staticData.refresh_retry_count = 0;\n } else {\n result.alert_needed = false;\n result.retry_pending = true;\n result.retry_count = staticData.refresh_retry_count;\n }\n }\n}\n\nreturn [{ json: result }];"
},
"id": "handle-refresh-response",
"name": "Handle Refresh Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
140
],
"notes": "T041: Classifies refresh result \u2014 updates Static Data on success, implements circuit breaker and retry logic on failure."
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "aabb1122-3344-5566-7788-99aabbccddee",
"leftValue": "={{ $json.alert_needed }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
}
},
"id": "if-alert-needed",
"name": "Alert Needed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1340,
200
]
},
{
"parameters": {
"url": "http://host.docker.internal:18789/hooks/agent",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $json.hookToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "payload",
"value": "={{ JSON.stringify({ type: 'alert', alert_type: $json.alert_type || 'token_expiring', message: $json.alert_message || ('LinkedIn OAuth token status: ' + $json.status), severity: $json.alert_severity || 'warning', access_token_days_remaining: $json.access_token_days_remaining, refresh_token_days_remaining: $json.refresh_token_days_remaining, access_token_expiry_date: $json.access_token_expiry_date }) }}"
}
]
},
"options": {}
},
"id": "send-alert",
"name": "Alert OpenClaw",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
1560,
140
],
"notes": "Sends token lifecycle alerts to OpenClaw inbound hook. Alert types: token_expiring, refresh_token_expiring, refresh_token_expired, token_refresh_failed."
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Check Token",
"type": "main",
"index": 0
}
]
]
},
"Check Token": {
"main": [
[
{
"node": "Should Refresh?",
"type": "main",
"index": 0
}
]
]
},
"Should Refresh?": {
"main": [
[
{
"node": "Refresh Access Token",
"type": "main",
"index": 0
}
],
[
{
"node": "Alert Needed?",
"type": "main",
"index": 0
}
]
]
},
"Refresh Access Token": {
"main": [
[
{
"node": "Handle Refresh Response",
"type": "main",
"index": 0
}
]
]
},
"Handle Refresh Response": {
"main": [
[
{
"node": "Alert Needed?",
"type": "main",
"index": 0
}
]
]
},
"Alert Needed?": {
"main": [
[
{
"node": "Alert OpenClaw",
"type": "main",
"index": 0
}
],
[]
]
},
"Webhook": {
"main": [
[
{
"node": "HMAC Verify",
"type": "main",
"index": 0
}
]
]
},
"HMAC Verify": {
"main": [
[
{
"node": "Is Verified?",
"type": "main",
"index": 0
}
]
]
},
"Is Verified?": {
"main": [
[
{
"node": "Check Token (Webhook)",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject 401",
"type": "main",
"index": 0
}
]
]
},
"Check Token (Webhook)": {
"main": [
[
{
"node": "Respond Success",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": {
"notes": "T040-T042: LinkedIn OAuth dual-token lifecycle manager. Tracks both access tokens (60-day TTL) and refresh tokens (365-day TTL). Schedule path (daily 09:00): checks token status, auto-refreshes access token when <= 7 days remaining, alerts on refresh token expiry (30-day warning), circuit breaker on refresh failure. Webhook path: returns read-only token status. Grant timestamps stored in Workflow Static Data."
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
token-check. Uses httpRequest. Scheduled trigger; 13 nodes.
Source: https://github.com/traylorre/openclaw-mac/blob/d5ef2bd70e230b1877d7165d35c2e27947b269e0/workflows/token-check.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Birthday Automation - Production (Fixed). Uses stopAndError, httpRequest, emailSend, bannerbear. Scheduled trigger; 86 nodes.
This template runs two scheduled workflows to govern Microsoft Entra ID (Azure AD) guest accounts by detecting stale users via Microsoft Graph, staging deletions in SharePoint with a 72-hour window, n
Jira-Allure-Auto-Qa. Uses httpRequest, jira. Scheduled trigger; 68 nodes.
Spotify-Sync-Surrealdb-V1. Uses httpRequest, n8n-nodes-surrealdb, spotify. Scheduled trigger; 62 nodes.
As n8n instances scale, teams often lose track of sub-workflows—who uses them, where they are referenced, and whether they can be safely updated. This leads to inefficiencies like unnecessary copies o