# API reference — Instagram Competitor Content: AI Winning Posts Finder

Developer tables moved out of the Store page. Back to the [README](../../README.md).

## Flags for the "Target flags" field

### All flags (`targetFlags` values; `questionHook` works too)

| Flag (`targetFlags` value) | Meaning |
|---|---|
| `viral_outlier` | engagement at least 5× the profile's median |
| `reel_format` · `carousel_format` | a reel · a carousel |
| `question_hook` · `number_hook` · `how_to_hook` | the caption opens with a question · a number or list · a how-to |
| `cta_in_caption` | the caption asks for an action (comment, save, share, follow, DM, link in bio) |
| `long_caption` · `short_caption` | 300 characters or more · under 40 characters or none |
| `hashtag_heavy` | 8 or more hashtags |
| `collab_post` | co-authored with another account |
| `verified_account` | posted by a verified account |
| `weak_baseline` | fewer than 5 posts to build the profile's median from — treat the multiple with care |

An unknown name stops the run with the list of valid ones.



## Webhook contract

### Webhook contract

Batch size 1 sends `{"event": "post.qualified", "post": {…}, "runId": "…"}` per winning post; a larger size sends
`{"event": "posts.qualified", "posts": [{…}, …], "runId": "…"}` per batch (the last one may be shorter). Each row is the
dataset item. With `webhookSecret` every POST carries `X-Signature: sha256=<HMAC-SHA256 of the raw body>` — verify it on
your end. A 429 / 5xx / network error is retried once after 2 s (15 s timeout); a POST that still fails is counted in
`OUTPUT.webhook.failed`, its rows' `dedupeKey`s in `OUTPUT.webhook.failedKeys` (re-post them from the dataset: a failed
row is not re-sent next run, it was delivered and charged) — a webhook failure never fails the run.

**Send alerts to** takes every delivery URL in one field, one per line: Slack, Discord, Teams Workflows, Google Chat, your
Telegram bot's `https://api.telegram.org/bot<token>/sendMessage?chat_id=@yourchannel`, and any other https URL as the JSON
webhook. API callers may still pass `slackWebhookUrl`, `discordWebhookUrl`, `teamsWebhookUrl`, `googleChatWebhookUrl` and
`telegramUrl`; a per-service field wins over the same service in `sendAlertsTo`.



## Niche preset inputs

### Coffee, fitness and skincare inputs

```json
{"sourceMode": "actor", "profiles": ["bluebottle", "intelligentsia", "stumptowncoffee"], "postsPerProfile": 30, "outlierMultiple": 2}
{"sourceMode": "actor", "profiles": ["gymshark", "myprotein", "alphaleteathletics"], "postsPerProfile": 30, "outlierMultiple": 2}
{"sourceMode": "actor", "profiles": ["theordinary", "cerave", "glossier"], "postsPerProfile": 30, "outlierMultiple": 2}
```



## Which call runs what

### Which call runs what

| Input shape | `OUTPUT.mode` | Can it charge? |
|---|---|---|
| `{}` or the untouched form (the sample posts) | `demo` | no — sample rows are `chargedEvent: demo` |
| the sample posts + your own profiles typed next to them | `live` | yes — per winning post; the source scan on your account, capped by `maxDiscoveryChargeUsd` |
| the same, with `autoSwitchToLive: false` | `demo` | no — the mode you send is the mode that runs; the status names the profiles it did not search |
| `sourceMode: "actor"` + `profiles` | `live` | yes — as above |
| `sourceMode: "list"` + your own `itemsList` rows | `list` | yes — per winning post, no source run |
| `sourceMode: "dataset"` + `datasetId` | `dataset` | yes — per winning post, no source run |

Every run writes `OUTPUT.mode`, so an agent can assert what it paid for.

## How the AI works in detail

- **Grounded, not generative.** The model sees one winning post — caption, format, counts, hashtags, the profile's median,
  the multiple and the flags — and returns a typed analysis. Numbers it was not shown and claims about reach, audience or
  followers are removed before delivery.
- **One adaptation per brand, never a copy.** The remix brief needs a brand description, a caption and a post that is not
  cold, and never presents a competitor's claims as yours.
- **Pillars across the run.** One extra call groups winners into at most 8 pillars from the labels and hashtags it was
  given; if it fails, the most common shared hashtag decides.
- **Tokens only where they matter.** Candidates are posts that already beat the multiple, best rule score first, up to
  "AI candidate pool" × max winning posts. Default model: `anthropic/claude-haiku-4.5` through Apify's model access — no key
  where your plan includes it, otherwise your own key.
