Skip to main content

Analytics and A/B tests

Reelwire posts nothing itself, so it never sees how a post did. Whoever posted it tells Reelwire, with the same key and the same post id it already reports the post published with: that is POST /v1/posts/{postId}/metrics, and it works on every plan. Reading the numbers back, compared, and running A/B tests on them is part of Enterprise; on any other plan those routes answer 409.

MethodPathPermission
POST/v1/posts/{id}/metricsposts:write
GET/v1/analytics/summaryanalytics:read
GET/v1/analytics/postsanalytics:read
GET/v1/analytics/posts/{id}analytics:read
POST/v1/analytics/snapshots/{id}/deleteanalytics:write
GET/v1/ab-testsabtests:read
GET/v1/ab-tests/optionsabtests:read
POST/v1/ab-testsabtests:write
GET/v1/ab-tests/{id}abtests:read
POST/v1/ab-tests/{id}/stopabtests:write
POST/v1/ab-tests/{id}/promoteabtests:write
POST/v1/ab-tests/{id}/deleteabtests:write

Reporting how a post did​

Once a post is up and reported published (Reporting back), send its numbers whenever you read them. The best times are 3 days and 7 days after it went up, because that is when posts are compared.

curl http://localhost:4000/v1/posts/01M3DE97XZMTT6GX71JAS8Z9VJ/metrics \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"postedAt": "2026-09-28T09:00:00Z",
"source": "ayrshare",
"snapshots": [
{
"measuredAt": "2026-10-01T09:00:00Z",
"views": 18400,
"reach": 15100,
"likes": 812,
"shares": 96,
"saves": 140,
"avgWatchSeconds": 11.2,
"completionRate": 0.31
}
]
}'
{
"ok": true,
"postId": "01M3DE97XZMTT6GX71JAS8Z9VJ",
"postGroupId": "01M3DE6QPKHYJYTX24PFCS8QD4",
"stored": 1,
"snapshots": 3,
"postedAt": "2026-09-28T09:00:00.000Z"
}

The id is channels[].postId from the hand-over; a channel's id from GET /v1/jobs works too.

FieldWhat it is
postedAtOptional: when the post went up on the platform. Every reading is aged from it. Without it, the moment the post was reported published stands in.
sourceOptional: where the numbers came from, in a word, up to 60 characters: ayrshare, zernio, tiktok studio.
snapshotsOne reading or several, up to 30 in one call.

Each reading:

FieldWhat it is
measuredAtWhen the numbers were read. Absent is now. Not in the future, and not more than a day before the post went up.
views, reach, impressionsCounts.
engagedViewsYouTube's engaged views.
likes, comments, shares, savesCounts. Reactions are likes; Telegram's forwards are shares.
followsFollowers gained from this post.
clicksClicks on its link, profile or call to action.
avgWatchSecondsHow long an average view lasted, in seconds.
totalWatchSecondsEvery view's time added up, in seconds.
avgViewPercentYouTube's average percentage viewed, 0 to 100, above 100 for a looped short.
completionRateThe share of views that watched to the end, 0 to 1: TikTok's full_video_watched_rate.
skipRateThe share of viewers who left in the first seconds, 0 to 1: Instagram's reels_skip_rate.
rawOptional: the platform's own names and values, kept as they came, up to 20 KB. Never compared.

Every number is optional, and one left out is stored as unknown, never as nought. A reading must carry at least one number. Counts are whole numbers of nought or more.

  • The post must have been reported published (or since deleted): anything else is a 409 naming its state.
  • A post keeps up to 200 readings; more is a 409.
  • A wrong or missing field is a 400, an id that is not one of the workspace's posts a 404.

A reading is never edited. To correct one, remove it (POST /v1/analytics/snapshots/{id}/delete) and send it again.

Where to read the numbers​

The platforms report far more than views, and the posting services pass on very different amounts of it. What to send, per platform:

