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.
| Method | Path | Permission |
|---|---|---|
POST | /v1/posts/{id}/metrics | posts:write |
GET | /v1/analytics/summary | analytics:read |
GET | /v1/analytics/posts | analytics:read |
GET | /v1/analytics/posts/{id} | analytics:read |
POST | /v1/analytics/snapshots/{id}/delete | analytics:write |
GET | /v1/ab-tests | abtests:read |
GET | /v1/ab-tests/options | abtests:read |
POST | /v1/ab-tests | abtests:write |
GET | /v1/ab-tests/{id} | abtests:read |
POST | /v1/ab-tests/{id}/stop | abtests:write |
POST | /v1/ab-tests/{id}/promote | abtests:write |
POST | /v1/ab-tests/{id}/delete | abtests: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.
| Field | What it is |
|---|---|
postedAt | Optional: 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. |
source | Optional: where the numbers came from, in a word, up to 60 characters: ayrshare, zernio, tiktok studio. |
snapshots | One reading or several, up to 30 in one call. |
Each reading:
| Field | What it is |
|---|---|
measuredAt | When the numbers were read. Absent is now. Not in the future, and not more than a day before the post went up. |
views, reach, impressions | Counts. |
engagedViews | YouTube's engaged views. |
likes, comments, shares, saves | Counts. Reactions are likes; Telegram's forwards are shares. |
follows | Followers gained from this post. |
clicks | Clicks on its link, profile or call to action. |
avgWatchSeconds | How long an average view lasted, in seconds. |
totalWatchSeconds | Every view's time added up, in seconds. |
avgViewPercent | YouTube's average percentage viewed, 0 to 100, above 100 for a looped short. |
completionRate | The share of views that watched to the end, 0 to 1: TikTok's full_video_watched_rate. |
skipRate | The share of viewers who left in the first seconds, 0 to 1: Instagram's reels_skip_rate. |
raw | Optional: 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 sincedeleted): anything else is a409naming 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 a404.
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:
| Platform | Send |
|---|---|
| TikTok | avgWatchSeconds, completionRate (TikTok's full_video_watched_rate, business connections only), views, reach, shares, saves (favorites), follows |
| Instagram Reels | avgWatchSeconds (ig_reels_avg_watch_time, which Meta gives in milliseconds), skipRate (reels_skip_rate), views, reach, shares, saves |
| Facebook Reels | avgWatchSeconds (post_video_avg_time_watched, in milliseconds), views (blue_reels_play_count), reach, follows |
| YouTube and Shorts | avgViewPercent (averageViewPercentage), avgWatchSeconds (averageViewDuration), views, engagedViews, shares |
avgWatchSeconds (TIME_WATCHED_FOR_VIDEO_VIEWS, in milliseconds, divided by VIDEO_VIEW), views, impressions, reach, clicks | |
| Telegram | views, 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 | |
|---|---|
groupBy | cut (the default: template and cut), brand, language, channel, platform (per surface) or source. |
horizon | 72 (the default: 3 days after the post went up), 168 (7 days) or latest (the newest reading). |
since, until | The period, over when posts went up, as ISO instants. |
platform, channelId | Only these posts. |
q, sort, ascending, page, perPage | As 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 | |
|---|---|
subscriptionId | The channel's feed or custom feed. One running test per feed: a second is a 409. |
arms | Two 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. |
metric | Optional: 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. |
horizonHours | Optional: 72 (the default) or 168. |
name | Optional, up to 80 characters. |
Read one
GET /v1/ab-tests/{id} answers the test, its versions and verdict:
verdict.state | |
|---|---|
collecting | A version has fewer than 20 posts with numbers at the test's age. |
withheld | Fewer than 80% of the posts old enough have numbers. |
same | Every version is within 5% of the best, either way, with 90% certainty. |
winner | One version is the best with a chance of at least 95%: verdict.winner. |
inconclusive | None 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}/stopends it. New posts go back to the channel's own cut and brand.POST /v1/ab-tests/{id}/promotewith{ "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 whatchanged.POST /v1/ab-tests/{id}/deleteremoves 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.