- **Graceful fallback.** If the model cannot be reached, the run continues with rule scores and tags
  (`analysisSource: "rule"`); those winners use your free ones while they last, then the basic price.

## Webhook, Sheets, CRM and AI agents

- **Webhook, Sheets, Slack** — the Actor's **Integrations** tab and the webhook field push winners to Zapier, Make, n8n or
  your CRM; the Shortlist view maps straight onto a sheet. **CRM:** a Make / Zapier "create or update" step per row — note
  = `whyItWon` + `generatedText`, link = `url`, tags = `format` and `pillarCluster` — upserting on `dedupeKey` (a native
  push is dated in the roadmap).
- **AI agents / MCP** — every input is a plain typed field. **Free first call:** `{}` runs the demo, and
  `{"sourceMode": "list", "itemsList": [...]}` analyses posts you already have — no source run on your account.

Webhook contract (batch size, retries, the `OUTPUT.webhook.failed` counter): [API reference](docs/img/api-reference.md).

## Which record to read

**Which record to read:** `OUTPUT` is the full run record; `RUN_SUMMARY` is the same record under its older name. It holds
the funnel (loaded → filtered → AI-assessed → qualified), the per-profile report (`profiles`: posts read, profiles with no
posts, weak baselines), charged events and `aiCoverage`. A source that fails before anything loads fails the run with the
reason (`sourceError`), never an empty "nothing matched".

## Example row

**Example row — the free demo's first winner**

```json
{
  "itemId": "2959c1a6aad9",
  "name": "@demo_roastery: Why does your espresso taste sour? ☕",
  "url": "https://www.instagram.com/p/rA1x2Y3z4/",
  "shortcode": "rA1x2Y3z4",
  "username": "demo_roastery",
  "profileUrl": "https://www.instagram.com/demo_roastery/",
  "format": "reel",
  "likes": 3100,
  "comments": 210,
  "views": 41200,
  "postedAt": "2026-09-26T08:15:00+00:00",
  "engagement": 3310,
  "profileMedianEngagement": 482.5,
  "outlierMultiple": 6.86,
  "baselineWeak": false,
  "hookPattern": "question",
  "captionLength": 139,
  "hashtagCount": 4,
  "postWeekday": "Sat",
  "postHourUtc": 8,
  "flags": ["viral_outlier", "reel_format", "question_hook", "cta_in_caption"],
  "ruleScore": 89,
  "score": 89,
  "label": "hot",
  "pillarCluster": "#coffeetips",
  "analysisSource": "rule",
  "generatedText": "Copy the angle: a reel opening with a question — 6.86x @demo_roastery's median engagement — “Why does your espresso taste sour? ☕”.",
  "chargedEvent": "demo",
  "dedupeKey": "sc:rA1x2Y3z4"
}
```

## Every field of a row

**Every field of a row**

