const response = await client.trackActivityMetrics({
events: [
{ activity_id: "activity_123", metric: "views" },
{ activity_id: "activity_123", metric: "clicks", delta: 2 },
],
});
for (const result of response.results) {
console.log(
`${result.activity_id}/${result.metric}: allowed=${result.allowed}`,
);
}Activity Metrics
Activity metrics let you track custom counters like views, clicks, and impressions for activities. These metrics are stored on the activity and can be used in ranking expressions to build engagement-driven feeds.
Activity metrics require a paid Feeds plan. Free-tier applications cannot use this feature. Paid (self-service) plans get the default metrics listed below. Enterprise plans can additionally configure up to 10 custom metrics per app.
Tracking Metrics
Track one or more metric events in a single request. Each event specifies an activity, a metric name, and an optional delta (defaults to 1). Events are independently rate-limited per user per activity per metric.
trackResponse, err := feedsClient.TrackActivityMetrics(ctx, &getstream.TrackActivityMetricsRequest{
Events: []getstream.TrackActivityMetricsEvent{
{ActivityID: "activity_123", Metric: "views"},
{ActivityID: "activity_123", Metric: "clicks", Delta: getstream.PtrTo(2)},
},
UserID: &userID,
})
if err != nil {
log.Fatal("Error tracking activity metrics:", err)
}
for _, result := range trackResponse.Data.Results {
fmt.Printf("%s/%s: allowed=%v\n", result.ActivityID, result.Metric, result.Allowed)
}TrackActivityMetricsResponse response = feeds.trackActivityMetrics(
TrackActivityMetricsRequest.builder()
.events(List.of(
TrackActivityMetricsEvent.builder()
.activityID("activity_123").metric("views").build(),
TrackActivityMetricsEvent.builder()
.activityID("activity_123").metric("clicks").delta(2).build()
))
.userID("user_456")
.build()
).execute().getData();
for (TrackActivityMetricsEventResult result : response.getResults()) {
System.out.printf("%s/%s: allowed=%b%n",
result.getActivityID(), result.getMetric(), result.getAllowed());
}$response = $feedsClient->trackActivityMetrics(
new GeneratedModels\TrackActivityMetricsRequest(
events: [
new GeneratedModels\TrackActivityMetricsEvent(
activityID: 'activity_123',
metric: 'views'
),
new GeneratedModels\TrackActivityMetricsEvent(
activityID: 'activity_123',
metric: 'clicks',
delta: 2
),
],
userID: 'user_456'
)
);
foreach ($response->getData()->results as $result) {
printf(
"%s/%s: allowed=%s\n",
$result->activityID,
$result->metric,
$result->allowed ? 'true' : 'false'
);
}var response = await _feedsV3Client.TrackActivityMetricsAsync(
new TrackActivityMetricsRequest
{
Events = new List<TrackActivityMetricsEvent>
{
new TrackActivityMetricsEvent { ActivityID = "activity_123", Metric = "views" },
new TrackActivityMetricsEvent { ActivityID = "activity_123", Metric = "clicks", Delta = 2 },
},
UserID = "user_456"
}
);
foreach (var result in response.Data!.Results) {
Console.WriteLine($"{result.ActivityID}/{result.Metric}: allowed={result.Allowed}");
}response = client.feeds.track_activity_metrics(
events=[
TrackActivityMetricsEvent(activity_id="activity_123", metric="views"),
TrackActivityMetricsEvent(
activity_id="activity_123",
metric="clicks",
delta=2,
),
],
user_id="user_456",
)
for result in response.data.results:
print(f"{result.activity_id}/{result.metric}: allowed={result.allowed}")track_request = GetStream::Generated::Models::TrackActivityMetricsRequest.new(
events: [
GetStream::Generated::Models::TrackActivityMetricsEvent.new(
activity_id: 'activity_123',
metric: 'views'
),
GetStream::Generated::Models::TrackActivityMetricsEvent.new(
activity_id: 'activity_123',
metric: 'clicks',
delta: 2
)
],
user_id: 'user_456'
)
response = client.feeds.track_activity_metrics(track_request)
response.results.each do |result|
puts "#{result.activity_id}/#{result.metric}: allowed=#{result.allowed}"
endconst response = await client.feeds.trackActivityMetrics({
events: [
{ activity_id: "activity_123", metric: "views" },
{ activity_id: "activity_123", metric: "clicks", delta: 2 },
],
user_id: "user_456",
});
for (const result of response.results) {
console.log(
`${result.activity_id}/${result.metric}: allowed=${result.allowed}`,
);
}Server-side calls must include user_id. Client-side calls use the authenticated user's ID automatically.
Default Metrics
All paid plans include the following built-in metrics:
| Metric | Max per hour per user per activity |
|---|---|
views | 10 |
clicks | 5 |
impressions | 50 |
These defaults are always available. Enterprise apps can override their rate limits or disable them by setting the limit to 0 via custom metrics configuration.
Metric names must be alphanumeric with underscores or dashes only, and a maximum of 64 characters (e.g. views, link_clicks, video-watch-time).
Custom Metrics Configuration
Enterprise plans can configure up to 10 custom metrics per app using the updateApp API. Custom metrics are merged on top of the defaults:
- Add a new metric: Include it in
activity_metrics_configwith a rate limit value. - Override a default's rate limit: Include the default metric name with a new limit value.
- Disable a default metric: Set its value to
0. - Unmentioned defaults are preserved: Only metrics you explicitly include in the config are affected.
// Add custom metrics and override the default "views" limit
await client.updateApp({
activity_metrics_config: {
shares: 5, // new custom metric: 5 per hour per user per activity
saves: 10, // new custom metric: 10 per hour
views: 20, // override default views limit from 10 to 20
impressions: 0, // disable the default impressions metric
},
});// Add custom metrics and override the default "views" limit
_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{
ActivityMetricsConfig: map[string]int{
"shares": 5, // new custom metric: 5 per hour per user per activity
"saves": 10, // new custom metric: 10 per hour
"views": 20, // override default views limit from 10 to 20
"impressions": 0, // disable the default impressions metric
},
})
if err != nil {
log.Fatal("Error updating app:", err)
}// Add custom metrics and override the default "views" limit
UpdateAppRequest request = UpdateAppRequest.builder()
.activityMetricsConfig(Map.of(
"shares", 5, // new custom metric: 5 per hour per user per activity
"saves", 10, // new custom metric: 10 per hour
"views", 20, // override default views limit from 10 to 20
"impressions", 0 // disable the default impressions metric
))
.build();
common.updateApp(request).execute();// Add custom metrics and override the default "views" limit
$client->updateApp(new GeneratedModels\UpdateAppRequest(
activityMetricsConfig: [
'shares' => 5, // new custom metric: 5 per hour per user per activity
'saves' => 10, // new custom metric: 10 per hour
'views' => 20, // override default views limit from 10 to 20
'impressions' => 0, // disable the default impressions metric
]
));// Add custom metrics and override the default "views" limit
await client.UpdateAppAsync(new UpdateAppRequest
{
ActivityMetricsConfig = new Dictionary<string, int>
{
["shares"] = 5, // new custom metric: 5 per hour per user per activity
["saves"] = 10, // new custom metric: 10 per hour
["views"] = 20, // override default views limit from 10 to 20
["impressions"] = 0, // disable the default impressions metric
}
});# Add custom metrics and override the default "views" limit
client.update_app(
activity_metrics_config={
"shares": 5, # new custom metric: 5 per hour per user per activity
"saves": 10, # new custom metric: 10 per hour
"views": 20, # override default views limit from 10 to 20
"impressions": 0, # disable the default impressions metric
}
)# Add custom metrics and override the default "views" limit
client.common.update_app(
GetStream::Generated::Models::UpdateAppRequest.new(
activity_metrics_config: {
shares: 5, # new custom metric: 5 per hour per user per activity
saves: 10, # new custom metric: 10 per hour
views: 20, # override default views limit from 10 to 20
impressions: 0 # disable the default impressions metric
}
)
)// Add custom metrics and override the default "views" limit (server-side)
await client.updateApp({
activity_metrics_config: {
shares: 5, // new custom metric: 5 per hour per user per activity
saves: 10, // new custom metric: 10 per hour
views: 20, // override default views limit from 10 to 20
impressions: 0, // disable the default impressions metric
},
});The resulting effective config for this app would be:
| Metric | Limit | Source |
|---|---|---|
views | 20 | overridden from default (10) |
clicks | 5 | default (unchanged) |
impressions | — | disabled by custom config |
shares | 5 | custom |
saves | 10 | custom |
You can read back the current custom config via getApp:
const app = await client.getApp();
console.log(app.app.activity_metrics_config);
// { shares: 5, saves: 10, views: 20, impressions: 0 }appResp, err := client.GetApp(ctx, &getstream.GetAppRequest{})
if err != nil {
log.Fatal("Error getting app:", err)
}
fmt.Println(appResp.Data.App.ActivityMetricsConfig)
// map[shares:5 saves:10 views:20 impressions:0]GetAppResponse appResp = common.getApp().execute().getData();
Map<String, Integer> config = appResp.getApp().getActivityMetricsConfig();
System.out.println(config);
// {shares=5, saves=10, views=20, impressions=0}$app = $client->getApp();
print_r($app->getData()->app->activityMetricsConfig);
// Array ( [shares] => 5 [saves] => 10 [views] => 20 [impressions] => 0 )var appResp = await client.GetAppAsync();
var config = appResp.Data!.App.ActivityMetricsConfig;
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(config));
// {"shares":5,"saves":10,"views":20,"impressions":0}app = client.get_app()
print(app.data.app.activity_metrics_config)
# {"shares": 5, "saves": 10, "views": 20, "impressions": 0}app = client.common.get_app
puts app.app.activity_metrics_config.inspect
# {"shares"=>5, "saves"=>10, "views"=>20, "impressions"=>0}const app = await client.getApp();
console.log(app.app.activity_metrics_config);
// { shares: 5, saves: 10, views: 20, impressions: 0 }To clear all custom overrides and revert to defaults, set an empty config:
await client.updateApp({ activity_metrics_config: {} });_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{
ActivityMetricsConfig: map[string]int{},
})common.updateApp(UpdateAppRequest.builder()
.activityMetricsConfig(Map.of())
.build()).execute();$client->updateApp(new GeneratedModels\UpdateAppRequest(
activityMetricsConfig: []
));await client.UpdateAppAsync(new UpdateAppRequest
{
ActivityMetricsConfig = new Dictionary<string, int>()
});client.update_app(activity_metrics_config={})client.common.update_app(
GetStream::Generated::Models::UpdateAppRequest.new(
activity_metrics_config: {}
)
)await client.updateApp({ activity_metrics_config: {} });Custom metrics configuration is available on Enterprise plans only. Paid self-service plans use the default metrics. The activity_metrics_config returned by getApp only contains your custom overrides — default metrics that have not been overridden are not listed.
Delta Values
The delta field controls how much the metric counter changes:
- Positive delta: Increments the metric (e.g.
delta: 5adds 5) - Negative delta: Decrements the metric (e.g.
delta: -1subtracts 1) - Default: If
deltais omitted, it defaults to1
The absolute value of delta counts against the rate limit. For example, delta: 100 counts as 100 against the limit, not just 1.
Batching
You can track up to 100 events in a single request. Events can target different activities and different metrics. Each event is independently rate-limited.
// Track metrics for multiple activities in one request
const response = await client.trackActivityMetrics({
events: [
{ activity_id: "activity_1", metric: "views" },
{ activity_id: "activity_1", metric: "clicks" },
{ activity_id: "activity_2", metric: "views" },
{ activity_id: "activity_3", metric: "impressions", delta: 5 },
],
});// Track metrics for multiple activities in one request
trackResponse, err := feedsClient.TrackActivityMetrics(ctx, &getstream.TrackActivityMetricsRequest{
Events: []getstream.TrackActivityMetricsEvent{
{ActivityID: "activity_1", Metric: "views"},
{ActivityID: "activity_1", Metric: "clicks"},
{ActivityID: "activity_2", Metric: "views"},
{ActivityID: "activity_3", Metric: "impressions", Delta: getstream.PtrTo(5)},
},
UserID: &userID,
})
if err != nil {
log.Fatal("Error tracking activity metrics:", err)
}// Track metrics for multiple activities in one request
TrackActivityMetricsResponse response = feeds.trackActivityMetrics(
TrackActivityMetricsRequest.builder()
.events(List.of(
TrackActivityMetricsEvent.builder()
.activityID("activity_1").metric("views").build(),
TrackActivityMetricsEvent.builder()
.activityID("activity_1").metric("clicks").build(),
TrackActivityMetricsEvent.builder()
.activityID("activity_2").metric("views").build(),
TrackActivityMetricsEvent.builder()
.activityID("activity_3").metric("impressions").delta(5).build()
))
.userID("user_456")
.build()
).execute().getData();// Track metrics for multiple activities in one request
$response = $feedsClient->trackActivityMetrics(
new GeneratedModels\TrackActivityMetricsRequest(
events: [
new GeneratedModels\TrackActivityMetricsEvent(activityID: 'activity_1', metric: 'views'),
new GeneratedModels\TrackActivityMetricsEvent(activityID: 'activity_1', metric: 'clicks'),
new GeneratedModels\TrackActivityMetricsEvent(activityID: 'activity_2', metric: 'views'),
new GeneratedModels\TrackActivityMetricsEvent(
activityID: 'activity_3',
metric: 'impressions',
delta: 5
),
],
userID: 'user_456'
)
);// Track metrics for multiple activities in one request
var response = await _feedsV3Client.TrackActivityMetricsAsync(
new TrackActivityMetricsRequest
{
Events = new List<TrackActivityMetricsEvent>
{
new TrackActivityMetricsEvent { ActivityID = "activity_1", Metric = "views" },
new TrackActivityMetricsEvent { ActivityID = "activity_1", Metric = "clicks" },
new TrackActivityMetricsEvent { ActivityID = "activity_2", Metric = "views" },
new TrackActivityMetricsEvent { ActivityID = "activity_3", Metric = "impressions", Delta = 5 },
},
UserID = "user_456"
}
);# Track metrics for multiple activities in one request
response = client.feeds.track_activity_metrics(
events=[
TrackActivityMetricsEvent(activity_id="activity_1", metric="views"),
TrackActivityMetricsEvent(activity_id="activity_1", metric="clicks"),
TrackActivityMetricsEvent(activity_id="activity_2", metric="views"),
TrackActivityMetricsEvent(
activity_id="activity_3",
metric="impressions",
delta=5,
),
],
user_id="user_456",
)# Track metrics for multiple activities in one request
track_request = GetStream::Generated::Models::TrackActivityMetricsRequest.new(
events: [
GetStream::Generated::Models::TrackActivityMetricsEvent.new(activity_id: 'activity_1', metric: 'views'),
GetStream::Generated::Models::TrackActivityMetricsEvent.new(activity_id: 'activity_1', metric: 'clicks'),
GetStream::Generated::Models::TrackActivityMetricsEvent.new(activity_id: 'activity_2', metric: 'views'),
GetStream::Generated::Models::TrackActivityMetricsEvent.new(
activity_id: 'activity_3',
metric: 'impressions',
delta: 5
)
],
user_id: 'user_456'
)
response = client.feeds.track_activity_metrics(track_request)// Track metrics for multiple activities in one request (server-side)
const response = await client.feeds.trackActivityMetrics({
events: [
{ activity_id: "activity_1", metric: "views" },
{ activity_id: "activity_1", metric: "clicks" },
{ activity_id: "activity_2", metric: "views" },
{ activity_id: "activity_3", metric: "impressions", delta: 5 },
],
user_id: "user_456",
});Rate Limiting
Each metric is rate-limited per user (or IP) per activity per metric within a 1-hour sliding window. When the limit is exceeded, the event is rejected and allowed is set to false in the response — this is not an error, just a signal that the event was not counted.
Always check the allowed field in the response:
const response = await client.trackActivityMetrics({
events: [{ activity_id: "activity_123", metric: "views" }],
});
for (const result of response.results) {
if (!result.allowed) {
console.log(`Rate limited: ${result.activity_id}/${result.metric}`);
}
}trackResponse, err := feedsClient.TrackActivityMetrics(ctx, &getstream.TrackActivityMetricsRequest{
Events: []getstream.TrackActivityMetricsEvent{
{ActivityID: "activity_123", Metric: "views"},
},
UserID: &userID,
})
if err != nil {
log.Fatal("Error tracking activity metrics:", err)
}
for _, result := range trackResponse.Data.Results {
if !result.Allowed {
fmt.Printf("Rate limited: %s/%s\n", result.ActivityID, result.Metric)
}
}TrackActivityMetricsResponse response = feeds.trackActivityMetrics(
TrackActivityMetricsRequest.builder()
.events(List.of(
TrackActivityMetricsEvent.builder()
.activityID("activity_123").metric("views").build()
))
.userID("user_456")
.build()
).execute().getData();
for (TrackActivityMetricsEventResult result : response.getResults()) {
if (!result.getAllowed()) {
System.out.printf("Rate limited: %s/%s%n",
result.getActivityID(), result.getMetric());
}
}$response = $feedsClient->trackActivityMetrics(
new GeneratedModels\TrackActivityMetricsRequest(
events: [
new GeneratedModels\TrackActivityMetricsEvent(
activityID: 'activity_123',
metric: 'views'
),
],
userID: 'user_456'
)
);
foreach ($response->getData()->results as $result) {
if (!$result->allowed) {
printf("Rate limited: %s/%s\n", $result->activityID, $result->metric);
}
}var response = await _feedsV3Client.TrackActivityMetricsAsync(
new TrackActivityMetricsRequest
{
Events = new List<TrackActivityMetricsEvent>
{
new TrackActivityMetricsEvent { ActivityID = "activity_123", Metric = "views" },
},
UserID = "user_456"
}
);
foreach (var result in response.Data!.Results) {
if (!result.Allowed) {
Console.WriteLine($"Rate limited: {result.ActivityID}/{result.Metric}");
}
}response = client.feeds.track_activity_metrics(
events=[TrackActivityMetricsEvent(activity_id="activity_123", metric="views")],
user_id="user_456",
)
for result in response.data.results:
if not result.allowed:
print(f"Rate limited: {result.activity_id}/{result.metric}")response = client.feeds.track_activity_metrics(
GetStream::Generated::Models::TrackActivityMetricsRequest.new(
events: [
GetStream::Generated::Models::TrackActivityMetricsEvent.new(
activity_id: 'activity_123',
metric: 'views'
)
],
user_id: 'user_456'
)
)
response.results.each do |result|
puts "Rate limited: #{result.activity_id}/#{result.metric}" unless result.allowed
endconst response = await client.feeds.trackActivityMetrics({
events: [{ activity_id: "activity_123", metric: "views" }],
user_id: "user_456",
});
for (const result of response.results) {
if (!result.allowed) {
console.log(`Rate limited: ${result.activity_id}/${result.metric}`);
}
}Reading Metrics
Metrics are included in the activity response as a metrics object:
// metrics is available on any activity response
const activity = await client.getActivity({ id: "activity_123" });
console.log(activity.metrics);
// { views: 42, clicks: 15, impressions: 200 }
// Access individual metrics
const views = activity.metrics?.views ?? 0;
const clicks = activity.metrics?.clicks ?? 0;// metrics is available on any activity response
activityResponse, err := feedsClient.GetActivity(ctx, "activity_123", &getstream.GetActivityRequest{})
if err != nil {
log.Fatal("Error getting activity:", err)
}
activity := activityResponse.Data.Activity
fmt.Println(activity.Metrics)
// map[views:42 clicks:15 impressions:200]
// Access individual metrics
views := activity.Metrics["views"] // 42
clicks := activity.Metrics["clicks"] // 15// metrics is available on any activity response
GetActivityResponse response = feeds.getActivity("activity_123").execute().getData();
ActivityResponse activity = response.getActivity();
Map<String, Integer> metrics = activity.getMetrics();
System.out.println(metrics);
// {views=42, clicks=15, impressions=200}
// Access individual metrics
int views = metrics.getOrDefault("views", 0); // 42
int clicks = metrics.getOrDefault("clicks", 0); // 15// metrics is available on any activity response
$response = $feedsClient->getActivity('activity_123');
$activity = $response->getData()->activity;
print_r($activity->metrics);
// Array ( [views] => 42 [clicks] => 15 [impressions] => 200 )
// Access individual metrics
$views = $activity->metrics['views'] ?? 0;
$clicks = $activity->metrics['clicks'] ?? 0;// metrics is available on any activity response
var response = await _feedsV3Client.GetActivityAsync("activity_123");
var activity = response.Data!.Activity;
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(activity.Metrics));
// {"views":42,"clicks":15,"impressions":200}
// Access individual metrics
var views = activity.Metrics["views"];
var clicks = activity.Metrics["clicks"];# metrics is available on any activity response
response = client.feeds.get_activity("activity_123")
activity = response.data.activity
print(activity.metrics)
# {"views": 42, "clicks": 15, "impressions": 200}
# Access individual metrics
views = activity.metrics.get("views", 0)
clicks = activity.metrics.get("clicks", 0)# metrics is available on any activity response
response = client.feeds.get_activity('activity_123')
activity = response.activity
puts activity.metrics.inspect
# {"views"=>42, "clicks"=>15, "impressions"=>200}
# Access individual metrics
views = activity.metrics['views'] || 0
clicks = activity.metrics['clicks'] || 0// metrics is available on any activity response
const activity = await client.feeds.getActivity({ id: "activity_123" });
console.log(activity.metrics);
// { views: 42, clicks: 15, impressions: 200 }The raw JSON response looks like this:
{
"id": "activity_123",
"type": "post",
"text": "Hello world",
"metrics": {
"views": 42,
"clicks": 15,
"impressions": 200
}
}Metrics are synced from the buffer to the database every 5 minutes.
Using Metrics in Ranking
Metrics can be referenced in ranking expressions to build engagement-driven feeds. Use the metrics object in your ranking score expression:
// Create a feed group ranked by engagement metrics
const response = await serverClient.feeds.createFeedGroup({
id: "trending",
ranking: {
type: "expression",
score: "metrics.views * 2 + metrics.clicks * 3",
defaults: {
metrics: {
views: 0,
clicks: 0,
},
},
},
activity_selectors: [{ type: "following" }],
});// Create a feed group ranked by engagement metrics
scoreFormula := "metrics.views * 3 + metrics.clicks * 2 + popularity"
createResponse, err := feedsClient.CreateFeedGroup(ctx, &getstream.CreateFeedGroupRequest{
ID: "trending",
ActivitySelectors: []getstream.ActivitySelectorConfig{
{Type: "following"},
},
Ranking: &getstream.RankingConfig{
Type: "expression",
Score: &scoreFormula,
Defaults: map[string]any{
"metrics": map[string]any{
"views": 0.0,
"clicks": 0.0,
},
},
},
})
if err != nil {
log.Fatal("Error creating feed group:", err)
}// Create a feed group ranked by engagement metrics
CreateFeedGroupResponse response = feeds.createFeedGroup(
CreateFeedGroupRequest.builder()
.id("trending")
.activitySelectors(List.of(
ActivitySelectorConfig.builder().type("following").build()
))
.ranking(RankingConfig.builder()
.type("expression")
.score("metrics.views * 3 + metrics.clicks * 2 + popularity")
.defaults(Map.of(
"metrics", Map.of(
"views", 0.0,
"clicks", 0.0
)
))
.build())
.build()
).execute().getData();// Create a feed group ranked by engagement metrics
const response = await serverClient.feeds.createFeedGroup({
id: "trending",
ranking: {
type: "expression",
score: "metrics.views * 2 + metrics.clicks * 3",
defaults: {
metrics: {
views: 0,
clicks: 0,
},
},
},
activity_selectors: [{ type: "following" }],
});When using metrics in ranking expressions, you must provide defaults for each metric. Activities without any tracked metrics need default values for the expression to evaluate correctly.
Ranking Expression Examples
// Simple view-based ranking
metrics.views
// Weighted engagement score
metrics.views * 2 + metrics.clicks * 3 + metrics.impressions * 0.1
// Combined with popularity and time decay
decay_linear(time) * (popularity + metrics.views * 0.5)
// Conditional boost for popular content
metrics.views > 100 ? popularity * 2 : popularityLimitations
- Paid plan required: Activity metrics are not available on the free tier
- Custom metrics (Enterprise only): Up to 10 custom metrics per app; self-service plans use defaults only
- Metric names: Alphanumeric characters, dashes, and underscores only; maximum 64 characters
- Sync delay: ~5 minutes between tracking and availability in responses/ranking
- No filtering: Metrics cannot be used in query filters (only in ranking expressions)
- Rate limit windows: Rate limits use 1-hour time windows, not permanent deduplication
- Batch size: Maximum 100 events per request