Read-only JSON over HTTPS, for n8n, Make, Zapier, spreadsheets and your own code. The full machine-readable description is at /api/v1/openapi.json (OpenAPI 3.1); import it into Postman or any tool that reads OpenAPI.
Create a key under API keys in your organization (owners and admins). Each key is limited to the sites and providers you choose, and is shown once. Send it as a bearer token:
Authorization: Bearer plk_live_...Keep keys out of browsers and public repositories. Revoking a key stops it on its next request.
| Request | Returns |
|---|---|
GET /api/v1/sites | The sites the key can read, each with the providers (gsc, ga4) it has linked. |
GET /api/v1/reports | The reports the key may run. |
GET /api/v1/sites/{siteId}/reports/{report} | One report for one site. |
| Parameter | Meaning |
|---|---|
range | last_7_days, last_28_days, last_90_days, last_365_days. Default last_28_days. Presets end yesterday in the source's time zone (the GA4 property's, or US Pacific for Search Console). |
start, end | Explicit dates, YYYY-MM-DD, instead of a range. Search Console keeps 16 months; GA4 typically 14. |
limit, offset | Paging for ranked reports. The next page's offset is in meta.paging.nextOffset. By-day reports always return every day of the range. |
| Report | Data | Rows (default / max) |
|---|---|---|
gsc.overview | Clicks, impressions, CTR and average position from Google Search, per day, with totals. | one per day |
gsc.queries | The searches the site appeared for, by clicks. | 25 / 1000 |
gsc.pages | The site's pages that appeared in Google Search, by clicks. | 25 / 1000 |
gsc.countries | Clicks and impressions by searcher country (ISO 3166-1 alpha-3). | 25 / 250 |
gsc.devices | Clicks and impressions on desktop, mobile and tablet. | 10 / 10 |
ga4.overview | Active users, sessions, engaged sessions, engagement rate, views and key events, per day, with totals. | one per day |
ga4.channels | Sessions and users by default channel group (Organic Search, Direct, Paid Search, ...). | 25 / 100 |
ga4.sources | Sessions and users by source and medium, e.g. google / organic. | 25 / 1000 |
ga4.landing_pages | Pages where sessions started, by sessions. | 25 / 1000 |
ga4.pages | Pages by views, with users and total engagement time (seconds). | 25 / 1000 |
curl "https://portylink.com/api/v1/sites/<site-id>/reports/gsc.queries?range=last_28_days&limit=3" \
-H "Authorization: Bearer plk_live_..."{
"data": {
"rows": [
{ "query": "running shoes", "clicks": 412, "impressions": 9120, "ctr": 0.0452, "position": 3.1 },
...
],
"totals": null
},
"meta": {
"report": "gsc.queries",
"site": { "id": "…", "name": "Client", "domain": "client.com" },
"source": { "name": "client.com", "externalId": "sc-domain:client.com" },
"dateRange": { "start": "2026-09-16", "end": "2026-10-13", "timeZone": "America/Los_Angeles", "days": 28 },
"freshness": { "fetchedAt": "…", "cached": false, "final": false, "dataDelayDays": 3 },
"paging": { "limit": 3, "offset": 0, "nextOffset": 3 }
}
}By-day reports (.overview) also return totals for the whole range. Rates (CTR, engagement rate) are fractions: 0.0452 means 4.52%. freshness.final is false while Google may still revise the numbers.
Errors are JSON: { "error": { "code": "…", "message": "…", "retryAfter": 30 } }. The message is written for people; branch on the code.
| Status | Code | When |
|---|---|---|
| 400 | invalid | Bad parameters, e.g. an unknown report or a date range outside what Google keeps. |
| 401 | unauthenticated | Missing, invalid, revoked or expired key. |
| 403 | forbidden | The key may not read this provider. |
| 404 | not_found | Unknown site, a site outside the key's scope, or no property linked for this report. |
| 429 | rate_limited | Over 120 requests a minute for the key (300 for the whole organization), or Google's quota for the property ran out. Wait Retry-After seconds. |
| 503 | unavailable | Google didn't answer, or no connected account can read the property any more. |
Results are cached: settled days for 24 hours, recent days for 15 minutes. Repeating a request is cheap and doesn't use Google quota (meta.freshness.cached tells you).
https://portylink.com/api/v1/sites/<site-id>/reports/gsc.overview.Authorization, value Bearer plk_live_....data.rows in the next node; turn on Split out for one item per row.