| Field | Description |
|---|---|
| `itemId`, `name`, `url` | Identity of the post — `url` is its own Instagram page |
| `shortcode`, `username`, `profileUrl`, `fullName`, `isVerified` | The post id and the profile that posted it (`null` when the source did not report it) |
| `format`, `caption`, `hashtags`, `mentions`, `taggedUsers`, `locationName`, `audioTitle`, `coauthors`, `thumbnailUrl` | Post content; `null` when missing. Cover-image links expire after a few days |
| `likes`, `comments`, `views`, `videoDurationSec`, `hasAudio`, `commentsDisabled` | Counts the source reported — `views` is often `null`, never estimated |
| `postedAt`, `crawledAt` | When it was posted and read (ISO 8601, UTC) |
| `engagement`, `profileMedianEngagement`, `outlierMultiple`, `baselineWeak` | likes + comments, the profile's median, their ratio, whether the profile had fewer than 5 posts |
| `hookPattern`, `captionLength`, `hashtagCount`, `postWeekday`, `postHourUtc` | Free rule tags |
| `flags`, `targetFlagsMatched` | Rule-based findings and which of your target flags matched |
| `ruleScore`, `fitScore`, `score`, `label`, `assessedBy` | Rule score, AI score, the final ranking score, hot / warm / cold, and which scale `score` is on (`ai`, `sample` — the demo's labelled verdict — or `rules`); within a label the AI-assessed rows come first |
| **Canonical fields** | `url` and `score` are the fields to map; no twin carries another value |
| **Which score to trend on** | `ruleScore` — the same rule scale on every row and run; `score` is the AI fit where `aiModel` is set, else the rule score, so sort one run on `score` but threshold and trend across runs on `ruleScore` |
| `scoreReasons`, `whyItWon`, `hookType`, `contentPillar`, `cta`, `tone`, `remixBrief` | AI analysis (`null` or empty when AI is off); `remixBrief` also needs a brand description |
| `pillarCluster`, `analysisSource` | Content-pillar group of this run's winners (AI, else the shared hashtag) and `llm` / `rule` |
| `generatedText` | The line to act on: the remix brief with AI, else the rule-based angle to copy (hot / warm posts) |
| `aiModel`, `chargedEvent`, `billedAs`, `dedupeKey`, `source`, `scrapedAt` | Provenance and billing (`billedAs` = the price tier, also on free rows; `chargedEvent: already-charged` = a post you paid for under another search of the same memory, delivered again but not charged) |

## List-mode rows

**List-mode rows — what a pasted post needs**

`sourceMode: "list"` runs on any Apify plan: paste posts you already have into `itemsList` — start from the
[ready template](docs/img/list-template.json) (no source run, so nothing is billed on your account but our per-winner
price). A profile needs at least 2 posts to have a median, and 5 for a solid one. Rows come in the source scraper's shape:

| Field | Needed? | What it enables |
|---|---|---|
| `shortcode` or `post_url` | required | the post's identity, link and dedupe key |
| `user.username` | required | the profile the post is compared with (also read from `owner.username`, `username`) |
| `like_count`, `comment_count` | required | engagement and the outlier multiple |
| `caption`, `hashtags` | optional | hook pattern, caption length, hashtag count, AI analysis |
| `type`, `product_type`, `carousel_media_count` | optional | `format` — reel, image or carousel |
| `date` | optional | `postedAt`, weekday, hour and the last-N-days filter |
| `view_count`, `video_duration`, `has_audio`, `mentions`, `usertags`, `images`, `location.name`, `coauthor_producers`, `user.full_name`, `user.is_verified`, `from_url`, `crawled_at`, `comments_disabled` | optional | the matching output fields (`views`, `videoDurationSec`, `taggedUsers`, `thumbnailUrl` …) |

## Run outcomes

| Outcome | Run status | Dataset | `OUTPUT` | Charged? | What to do |
|---|---|---|---|---|---|
| Free demo (the pre-filled sample) | SUCCEEDED | the 6 sample winners | `qualified`, `demo: true`, `outcome: ok` | no | type your own profiles |
| Success | SUCCEEDED | winning posts, best first | `ok: true`, `outcome: ok`, funnel, `chargedEvents`, `aiCoverage`, `profiles` | per winning post | — |
| Nothing matched (the demo too) | SUCCEEDED | empty | `qualified: 0`, `outcome: empty` | not by us — a live search's source scan runs on your plan | lower `outlierMultiple` or the filters, add profiles |
| Demo text still in place | SUCCEEDED | empty | `inputNeeded` | no | replace the field the status names with your own text |
| Invalid input | FAILED | empty | — | no | fix the field the status message names |
| Your plan cannot run the source Store Actor | SUCCEEDED — the same for the Console, the API and a schedule; check `OUTPUT.ok` | the free sample winners, marked `demo`, nothing charged | `ok: false`, `outcome: source_refused`, `sourceError`, `sourceRefused: "plan"`, `fellBackToDemo: true` | no | on any plan paste [list-mode rows](#list-mode-rows--what-a-pasted-post-needs), or use a plan that runs Store Actors |
| Source failed (nothing loaded: crash, timeout, or the source renamed its output fields) | FAILED | empty | `ok: false`, `outcome: source_failed`, `sourceError` | no | follow the reason in the status message |
| Source partial (the source run timed out / was aborted after scraping) | SUCCEEDED | winners from the rows scraped | `sourceStatus` (`status`, `partial: true`) | per winning post | raise the source timeout or lower posts per profile |
| Profiles without posts | SUCCEEDED | winners of the other profiles | `profiles.noPosts` | no — never charged | check the username (private, renamed or empty) |
| AI unavailable | SUCCEEDED | rule-scored winners (the free demo: labelled sample AI verdicts) | `aiError`, `aiCoverage.sampleVerdicts` | free while your free winners last, then the basic price | use "My own API key" |



**Webhook:** `webhookBatchSize: 1` POSTs `{"event": "post.qualified", "post": {…}, "runId": "…"}` per winning post; a larger size POSTs `{"event": "posts.qualified", "posts": [{…}, …], "runId": "…"}` per batch. With `webhookSecret` every POST carries `X-Signature: sha256=<HMAC-SHA256 of the raw body>`. A 429 / 5xx / network error is retried once after 2 s; a POST that still fails never fails the run — it is counted in `OUTPUT.webhook.failed` with its rows' `dedupeKey`s in `OUTPUT.webhook.failedKeys`, and is not re-sent next run (it was delivered and charged).
