Netlify Analytics API
Table of Contents
I recently moved most of my websites over to Netlify, because, well - I work there, and I want to be dogfooding as much of our product as possible. As part of this, I enabled my sites to use Netlify Analytics, which has been a fantastic lens to look at the site usage from a server-side perspective.
As I explored the view a bit more, I realized that I wanted to store the data locally for better long-term analysis. After all, I’ve done that before with Twitter analytics. How could I do that here?
Well, I am writing this on vacation and my work computer is off - I thought I shouldn’t bug any of my coworkers and do some exploration myself. One of my colleagues chimed in in the support forums earlier this year about an undocumented API, so why don’t I try leveraging that?
A word of caution here - this is not something that is officially supported. That is, if something changes or breaks, there is no support for that problem, as the API is not officially exposed through the Netlify API surface. Don't put this into any production workloads.
Endpoints #
There are a few endpoints that are available through the dashboard, that map to each “panel” that is visible to the user. These endpoints are listed below.
Page Views #
Volume of page views for the site.
https://analytics.services.netlify.com/v2/<SITE_ID>/pageviews?from=<UNIX_TIMESTAMP>&to=<UNIX_TIMESTAMP>&timezone=-0800&resolution=day`
Example Response #
{
"data": [
[
1640246400000,
1226
],
[
1640332800000,
1522
],
[
1640419200000,
1243
],
[
1640505600000,
1253
],
[
1640592000000,
2485
],
[
1640678400000,
1297
],
[
1640764800000,
2234
],
[
1640851200000,
1858
]
]
}
Visitors #
Volume of unique visitors to the site.
https://analytics.services.netlify.com/v2/<SITE_ID>/visitors?from=<UNIX_TIMESTAMP>&to=<UNIX_TIMESTAMP>&timezone=-0800&resolution=range
Example Response #
{
"data": [
[
1640246400000,
522
],
[
1640332800000,
411
],
[
1640419200000,
445
],
[
1640505600000,
529
],
[
1640592000000,
641
],
[
1640678400000,
536
],
[
1640764800000,
871
],
[
1640851200000,
583
]
]
}
Countries #
Countries from which visitors come to the site.
https://analytics.services.netlify.com/v2/<SITE_ID>/ranking/countries?from=<UNIX_TIMESTAMP>&to=<UNIX_TIMESTAMP>&timezone=-0800
Example Response #
{
"data": [
{
"count": 6738,
"country_name": "United States",
"resource": "US"
},
{
"count": 1293,
"country_name": "Germany",
"resource": "DE"
},
{
"count": 1188,
"country_name": "Canada",
"resource": "CA"
},
{
"count": 532,
"country_name": "United Kingdom",
"resource": "GB"
},
{
"count": 530,
"country_name": "Belgium",
"resource": "BE"
},
{
"count": 415,
"country_name": "Singapore",
"resource": "SG"
},
{
"count": 305,
"country_name": "Romania",
"resource": "RO"
},
{
"count": 305,
"country_name": "Russia",
"resource": "RU"
},
{
"count": 206,
"country_name": "France",
"resource": "FR"
},
{
"count": 113,
"country_name": "Czechia",
"resource": "CZ"
}
]
}
Sources #
Site traffic sources.
https://analytics.services.netlify.com/v2/<SITE_ID>/ranking/sources?from=<UNIX_TIMESTAMP>&to=<UNIX_TIMESTAMP>&timezone=-0800&limit=6
Example Response #
{
"data": [
{
"count": 89286,
"resource": ""
},
{
"count": 1289,
"resource": "google.com"
},
{
"count": 190,
"resource": "duckduckgo.com"
},
{
"count": 169,
"resource": "bing.com"
},
{
"count": 98,
"resource": "t.co"
},
{
"count": 76,
"resource": "news.ycombinator.com"
}
]
}
Pages #
Most frequently visited pages on the site.
https://analytics.services.netlify.com/v2/<SITE_ID>/ranking/pages?from=<UNIX_TIMESTAMP>&to=<UNIX_TIMESTAMP>&timezone=-0800&limit=15
Example Response #
{
"data": [
{
"count": 2094,
"resource": "/"
},
{
"count": 859,
"resource": "/blog/"
},
{
"count": 789,
"resource": "/blog/twitter-verification-api/"
},
{
"count": 348,
"resource": "/blog/nest/"
},
{
"count": 325,
"resource": "/blog/nikon-d3100-webcam-camlink/"
},
{
"count": 298,
"resource": "/blog/user-hostile-software/"
},
{
"count": 262,
"resource": "/blog/powershell-windows-notification/"
},
{
"count": 252,
"resource": "/blog/edge-blank-new-tab/"
},
{
"count": 233,
"resource": "/blog/career/"
},
{
"count": 229,
"resource": "/categories/opinion/"
},
{
"count": 209,
"resource": "/blog/free-nest-video-recording/"
},
{
"count": 207,
"resource": "/blog/spotify-wrapped/"
},
{
"count": 176,
"resource": "/blog/streamdeck-windows-store-apps/"
},
{
"count": 156,
"resource": "/projects/"
},
{
"count": 153,
"resource": "/blog/first-summer-working-at-microsoft/"
}
]
}
Bandwidth #
Amount of bandwidth consumed.
https://analytics.services.netlify.com/v2/<SITE_ID>/bandwidth?from=<UNIX_TIMESTAMP>&to=<UNIX_TIMESTAMP>&timezone=-0800&resolution=day
Example Response #
{
"data": [
{
"start": 1640246400000,
"end": 1640332800000,
"siteBandwidth": 80815793,
"accountBandwidth": 0
},
{
"start": 1640332800000,
"end": 1640419200000,
"siteBandwidth": 79218742,
"accountBandwidth": 0
},
{
"start": 1640419200000,
"end": 1640505600000,
"siteBandwidth": 71471332,
"accountBandwidth": 0
},
{
"start": 1640505600000,
"end": 1640592000000,
"siteBandwidth": 74806924,
"accountBandwidth": 0
},
{
"start": 1640592000000,
"end": 1640678400000,
"siteBandwidth": 95522725,
"accountBandwidth": 0
},
{
"start": 1640678400000,
"end": 1640764800000,
"siteBandwidth": 82424412,
"accountBandwidth": 0
},
{
"start": 1640764800000,
"end": 1640851200000,
"siteBandwidth": 105744149,
"accountBandwidth": 0
},
{
"start": 1640851200000,
"end": 1640937600000,
"siteBandwidth": 65495587,
"accountBandwidth": 0
}
]
}
Not Found Pages #
Top pages that were not found on the site but were requested.
https://analytics.services.netlify.com/v2/<SITE_ID>/ranking/not_found?from=<UNIX_TIMESTAMP>&to=<UNIX_TIMESTAMP>&timezone=-0800&limit=15
Example Response #
{
"data": [
{
"count": 270,
"resource": "/favicon.ico"
},
{
"count": 91,
"resource": "/apple-touch-icon.png"
},
{
"count": 86,
"resource": "/blog/how-i-think-about-my-career-part-one/"
},
{
"count": 64,
"resource": "/apple-touch-icon-precomposed.png"
},
{
"count": 63,
"resource": "/blog/release-of-hummingbird--distribution-list-converter/"
},
{
"count": 62,
"resource": "/gear/"
},
{
"count": 53,
"resource": "/ads.txt"
},
{
"count": 52,
"resource": "/content/NetduinoTest.zip"
},
{
"count": 36,
"resource": "/blog/intercepting-iphone-traffic-on-a-mac---a-how-to-guide/"
},
{
"count": 36,
"resource": "/wp-login.php"
},
{
"count": 29,
"resource": "//wp/wp-includes/wlwmanifest.xml"
},
{
"count": 29,
"resource": "//wp2/wp-includes/wlwmanifest.xml"
},
{
"count": 29,
"resource": "//wordpress/wp-includes/wlwmanifest.xml"
},
{
"count": 29,
"resource": "//cms/wp-includes/wlwmanifest.xml"
},
{
"count": 29,
"resource": "//blog/wp-includes/wlwmanifest.xml"
}
]
}
Getting The Data #
Great - now I know the endpoints, and am able to query the data for local storage and processing. The neat part about the above too is that I can set custom time ranges and limits, so I can pull whatever amount of data I want rather than be constrained by the most common options.
Each request requires a bearer token that is passed in the Authorization
header. Assuming that I am automating the process, I don’t want to be constantly looking at the network inspector to get the token. So, I thought I’d automate that piece of the process two. The login flow (without SAML) follows the pattern below.
First, an authentication POST
request is issued to this endpoint:
https://api.netlify.com/auth/login?provider=email&site_id=app.netlify.com&login=true&redirect=https://app.netlify.com/
The body of the request is like this:
{
"email": "<NETLIFY_USER_ID>",
"no_autosignup": true,
"password": "<NETLIFY_PASSWORD>",
"redirect": "https://app.netlify.com",
"shareable_invite_code": false
}
If the request is successful and you have two-factor authentication (2FA) enabled, you’ll get a redirect URL:
{
"redirect_uri": "https://app.netlify.com/two-factor-auth#access_token=<ACCESS_TOKEN>"
}
This is the link to the page where the 2FA token can be entered manually, but because I am automating the process, I need to have a way to not do this. Instead, I noticed that in the browser there is another API request that is executed, against this endpoint:
https://api.netlify.com/api/v1/user/multifactor_auth/verify_login
It’s a POST
request with the body containing the OTP code wrapped in a JSON envelope:
{"otp":"000000"}
The request to this endpoint requires a bearer token too, but the nice thing is that the access token from the redirect_uri
property is, in fact, the bearer token that we need here. Include it in the Authorization header to the verify_login
call, with the correct OTP code in the body, and if all goes well - you will end up with another response:
{
"redirect_path": "/auth#access_token=<ACCESS_TOKEN>"
}
That’s it - exchange complete! The access token you got here is the bearer token you can use for requests against the Analytics API. By wrapping the logic above in a Python script, I am now able to store the data in a local SQLite file and slice-and-dice it in a Jupyer notebook, but that’s a topic for another blog post in the future.