Retention, impressions and CTR
Retention (% watched and average watch time), impressions and CTR from YouTube, and skip rate from Instagram, per post. Data the network does not provide comes as null, with the reason in retention.message. Requires the yt-analytics.readonly permission, which the YouTube connection already requests (channels connected before it was added must reauthorize once).
Authorization: Bearer praeko_… and X-Praeko-Contract: en. Always send both.Example
curl "https://praeko.app/v1/insights/retention" \
-H "Authorization: Bearer $PRAEKO_KEY" \
-H "X-Praeko-Contract: en"const res = await fetch("https://praeko.app/v1/insights/retention", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.PRAEKO_KEY}`,
"X-Praeko-Contract": "en",
},
});
console.log(res.status, await res.json());// O @praeko/sdk ainda não tem um método para esta rota: use o fetch (aba JavaScript).
const res = await fetch("https://praeko.app/v1/insights/retention", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.PRAEKO_KEY}`,
"X-Praeko-Contract": "en",
},
});
console.log(res.status, await res.json());import os
import requests
resp = requests.get(
"https://praeko.app/v1/insights/retention",
headers={
"Authorization": f"Bearer {os.environ['PRAEKO_KEY']}",
"X-Praeko-Contract": "en",
},
)
print(resp.status_code, resp.json())<?php
$ch = curl_init('https://praeko.app/v1/insights/retention');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("PRAEKO_KEY"),
'X-Praeko-Contract: en',
],
]);
$resposta = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $resposta, PHP_EOL;
curl_close($ch);package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://praeko.app/v1/insights/retention", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("PRAEKO_KEY"))
req.Header.Set("X-Praeko-Contract", "en")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status, string(out))
}require "net/http"
require "uri"
uri = URI("https://praeko.app/v1/insights/retention")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV['PRAEKO_KEY']}"
request["X-Praeko-Contract"] = "en"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.code, response.bodyusing System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "https://praeko.app/v1/insights/retention");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("PRAEKO_KEY"));
request.Headers.Add("X-Praeko-Contract", "en");
using var response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Main {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://praeko.app/v1/insights/retention"))
.header("Authorization", "Bearer " + System.getenv("PRAEKO_KEY"))
.header("X-Praeko-Contract", "en")
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}http GET "https://praeko.app/v1/insights/retention" \
"Authorization: Bearer $PRAEKO_KEY" \
"X-Praeko-Contract: en"fetch.Response
| Success status | When |
|---|---|
200 |
Success. |
{
"period": {
"from": "2026-10-08T14:02:11.482Z",
"to": "2026-11-07T14:02:11.482Z"
},
"channels": [
{
"channelId": "01KA3V0R5D8M2WQ7X4HT9NZ1BF",
"network": "youtube",
"name": "Marcone's Channel",
"state": "ready",
"message": "Per-video retention, impressions and CTR, through the YouTube Analytics API."
}
],
"total": 1,
"items": [
{
"id": "youtube:dQw4w9WgXcQ",
"network": "youtube",
"channelId": "01KA3V0R5D8M2WQ7X4HT9NZ1BF",
"title": "The test that only failed on Mondays",
"type": "short",
"publishedAt": "2026-11-01T12:00:00Z",
"url": "https://youtu.be/dQw4w9WgXcQ",
"origin": "imported",
"views": 1240,
"reach": null,
"impressions": 9800,
"ctr": 5.5,
"avgViewPercentage": 47.5,
"avgWatchTimeSec": 31.2,
"watchTimeMin": 2500.5,
"retention": {
"state": "ready",
"source": "youtube-analytics",
"collectedAt": "2026-11-07T15:40:03.120Z",
"points": 100
}
}
],
"collection": {
"last": "2026-11-07T14:02:11.482Z"
}
}A number the network has not delivered yet comes as null, never 0. retention.state: ready, pending, reauthorize, unavailable or error. channels[].message is free text the server writes in Portuguese; the example shows it in English.
Errors
No errors specific to this route besides the common ones.
Errors on any route
| Status | code | When |
|---|---|---|
| 401 | unauthorized |
The Authorization: Bearer … header is missing. |
| 401 | invalid_api_key |
The key does not exist. |
| 401 | revoked_api_key |
The key has been revoked. |
| 401 | expired_api_key |
The key has expired. |
| 403 | forbidden |
The key's scope is not enough for this route (see the scope badge). |
| 429 | rate_limited |
The per-minute request limit was exceeded. |
The body of every error is { "error": { "code", "message", "details"? } }. See all error codes.
Try it
The live test uses your key and your account. It stays off when you are not signed in.