This workflow corresponds to n8n.io template #15665 — we link there as the canonical source.
This workflow follows the Google Sheets → Googlesheetstrigger recipe pattern — see all workflows that pair these two integrations.
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 →
{
"name": "Client Portfolio Diversification Check",
"tags": [],
"nodes": [
{
"id": "8b0729e6-3b77-41f4-be6b-4908d0bbe2ca",
"name": "Fetch Portfolio Data",
"type": "n8n-nodes-base.googleSheets",
"position": [
1264,
736
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q/edit#gid=0",
"cachedResultName": "Client Portfolio "
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q/edit?usp=drivesdk",
"cachedResultName": "Client Portfolio Diversification Check-task 3"
}
},
"executeOnce": true,
"typeVersion": 4.7
},
{
"id": "18e4e5ba-6b76-437f-87a0-0a4d24158564",
"name": "Slack Alert: Overexposure Warning",
"type": "n8n-nodes-base.slack",
"position": [
3296,
480
],
"parameters": {
"text": "= Overexposure Alert!\n\nSectors:\n{{ $json.overexposedSectors?.length \n ? $json.overexposedSectors.map(s => s.sector + \" (\" + s.percentage + \"%)\").join(\", \")\n : \"None\"\n}}\n\nRisk Level: {{ $json.riskLevel }}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "list",
"value": "C0APVTBS27Q",
"cachedResultName": "all-ujjwal"
},
"otherOptions": {},
"authentication": "oAuth2"
},
"typeVersion": 2.4
},
{
"id": "7d59a241-69c5-4497-9203-d0052c99abca",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
1024,
352
],
"parameters": {
"color": 7,
"width": 790,
"height": 768,
"content": "## Data Ingestion & Validation\n\nThis section handles data collection and ensures quality before processing.\n\nTrigger workflow every hour from Google Sheets\nFetch latest portfolio records\nNormalize stock, sector and investment fields\nValidate data (remove empty/invalid entries)\nSend Slack alert if data is incorrect"
},
"typeVersion": 1
},
{
"id": "aa23be8d-5442-4110-b653-eae4fddece27",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
1856,
352
],
"parameters": {
"color": 7,
"width": 576,
"height": 560,
"content": "## Portfolio Analysis Engine\n\nThis section performs core financial calculations on the portfolio.\n\nGroup investments by sector\nCalculate sector-wise allocation (%)\nCompute risk score (Low / Medium / High)\nIdentify overexposed sectors (>40%)\nCalculate diversification score"
},
"typeVersion": 1
},
{
"id": "8438d415-a76b-4cbd-a5e1-7f481147c6f5",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
2528,
352
],
"parameters": {
"color": 7,
"width": 630,
"height": 528,
"content": "## AI Insights & Decision Logic\n\nThis section generates intelligent insights and checks conditions.\n\nUse AI to analyze portfolio health\nGenerate insights and recommendations\nExtract AI response for further use\nCheck if portfolio is overexposed"
},
"typeVersion": 1
},
{
"id": "8e865d2e-5946-4432-9c99-6d84ec3cc73a",
"name": "Validate Data (Sector + Investment)",
"type": "n8n-nodes-base.if",
"position": [
1632,
736
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "77667fb9-a090-4d0b-a990-45b1515ff0bc",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json[\"Investment\"] }}",
"rightValue": 0
},
{
"id": "b01612f0-8d2f-4ea9-9809-5eed2092757d",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
},
"leftValue": "={{ $json[\"Sector\"] }}",
"rightValue": ""
},
{
"id": "04dfcd89-0362-4945-8794-fa35fda0423a",
"operator": {
"type": "string",
"operation": "notEquals"
},
"leftValue": "={{ $json[\"Sector\"] }}",
"rightValue": "UNKNOWN"
}
]
}
},
"typeVersion": 2.3
},
{
"id": "ef80e38c-0eff-499c-aa9d-3460b0413b12",
"name": "Portfolio Update (Hourly)",
"type": "n8n-nodes-base.googleSheetsTrigger",
"position": [
1056,
736
],
"parameters": {
"options": {},
"pollTimes": {
"item": [
{
"mode": "everyHour"
}
]
},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q/edit#gid=0",
"cachedResultName": "Client Portfolio "
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q/edit?usp=drivesdk",
"cachedResultName": "Client Portfolio Diversification Check-task 3"
}
},
"typeVersion": 1
},
{
"id": "445cc891-ec79-4fa1-b72d-40f247d59ad9",
"name": "Normalize Portfolio Data",
"type": "n8n-nodes-base.set",
"position": [
1440,
736
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "791d9626-38e6-46b4-b473-9b7132121907",
"name": "Stock",
"type": "string",
"value": "={{ $json[\"Stock\"] ? $json[\"Stock\"].toUpperCase().trim() : \"UNKNOWN\" }}"
},
{
"id": "60106ede-820e-4e31-8a6a-56d92b2ed2a2",
"name": "Sector",
"type": "string",
"value": "={{ $json[\"Sector\"] ? $json[\"Sector\"].toUpperCase().trim() : \"UNKNOWN\" }}"
},
{
"id": "7adb5557-3639-48db-986d-69c560cafde1",
"name": "Investment",
"type": "number",
"value": "={{ Number($json[\"Investment\"].toString().replace(/,/g, \"\")) || 0 }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "a6a4f31d-d7e9-4eb0-8f53-da40799b2f3b",
"name": "Alert: Invalid Data",
"type": "n8n-nodes-base.slack",
"position": [
1664,
976
],
"parameters": {
"text": "=Data Validation Failed!\n\nStock: {{ $json[\"Stock\"] }}\nSector: {{ $json[\"Sector\"] }}\nInvestment: {{ $json[\"Investment\"] }}\n\nReason:\n{{ $json[\"Investment\"] <= 0 ? \"Invalid Investment\" : \"\" }}\n{{ !$json[\"Sector\"] || $json[\"Sector\"] === \"UNKNOWN\" ? \"Invalid Sector\" : \"\" }}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "list",
"value": "C0APVTBS27Q",
"cachedResultName": "all-ujjwal"
},
"otherOptions": {},
"authentication": "oAuth2"
},
"typeVersion": 2.4
},
{
"id": "7dfc2d07-c433-41a1-a9c3-f21c61d387dc",
"name": "Compute: Sector Allocation Breakdown",
"type": "n8n-nodes-base.code",
"position": [
1904,
608
],
"parameters": {
"jsCode": "const data = items.map(item => item.json);\n\n// Step 1: Group by sector\nconst sectorMap = {};\n\nfor (const row of data) {\n const sector = row.Sector;\n const value = parseFloat(row.Investment) || 0;\n\n if (!sectorMap[sector]) {\n sectorMap[sector] = 0;\n }\n\n sectorMap[sector] += value;\n}\n\n// Step 2: Total portfolio\nconst totalPortfolio = Object.values(sectorMap)\n .reduce((sum, val) => sum + val, 0);\n\n// Step 3: Allocation %\nconst allocation = Object.entries(sectorMap).map(([sector, value]) => {\n return {\n sector,\n value,\n percentage: totalPortfolio === 0 \n ? 0 \n : parseFloat(((value / totalPortfolio) * 100).toFixed(2))\n };\n});\n\nreturn [\n {\n json: {\n allocation,\n totalPortfolio\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "5679d3ef-9a10-4d2e-ad8f-aacde3b497ba",
"name": "Compute: Risk Score & Exposure",
"type": "n8n-nodes-base.code",
"position": [
2112,
608
],
"parameters": {
"jsCode": "\nconst data = items[0].json;\n\nconst allocation = data.allocation || [];\n\nlet highestRisk = 0;\nlet riskLevel = \"Low\";\n\nif (allocation.length === 0) {\n return [{\n json: {\n ...data,\n riskScore: 0,\n riskLevel: \"No Data\",\n isOverexposed: false,\n overexposedSectors: []\n }\n }];\n}\n// Loop through each sector\nfor (const sector of allocation) {\n const percentage = sector.percentage;\n\n if (percentage > 50) {\n highestRisk = Math.max(highestRisk, 3);\n } else if (percentage >= 30) {\n highestRisk = Math.max(highestRisk, 2);\n } else {\n highestRisk = Math.max(highestRisk, 1);\n }\n}\n\n// Assign label\nif (highestRisk === 3) {\n riskLevel = \"High\";\n} else if (highestRisk === 2) {\n riskLevel = \"Medium\";\n}\nconst overexposedSectors = allocation.filter(s => s.percentage > 40);\nconst isOverexposed = overexposedSectors.length > 0;\n// Return enriched data\nreturn [\n {\n json: {\n ...data,\n riskScore: highestRisk,\n riskLevel,\n isOverexposed,\n overexposedSectors\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "0903c37d-f952-460d-94fc-42c29b0c9c41",
"name": "Compute: Diversification Score",
"type": "n8n-nodes-base.code",
"position": [
2288,
608
],
"parameters": {
"jsCode": "const data = items[0].json;\nconst allocation = data.allocation || [];\n\n// Number of sectors\nconst numSectors = allocation.length;\n\nif (numSectors === 0) {\n return [{\n json: {\n ...data,\n diversificationScore: 0,\n diversificationLevel: \"Poor\"\n }\n }];\n}\n\n// Balance factor\nlet balanceFactor = 0;\n\nfor (const sector of allocation) {\n const ideal = 100 / numSectors;\n const diff = Math.abs(sector.percentage - ideal);\n balanceFactor += diff;\n}\n\nbalanceFactor = balanceFactor / numSectors;\n\n// Score\nconst diversificationScore = Math.max(0, (100 - balanceFactor)).toFixed(2);\n\n// Label\nlet diversificationLevel = \"Poor\";\n\nif (diversificationScore > 80) {\n diversificationLevel = \"Excellent\";\n} else if (diversificationScore > 60) {\n diversificationLevel = \"Good\";\n} else if (diversificationScore > 40) {\n diversificationLevel = \"Moderate\";\n}\n\nreturn [{\n json: {\n ...data,\n diversificationScore: Number(diversificationScore),\n diversificationLevel\n }\n}];"
},
"typeVersion": 2
},
{
"id": "19918755-b5e5-471e-a9eb-b459898817dc",
"name": "AI: Portfolio Insights Generator",
"type": "@n8n/n8n-nodes-langchain.openAi",
"position": [
2544,
592
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "gpt-4o-mini",
"cachedResultName": "GPT-4O-MINI"
},
"options": {},
"responses": {
"values": [
{
"role": "system",
"content": "You are a senior financial portfolio advisor with expertise in asset allocation, risk management and diversification.\n\nYour job is to analyze client portfolios and provide:\n- Clear, concise insights\n- Risk-aware recommendations\n- Practical, actionable advice\n\nRules:\n- Be professional and client-friendly (non-technical language)\n- Be concise (max 120 words)\n- Do NOT repeat input data\n- Highlight risks clearly if present\n- Always give at least 2 actionable suggestions\n- Avoid generic advice (be specific to allocation)\n- If diversification is poor, suggest specific sectors to rebalance into\n- If overexposed, clearly name the sector\n\nOutput format:\n- 1\u20132 line summary\n- Bullet points for insights\n- Bullet points for recommendations"
},
{
"content": "=Analyze this portfolio:\n\nSector Allocation:\n{{ JSON.stringify($json.allocation, null, 2) }}\n\nRisk Level: {{ $json.riskLevel }}\nDiversification Level: {{ $json.diversificationLevel }}\nOverexposed: {{ $json.isOverexposed }}\n\nProvide:\n\n1. A short summary of portfolio health\n2. Key risks or weaknesses (if any)\n3. Diversification feedback\n4. 2\u20133 specific actionable recommendations"
}
]
},
"builtInTools": {}
},
"typeVersion": 2.1
},
{
"id": "0f0d2f22-a3a1-4c79-96cf-b39762440d41",
"name": "Transform: Extract AI Response",
"type": "n8n-nodes-base.set",
"position": [
2848,
592
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "c026a3ac-9f84-41bd-900b-ce937eb6bc0d",
"name": "ai_response ",
"type": "string",
"value": "= {{ $json.output[0].content[0].text }}\n"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "3994aee7-56d6-4c4e-9d51-ac73c88b20ac",
"name": "Decision: Overexposure Check",
"type": "n8n-nodes-base.if",
"position": [
3040,
592
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "329dd6bc-ccfb-456c-9435-f9e9d986d61f",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $('Compute: Diversification Score').item.json.isOverexposed }}",
"rightValue": "="
}
]
}
},
"typeVersion": 2.3
},
{
"id": "d1eba2ae-c0ab-4b51-a41f-83e36a73b753",
"name": "Format: Final Portfolio Report",
"type": "n8n-nodes-base.code",
"position": [
3520,
608
],
"parameters": {
"jsCode": "const data = items[0].json;\n\nconst allocation = data.allocation || [];\n\nconst report = `\n Portfolio Report\n\nRisk: ${data.riskLevel || \"N/A\"}\nDiversification: ${data.diversificationLevel || \"N/A\"}\n\nAllocation:\n${allocation.length \n ? allocation.map(s => `- ${s.sector}: ${s.percentage}%`).join(\"\\n\")\n : \"No allocation data available\"}\n\nInsights:\n${data.ai_response || \"No insights generated\"}\n`;\n\nreturn [{\n json: {\n ...data,\n report,\n timestamp: new Date().toISOString()\n }\n}];"
},
"typeVersion": 2
},
{
"id": "f6711444-8ad1-4d9d-a963-dbfbe1a02379",
"name": "Store: Save Report to Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"position": [
3776,
512
],
"parameters": {
"columns": {
"value": {
"report": "={{ $('AI: Portfolio Insights Generator').item.json.output[0].content[0].text }}",
"riskLevel": "={{ $('Compute: Diversification Score').item.json.riskLevel }}",
"timestamp": "={{ $json.timestamp }}",
"diversificationLevel": "={{ $('Compute: Diversification Score').item.json.diversificationLevel }}"
},
"schema": [
{
"id": "timestamp",
"type": "string",
"display": true,
"required": false,
"displayName": "timestamp",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "riskLevel",
"type": "string",
"display": true,
"required": false,
"displayName": "riskLevel",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "diversificationLevel",
"type": "string",
"display": true,
"required": false,
"displayName": "diversificationLevel",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "report",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "report",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": 1077112945,
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q/edit#gid=1077112945",
"cachedResultName": "Save Report t-3"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/13DZI16f7_J9_WbVlMyS8Y1Nj-1K6fhtnOBvUPHvk29Q/edit?usp=drivesdk",
"cachedResultName": "Client Portfolio Diversification Check-task 3"
}
},
"typeVersion": 4.7
},
{
"id": "b18a7e1f-8631-456a-ae9f-e8835b81d99b",
"name": "Report: Portfolio Summary",
"type": "n8n-nodes-base.slack",
"position": [
3760,
704
],
"parameters": {
"text": "= *Portfolio Analysis Report*\n\n{{ $('AI: Portfolio Insights Generator').item.json.output[0].content[0].text }}\n\n\u23f1 Generated at: {{$now}}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "list",
"value": "C0APVTBS27Q",
"cachedResultName": "all-ujjwal"
},
"otherOptions": {},
"authentication": "oAuth2"
},
"typeVersion": 2.4
},
{
"id": "c7589f38-a68b-47d7-ab53-fb8eeeb0f36b",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
0,
0
],
"parameters": {
"color": "#F0D02D",
"width": 672,
"height": 864,
"content": "## Client Portfolio Risk & Diversification Analyzer\n\n### Overview\n\nThis workflow automatically analyzes client portfolio data to evaluate risk exposure, sector allocation and diversification quality. It uses AI to generate clear insights and actionable recommendations, while also triggering alerts for risky or overexposed portfolios. Results are saved and shared in real-time for continuous monitoring.\n\n### How It Works\n\nThe workflow runs automatically every hour and performs the following steps:\n\nRetrieves latest portfolio data from Google Sheets\nCleans and validates stock, sector and investment data\nGroups investments by sector and calculates allocation percentages\nEvaluates risk level and diversification score\nUses AI to generate portfolio insights and recommendations\nDetects overexposure and triggers alerts if needed\nFormats a final report\nSends results to Slack and stores them in Google Sheets\n\n### Setup Steps\nTrigger\nRuns automatically every hour via Google Sheets\nFetch & Validate Data\nRetrieve and clean portfolio data (Stock, Sector, Investment)\nCompute Allocation\nGroup by sector and calculate allocation %\nRisk & Diversification Analysis\nCalculate risk level, detect overexposure (>40%) and score diversification\nAI Insights\nGenerate portfolio summary, risks and actionable recommendations\nAlerts\nSend Slack alert if overexposed\nReport & Share\nFormat report and send to Slack\nSave Results\nStore report and metrics in Google Sheets"
},
"typeVersion": 1
},
{
"id": "84d9cd8f-88d2-48e2-a733-fa642b5eccb2",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
3216,
240
],
"parameters": {
"color": 7,
"width": 832,
"height": 704,
"content": "## Alerts, Reporting & Storage\n\nThis section communicates results and saves outputs.\n\nSend Slack alert for overexposure\nFormat final portfolio report\nSend full report to Slack\nSave report and metrics in Google Sheets"
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "",
"connections": {
"Fetch Portfolio Data": {
"main": [
[
{
"node": "Normalize Portfolio Data",
"type": "main",
"index": 0
}
]
]
},
"Normalize Portfolio Data": {
"main": [
[
{
"node": "Validate Data (Sector + Investment)",
"type": "main",
"index": 0
}
]
]
},
"Portfolio Update (Hourly)": {
"main": [
[
{
"node": "Fetch Portfolio Data",
"type": "main",
"index": 0
}
]
]
},
"Decision: Overexposure Check": {
"main": [
[
{
"node": "Slack Alert: Overexposure Warning",
"type": "main",
"index": 0
}
],
[
{
"node": "Format: Final Portfolio Report",
"type": "main",
"index": 0
}
]
]
},
"Compute: Diversification Score": {
"main": [
[
{
"node": "AI: Portfolio Insights Generator",
"type": "main",
"index": 0
}
]
]
},
"Compute: Risk Score & Exposure": {
"main": [
[
{
"node": "Compute: Diversification Score",
"type": "main",
"index": 0
}
]
]
},
"Format: Final Portfolio Report": {
"main": [
[
{
"node": "Store: Save Report to Google Sheets",
"type": "main",
"index": 0
},
{
"node": "Report: Portfolio Summary",
"type": "main",
"index": 0
}
]
]
},
"Transform: Extract AI Response": {
"main": [
[
{
"node": "Decision: Overexposure Check",
"type": "main",
"index": 0
}
]
]
},
"AI: Portfolio Insights Generator": {
"main": [
[
{
"node": "Transform: Extract AI Response",
"type": "main",
"index": 0
}
]
]
},
"Slack Alert: Overexposure Warning": {
"main": [
[
{
"node": "Format: Final Portfolio Report",
"type": "main",
"index": 0
}
]
]
},
"Validate Data (Sector + Investment)": {
"main": [
[
{
"node": "Compute: Sector Allocation Breakdown",
"type": "main",
"index": 0
}
],
[
{
"node": "Alert: Invalid Data",
"type": "main",
"index": 0
}
]
]
},
"Compute: Sector Allocation Breakdown": {
"main": [
[
{
"node": "Compute: Risk Score & Exposure",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This n8n workflow automatically analyzes client portfolio data from Google Sheets, evaluates sector allocation, risk level and diversification quality and generates AI-powered insights. It also sends real-time alerts to Slack for invalid data and overexposed sectors while saving…
Source: https://n8n.io/workflows/15665/ — 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.
Consultants, agencies, freelancers, and professional service firms who need to create customized proposals and contracts quickly and efficiently.
Workflow-Ver1. Uses googleSheetsTrigger, openAi, gmail, googleSheets. Event-driven trigger; 12 nodes.
This template triggers when a new row appears in Google Sheets (from any intake form that writes to the sheet). It validates key fields, performs light deduplication by email or phone, and sends the d
Note: Now includes an Apify alternative for Rapid API (Some users can't create new accounts on Rapid API, so I have added an alternative for you. But immediately you are able to get access to Rapid AP
This system automates LinkedIn lead generation and enrichment in six clear stages: Lead Collection (via Apollo.io) Automatically pulls leads based on keywords, roles, or industries using Apollo’s API.