PlatformSend
TikTokavgWatchSeconds, completionRate (TikTok's full_video_watched_rate, business connections only), views, reach, shares, saves (favorites), follows
Instagram ReelsavgWatchSeconds (ig_reels_avg_watch_time, which Meta gives in milliseconds), skipRate (reels_skip_rate), views, reach, shares, saves
Facebook ReelsavgWatchSeconds (post_video_avg_time_watched, in milliseconds), views (blue_reels_play_count), reach, follows
YouTube and ShortsavgViewPercent (averageViewPercentage), avgWatchSeconds (averageViewDuration), views, engagedViews, shares
LinkedInavgWatchSeconds (TIME_WATCHED_FOR_VIDEO_VIEWS, in milliseconds, divided by VIDEO_VIEW), views, impressions, reach, clicks
Telegramviews, shares (forwards), likes (reactions)

Reelwire knows each clip's length, so from avgWatchSeconds it works out the share of the clip an average view watched, which is what posts are best compared on.

Reading the numbers​

GET /v1/analytics/summary​

The published posts of a period side by side, one row per group, each post read at the same age.

Query
groupBycut (the default: template and cut), brand, language, channel, platform (per surface) or source.
horizon72 (the default: 3 days after the post went up), 168 (7 days) or latest (the newest reading).
since, untilThe period, over when posts went up, as ISO instants.
platform, channelIdOnly these posts.
q, sort, ascending, page, perPageAs every list: see Pagination. sort is posts, views, watchedPercent, completionRate, skipRate, engagementRate or label.

A reading counts for an age if it was taken no earlier than three quarters of it and no later than three times it. Each row: posts, due (old enough for the age), measured (with a reading at it), coverage, and views (the median), watchedPercent (0 to 100), completionRate, skipRate, avgWatchSeconds, engagementRate and sharesPerView (averages; shares 0 to 1). A value no post reported is null. totals says what the whole period rests on.

GET /v1/analytics/posts and GET /v1/analytics/posts/{id}​

Every published post of a period with its newest reading, a page at a time (numbers=without for the ones nobody reported), and one post with every reading, oldest first, each with its ageHours.

A/B tests​

A test tries two to four versions of one channel's posts from one feed or custom feed. A version is a cut and a brand, null for the channel's own. While it runs, every new post the feed makes for that channel takes one version, the one with the fewest posts so far, a tie drawn at random, so the same content never goes out twice. See A/B tests for how it decides.

Start one​

GET /v1/ab-tests/options lists what a test can run on (sources[], each with its subscriptionId and its template's cuts), the workspace's brands, and per platform the measures a test can be decided on.

curl http://localhost:4000/v1/ab-tests \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"subscriptionId": "01M2V8950E1STND6ABWVV6X7BB",
"arms": [
{ "label": "As it is" },
{ "label": "Without outro", "brandId": "01M2V8950CCJKBRHP2ENAK9G47" }
],
"metric": "watchedPercent",
"horizonHours": 72
}'
Field
subscriptionIdThe channel's feed or custom feed. One running test per feed: a second is a 409.
armsTwo to four versions, keyed A to D in this order: label (up to 40 characters), templateVariant (a cut of the template, or null) and brandId (one of the workspace's brands, or null). No two may look alike.
metricOptional: watchedPercent, completionRate, skipRate (lower is better), avgWatchSeconds, engagementRate, sharesPerView or views, as offered for the channel's platform. The platform's suggested one by default.
horizonHoursOptional: 72 (the default) or 168.
nameOptional, up to 80 characters.

Read one​

GET /v1/ab-tests/{id} answers the test, its versions and verdict:

verdict.state
collectingA version has fewer than 20 posts with numbers at the test's age.
withheldFewer than 80% of the posts old enough have numbers.
sameEvery version is within 5% of the best, either way, with 90% certainty.
winnerOne version is the best with a chance of at least 95%: verdict.winner.
inconclusiveNone of these yet.

verdict.arms[] gives each version's posts, posts with numbers, typical value, lift against the first version with liftLow and liftHigh (its 90% range) and chanceBest. The range and the chance are null until every version has 20 posts with numbers. verdict.sentence says it in words. Each post of the test is in posts[] with its version and the value it scored.

End one, or keep a version​

  • POST /v1/ab-tests/{id}/stop ends it. New posts go back to the channel's own cut and brand.
  • POST /v1/ab-tests/{id}/promote with { "arm": "B" } makes that version the channel's own and ends the test: its cut becomes the channel's cut for that feed, its brand the channel's brand. The answer says what changed.
  • POST /v1/ab-tests/{id}/delete removes an ended test. Its posts and their numbers stay.

The hand-over names the cut each post was drawn in, channels[].template.variant, and the brand it wears, channels[].brandName, so a receiver can tell the versions apart too.