Publish to learn
After the first few posts land, read metrics, pick top performers, and commission more of what's working.
What you'll build
The feedback loop an agent runs once a customer has a week of content live: read per-post metrics, find which creatives are winning, and commission more content shaped by those winners. Layers runs its own optimization under the hood — this guide is the read-side, so your product surfaces the same signal to the customer and your agent can act on it in its own voice.
Prerequisite: you've completed Onboard a customer and have at least 5–10 posts live. Anything below roughly 50 total views per post is noise — wait a couple of days before trusting the ranking.
The running example is acme-todo, a productivity app with a week of published content.
This loop requires real platform post data. /top-performers ranks posts Layers has synced from Instagram, TikTok, YouTube, or your ad accounts — it returns an empty items array on a project with no social accounts linked or no posts yet. If you're testing against a fresh project, connect a social account and wait for the 30-minute sync cadence to complete before expecting ranked rows. See Requires top-performer data below.
Read per-post metrics
GET /v1/projects/:projectId/metrics is the unified organic metrics endpoint. Scope by platform_post for a single post, social_account for one handle, or project for the whole customer — and the URL-level :projectId guards that the scoped entity belongs to your org.
/v1/projects/:projectId/metrics- Auth
- Bearer
- Scope
- metrics:read
curl "https://api.layers.com/v1/projects/$PROJECT_ID/metrics?scope=project&id=$PROJECT_ID&metrics=views&metrics=engagement_rate&metrics=watch_time_ms&since=2026-04-10T00:00:00Z&until=2026-04-17T00:00:00Z&granularity=day" \
-H "Authorization: Bearer $LAYERS_API_KEY"const params = new URLSearchParams({
scope: "project",
id: projectId,
since: "2026-04-10T00:00:00Z",
until: "2026-04-17T00:00:00Z",
granularity: "day",
});
for (const m of ["views", "engagement_rate", "watch_time_ms"]) {
params.append("metrics", m);
}
const res = await fetch(
`https://api.layers.com/v1/projects/${projectId}/metrics?${params}`,
{ headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` } },
);
const { series, totals } = await res.json();import os
import requests
res = requests.get(
f"https://api.layers.com/v1/projects/{project_id}/metrics",
headers={"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}"},
params=[
("scope", "project"),
("id", project_id),
("since", "2026-04-10T00:00:00Z"),
("until", "2026-04-17T00:00:00Z"),
("granularity", "day"),
("metrics", "views"),
("metrics", "engagement_rate"),
("metrics", "watch_time_ms"),
],
)
body = res.json()
series, totals = body["series"], body["totals"]{
"scope": "project",
"id": "prj_01HX9Y7K8M2P4RSTUV56789AB",
"window": {
"since": "2026-04-10T00:00:00Z",
"until": "2026-04-17T00:00:00Z",
"granularity": "day"
},
"series": [
{ "bucket": "2026-04-10", "views": 3120, "engagement_rate": 0.038, "watch_time_ms": 58120000 },
{ "bucket": "2026-04-11", "views": 5402, "engagement_rate": 0.044, "watch_time_ms": 102400000 }
],
"totals": {
"views": 48210,
"engagement_rate": 0.041,
"watch_time_ms": 892100000,
"post_count": 23
}
}since and until are ISO-8601 datetimes — date-only strings like 2026-04-10 return VALIDATION. series[].bucket is the bucket start (date string at day/week granularity, full timestamp at hour). totals.post_count is always included so UIs can show "N posts" alongside the aggregates.
For paid metrics (spend, CPA, ROAS), call GET /v1/projects/:projectId/ads-metrics instead. Asking for conversions/spend/roas on this endpoint returns VALIDATION.
Find top performers
GET /v1/projects/:projectId/top-performers is the ranking endpoint. It cross-references organic post performance with any ads spend against those creatives and returns a single sorted list. You pick the axis with metric and the lookback with window.
/v1/projects/:projectId/top-performers- Auth
- Bearer
- Scope
- metrics:read
curl "https://api.layers.com/v1/projects/$PROJECT_ID/top-performers?metric=engagement_rate&window=30d&sourceType=platform_post&limit=5" \
-H "Authorization: Bearer $LAYERS_API_KEY"{
"metric": "engagement_rate",
"window": "30d",
"items": [
{
"rank": 1,
"sourceType": "platform_post",
"sourceId": null,
"platformPostId": "pp_01H9ZXT7...",
"adsContentId": "adc_01H9ZXT...",
"title": "Get things done. Faster than your to-do list app did yesterday.",
"thumbnailUrl": "https://media.meetsift.com/videos/ig_.../cover.jpg",
"metricValue": 0.082,
"organic": {
"views": 14820,
"engagementRate": 0.082,
"watchTimeMs": 61400000,
"platforms": ["tiktok"]
},
"paid": null
}
]
}metric options: views, engagement_rate, conversions, roas, watch_time_ms. Pick the one the customer's stage actually cares about — early-stage apps should look at engagement_rate and watch_time_ms because absolute views rewards luck; later-stage accounts should lean on conversions and roas.
window is 7d, 30d (default), or 90d. There is no offset or custom range — use /metrics for that.
sourceType[] restricts to content_container (Layers-generated), platform_post (UGC / organic posts), or manual. platform[] restricts organic rows by platform and has no effect on paid-only rows.
Every row carries both organic and paid blocks (either may be null). metricValue mirrors the value the ranker used so your UI can render "0.082" or "5.2x" without peeking into the subtree.
adsContentId is present when Layers has already promoted the creative to paid. Those are your strongest signals — they cleared the organic bar and are now being spent against. Use sourceType=content_container plus metric=roas to get the short list of "things already earning their keep."
Commission more content from the winner
Two choices. If you want a close variant — same hook, same influencer, fresh execution — clone the post. If you want to try a new angle on the same theme, generate a new piece with the winner's hook pinned in the brief.
Option A: Clone the top performer
Clone-from-post is a target-first shape: you mint a fresh UUID for the new container client-side, PUT-style, and the :containerId in the URL carries that id. Replay with the same id and you get the same container back — the path itself is your idempotency key.
/v1/content/:containerId/clone-from-post- Auth
- Bearer
- Scope
- content:write
NEW_CONTAINER_ID=$(uuidgen)
curl -X POST "https://api.layers.com/v1/content/$NEW_CONTAINER_ID/clone-from-post" \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"projectId": "'"$PROJECT_ID"'",
"sourcePlatformPostId": "pp_01H9ZXT7...",
"mode": "reimagine",
"variations": 3
}'{
"jobId": "job_01H9ZXV...",
"kind": "content_clone_from_post",
"status": "running",
"containerId": "cnt_01H9ZXV...",
"locationUrl": "/v1/jobs/job_01H9ZXV..."
}A single job handles all variations. Poll it like any other content generation — see Jobs. See Clone a top performer for the difference between fork and reimagine.
Option B: Generate with the hook pinned
curl -X POST "https://api.layers.com/v1/projects/$PROJECT_ID/content" \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"format": "video_remix",
"variantCount": 2,
"brief": {
"hook": "Get things done. Faster than your to-do list app did yesterday.",
"cta": "Download acme-todo",
"targetPlatforms": ["tiktok", "instagram"],
"themeTags": ["productivity", "speed"]
}
}'Use this when the hook is landing but the execution feels stale. You get a new container with the winner's hook as a starting point but different visuals, captions, and editing.
Nudge the scoring pool when you need to
organic_score on ads_content rows decays over time. A post that was a top performer two weeks ago slides down the ranking as it ages, which makes room for fresh winners. Layers runs this for you on a 6-hour cadence.
If you need to force a creative into or out of the pool — say, a top performer you want to keep eligible past normal decay, or a post the customer asked you to stop promoting — use the override:
/v1/projects/:projectId/ads-content/:id- Auth
- Bearer
- Scope
- ads:write
curl -X PATCH "https://api.layers.com/v1/projects/$PROJECT_ID/ads-content/adc_01H9ZXT..." \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "override": "include" }'override: "include" keeps the creative eligible regardless of score decay. "exclude" forces it ineligible. null clears the override and lets scoring run.
Use "include" sparingly. The scoring pool works because it's unopinionated about yesterday's winner — overriding too often turns off the learning.
Requires top-performer data
/top-performers returns items: [] when the project hasn't synced any platform posts or promoted any ads. That's the expected shape — not an error. Before you trust the loop for a given customer, confirm there's data for it to rank:
-- Do we have organic posts synced for this project?
SELECT pp.id, pp.external_id, pp.platform, pp.published_at
FROM platform_posts pp
INNER JOIN social_accounts sa ON pp.social_account_id = sa.id
WHERE sa.project_id = '<projectId>'
ORDER BY pp.published_at DESC
LIMIT 5;
-- Do we have any ads_content scored for this project?
SELECT ac.id, ac.source_type, ac.organic_score, ac.scored_at
FROM ads_content ac
INNER JOIN project_ads_content pac ON pac.ads_content_id = ac.id
WHERE pac.project_id = '<projectId>'
ORDER BY ac.organic_score DESC NULLS LAST
LIMIT 5;- Zero rows in the first query → no social accounts linked, or SIFT sync hasn't run (runs every 30 minutes on new connections). Top-performers for organic metrics will be empty.
- Zero rows in the second query → no creatives generated or promoted yet. Paid metrics (
conversions,roas) will be empty regardless of window. - Rows exist but
organic_scoreisNULLor low → content-generation ran but theupdate-organic-performancejob hasn't scored it yet (6-hour cadence). Rankings by paid metrics still work; ranking by organic metrics will return fewer rows than you'd expect.
If the loop has to run before real data exists, show the customer an empty state rather than a 500 — the endpoint is returning a valid empty response.
Running the loop in production
A reasonable cadence for an agent:
- Daily — read
/top-performersranked by whatever the customer actually cares about. Surface the top 3 in your UI. - On breakout detection — engagement_rate above 2x the customer's median. Auto-clone with
variations: 2and hold the results for approval. - Weekly — read
/ads-metricsto close the loop on which creatives drove installs or purchases, not just views. Use that to inform the next week's briefs.
Poll intervals: /top-performers is cached at the platform-sync cadence, so polling it faster than every few minutes buys nothing. /metrics is similar — daily reads are plenty for most partner UIs.