Instagram Campaign Performance Benchmark avatar

Instagram Campaign Performance Benchmark

Pricing

from $8.40 / 1,000 post benchmarks

Go to Apify Store
Instagram Campaign Performance Benchmark

Instagram Campaign Performance Benchmark

Benchmark Instagram posts within each account and format using observed likes and comments. Get cohort medians and transparent post benchmarks with sample size and missing-data limits.

Pricing

from $8.40 / 1,000 post benchmarks

Rating

0.0

(0)

Developer

Tim Zinin

Tim Zinin

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

Share

Instagram Campaign Performance Benchmark — turn an existing Instagram post export into per-post cohort benchmarks

Feed it an Instagram post export you already have and get back one benchmark per post — a robust median/percentile against that same account's other posts of the same format, an honest basis (combined, likes_only, or comments_only), and the exact evidence behind the number — never a guess, and never a claim about reach, conversion, or audience.

Instagram campaign performance benchmark — hero diagram

This Actor does not scrape Instagram. It reads post rows you already have — from an upstream Instagram-scraper Actor's Dataset, from an Integrations payload, or pasted in directly — and, for every unique post, compares it only against the same account's (case-insensitive) other posts of the same format (type, exact match — an Image is never benchmarked against a Reel or a Sidecar). A post only gets a real benchmark when its cohort has at least 3 comparable posts and its own metric is known; everything else is an honest free diagnostic, never a fabricated "typical" score. It is a research and prioritization tool for social teams comparing a fresh campaign export against an account's own recent history — not a reach estimate, an engagement-rate-vs-followers claim, a bot/fraud detector, or an audience-demographic tool.

What you get

For every unique post found in your source batch that has a computable benchmark, you get one record containing:

  • entityId — id:<id> when a valid post id is present in the alias cluster, otherwise shortCode:<shortCode> (rows connected by a shared id or shortCode are bridged into one entity first; disconnected aliases cannot be inferred to be the same post).
  • benchmarkBasis — combined (likes+comments, the strongest read), likes_only, or comments_only — whichever metric this post actually had enough comparable data to be scored on. Anything other than combined always sets partial: true.
  • groupKey and comparableCohortSize — the <owner>|<format> cohort this post was scored against, and how many non-conflicted posts in your batch had that basis's metric known (always ≥ 3 for a billed result).
  • likesPercentile / commentsPercentile / combinedPercentile — a tie-safe mid-rank percentile (0–100) per metric, wherever that metric's cohort qualifies. An all-tied cohort reads 50, never a spurious 0.
  • likesRelativeIndex / commentsRelativeIndex / combinedRelativeIndex — this post's value divided by its cohort's median, e.g. "2.5× the account's usual Sidecar engagement."
  • action — top_performer (percentile ≥ 75), weak_performer_review (percentile ≤ 25), or typical_performer, suffixed with the basis name whenever it isn't combined.
  • evidence — an entry on every billed post naming the basis, compared value, cohort size, median, and percentile — plus entries for anything unusual (a hidden count, a duplicate row, a metric from a different row than owner/type/url, an overflowing sum, an invalid URL).
  • Honest diagnostics for everything else — a too-small cohort, disagreeing duplicate rows, a row with neither id nor shortCode, or a row the source itself reported as failed, are all free (no charge). The combined diagnostic-plus-notice list writes at most 200 entries per run; later result-size/cap notices can add free rows beyond that.

What this Actor is explicitly not: a reach/impressions estimate or follower-adjusted engagement rate (no follower count is read); proof of a successful paid campaign, ROI, or conversion; bot/fraud or audience-demographic inference. It never exposes commenter identities, ownerId, or media/photo URLs.

Who uses it

Social media managers and paid-campaign analysts use these records to see, post by post, which pieces of a fresh export actually out- or under-performed that same account's own recent baseline — before spending time on a manual spreadsheet comparison. The output states its own sample size, basis, and confidence explicitly; it is a prioritization aid, not a live analytics dashboard and not a substitute for Instagram's own Insights (which this Actor never reads).

How to run

  1. Console (fastest for a one-off run). Open Input and either leave the prefilled Source records (inline) — six real, dated @nasa posts spanning two formats — or fill in Source Dataset with your own Instagram-scraper Dataset ID. Leave everything else at its default and hit Start.
  2. From an existing Instagram-scraper Dataset. Put your Dataset ID in datasetId — read with your own run's permissions, no separate authorization step. Verified live against the public example Dataset fuFnFcE7LnzPL0xLc (9 real posts, run h0Siy97GERQyvC1QV): all 9 benchmarked on the combined basis, cohort sizes 6 (Image) and 3 (Sidecar).
  3. Chained after an upstream Instagram-scraper Actor via Integrations. Add this Actor as an "Actor/task run" integration; Apify's UI auto-populates the payload with {{resource.defaultDatasetId}} — map that value explicitly into datasetId (see Integration recipes) to request READ access under LIMITED_PERMISSIONS; a payload reference alone does not grant access.
  4. Via the API. Call POST /v2/acts/HpnuTSqBZvFAe70CI/runs (or run-sync-get-dataset-items for one synchronous call) with a JSON body containing datasetId (or items) and, for a hard spending ceiling, the run option maxTotalChargeUsd.
  5. Read the results. One row per unique, benchmarked post lands in the run's default Dataset; a matching OUTPUT/SUMMARY Key-value record reports how many benchmarks were delivered, how many rows were free diagnostics, and whether a cap ended the run early.

Pricing

Pay-per-event, one event type: result-found — one delivered post benchmark. No start fee; every free diagnostic (undersized cohort, conflicting duplicate, budget stop, empty batch) requests no charge event at all.

TierPrice per post benchmarkDiscount
Free (default)$0.012—
Bronze$0.010810%
Silver$0.009620%
Gold$0.008430%
Platinum$0.008430%
Diamond$0.008430%

These are Apify account pricing tiers; your applicable account plan determines the charge. One "post benchmark" is one unique, non-conflicted post with a computable basis; a conflicting-duplicate post or a too-small cohort is never billed. Upstream scraping costs (running the Instagram-scraper Actor that produced your batch) are entirely separate from this price.

Set the platform's own Max total charge (USD) run option to cap total spend. A positive maxTotalChargeUsd below the price of one benchmark correctly delivers zero paid results and one free budget_stopped diagnostic — see Example 3, a real verified run.

Input contract

FieldTypeDefaultNotes
itemsarray (JSON editor), max 1000 entriesprefilled real example (6 rows)Inline source records. Ignored if datasetId, runId, or a resolvable payload.resource is also given.
datasetIdstring (Dataset picker, READ)—Dataset ID from an Instagram post export. Read with your own run's permissions. Overrides inline items. Public example: fuFnFcE7LnzPL0xLc.
runIdstring, max 64 chars—An Actor run ID to read instead of a Dataset ID; its default Dataset resolves automatically, no elevated access.
payloadobject (JSON editor)—Optional ACTOR.RUN.SUCCEEDED-shaped payload carrying resource.defaultDatasetId/resource.id. The datasetId resource picker is the recommended way to grant private-Dataset access — a bare payload reference alone does not grant it.
maxInputRowsinteger100Hard cap on source rows examined before grouping (1–1000); still bounded to 4 MiB total / 64 KiB per row regardless.
maxResultsinteger10Max useful post benchmarks this run can deliver (1–1000), independent of the platform's Max total charge run option.

Source precedence, exactly: an explicit datasetId or payload.resource.defaultDatasetId (must agree if both present) beats a runId or payload.resource.id, which beats inline items. Never merged with inline rows. A malformed or conflicting reference is rejected as invalid input, never silently downgraded.

Accepted row fields: id/shortCode — string (≤190 chars, trimmed) or non-negative safe integer, joined via union-find so one post split across two rows is bridged before counting. ownerUsername/type — non-empty string ≤190 chars, cohort keys (ownerUsername case-insensitive for grouping; type exact match, Image/Sidecar/Video/Reel never mixed); non-string rejected outright. likesCount/commentsCount — a finite, non-negative safe integer; negative treated as unknown (Instagram sometimes hides counts), never zero; non-integer or beyond Number.MAX_SAFE_INTEGER is invalid. timestamp — ≤64 chars, strict calendar-valid ISO-8601 (real leap-year/day/hour/minute/second/timezone-offset ranges, never a loose regex plus Date.parse()). url — http(s) only, ≤2000 chars, echoed only when genuinely bounded. found/error/errors/failed/status — a failed/error/timed-out/aborted status always yields a free diagnostic, even alongside contradictory found:true, partial:true; a non-empty error/errors warning survives only with explicit found:true, partial:true and no hard failure.

Fields the source may also carry (caption, hashtags, videoViewCount, ownerId, paidPartnership) are never read and never appear in output — ownerId is deliberately never surfaced.

A note on Instagram Profile Scraper. This Actor's contract is a flat, per-post row. apify/instagram-profile-scraper's own default output is profile-shaped: one record per account, posts nested inside a latestPosts-style array — not automatically a supported post row here. Flatten its nested post list into individual rows before pointing datasetId at it, or use its post-array output mode if offered. apify/instagram-scraper (behind this README's real sample — see Sources and rights) returns the flat per-post shape natively, no preprocessing needed.

Hard read bounds, always enforced regardless of maxInputRows: at most 1000 source rows, 4 MiB total source JSON per run, 64 KiB per row (an oversized row is dropped and disclosed). This Actor reads only the fixed https://api.apify.com origin with your run's own token — never an arbitrary URL, never an upstream Actor start.

Output examples

These four outputs reproduce actual September 23 canaries from accepted build XWhJBNjPuSWXzGKYb (0.1.1), values unchanged. Run IDs: hAYCefOQW31GBJ0w8 (1), xfXwstMfIqbfvwYGp (2), vlM19lF0IZO94jUph (3), J3FqX4kuPCgcNcfzH (4).

1. First useful benchmark — the default inline prefill, no source capture time claimed

Actor input (the schema's own six-row default prefill, verbatim; first record shown, the remaining five follow the identical shape):

{
"items": [
{
"id": "3991274049559765207",
"type": "Image",
"shortCode": "Ddj2pn1PqDX",
"url": "https://www.instagram.com/p/Ddj2pn1PqDX/",
"commentsCount": 625,
"likesCount": 171442,
"timestamp": "2026-09-21T18:56:33.000Z",
"ownerUsername": "nasa"
}
]
}

First Dataset output row (of six delivered from this run — 3 Image + 3 Sidecar, each cohort exactly at the ≥3 minimum):

{
"entityId": "id:3991274049559765207",
"found": true,
"status": "benchmarked",
"action": "weak_performer_review",
"confidence": 0.9,
"shortCode": "Ddj2pn1PqDX",
"url": "https://www.instagram.com/p/Ddj2pn1PqDX/",
"ownerUsername": "nasa",
"type": "Image",
"timestamp": "2026-09-21T18:56:33.000Z",
"likesCount": 171442,
"likesState": "present",
"commentsCount": 625,
"commentsState": "present",
"combinedEngagement": 172067,
"groupKey": "nasa|Image",
"benchmarkBasis": "combined",
"cohortSize": 3,
"comparableCohortSize": 3,
"likesMedian": 401824,
"commentsMedian": 1091,
"combinedMedian": 403011,
"likesPercentile": 16.7,
"commentsPercentile": 16.7,
"combinedPercentile": 16.7,
"likesRelativeIndex": 0.427,
"commentsRelativeIndex": 0.573,
"combinedRelativeIndex": 0.427,
"duplicateSourceRowCount": 1,
"evidence": [
{
"field": "combinedEngagement",
"value": 172067,
"rule": "benchmark_basis_combined: compared against 3 comparable post(s) in cohort \"nasa|Image\" (median 403011, percentile 16.7)",
"sourceRowIndex": 0
}
],
"businessPartial": false,
"sourceRowIndexes": [0],
"partial": false,
"sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },
"product": "instagram-campaign-performance-benchmark",
"schemaVersion": "1.0",
"sourceDatasetId": null,
"sourceRunId": null,
"suppliedSourceRunId": null,
"sourceRunIdVerified": null,
"processedAt": "2026-09-23T05:05:06.311Z"
}

The six-post prefill is a curated, real 2026-09-08–2026-09-21 export from @nasa, unchanged since capture (see Sources and rights). This post's own values (171,442 likes / 625 comments) sit below its 3-post Image cohort's median (401,824 / 1,091), landing at percentile 16.7 — hence weak_performer_review. This run delivered all 6 posts paid with no diagnostics; the broader public Dataset (fuFnFcE7LnzPL0xLc, 9 posts) yields a 6-post Image and 3-post Sidecar cohort with different medians — a larger real cohort genuinely changes a post's relative standing, expected, not a defect.

2. Same batch with source-admitted partial rows — a disclosed warning, not a hard failure

Actor input (identical six posts to Example 1, with found: true, partial: true added to every row — a source's own honest admission a row is incomplete; first record shown):

{
"items": [
{
"id": "3991274049559765207",
"type": "Image",
"shortCode": "Ddj2pn1PqDX",
"url": "https://www.instagram.com/p/Ddj2pn1PqDX/",
"commentsCount": 625,
"likesCount": 171442,
"timestamp": "2026-09-21T18:56:33.000Z",
"ownerUsername": "nasa",
"found": true,
"partial": true
}
]
}

Resulting Dataset output row for the same post (partial: true, an extra disclosed evidence entry, every measured value otherwise identical to Example 1):

{
"entityId": "id:3991274049559765207",
"found": true,
"status": "benchmarked",
"action": "weak_performer_review",
"confidence": 0.9,
"shortCode": "Ddj2pn1PqDX",
"url": "https://www.instagram.com/p/Ddj2pn1PqDX/",
"ownerUsername": "nasa",
"type": "Image",
"timestamp": "2026-09-21T18:56:33.000Z",
"likesCount": 171442,
"likesState": "present",
"commentsCount": 625,
"commentsState": "present",
"combinedEngagement": 172067,
"groupKey": "nasa|Image",
"benchmarkBasis": "combined",
"cohortSize": 3,
"comparableCohortSize": 3,
"likesMedian": 401824,
"commentsMedian": 1091,
"combinedMedian": 403011,
"likesPercentile": 16.7,
"commentsPercentile": 16.7,
"combinedPercentile": 16.7,
"likesRelativeIndex": 0.427,
"commentsRelativeIndex": 0.573,
"combinedRelativeIndex": 0.427,
"duplicateSourceRowCount": 1,
"evidence": [
{
"field": "sourceRow",
"value": null,
"rule": "source_admitted_partial: source row(s) reported partial:true; any failure warning is retained only with explicit found:true; this result preserves source incompleteness",
"sourceRowIndex": 0
},
{
"field": "combinedEngagement",
"value": 172067,
"rule": "benchmark_basis_combined: compared against 3 comparable post(s) in cohort \"nasa|Image\" (median 403011, percentile 16.7)",
"sourceRowIndex": 0
}
],
"businessPartial": true,
"sourceRowIndexes": [0],
"partial": true,
"sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },
"product": "instagram-campaign-performance-benchmark",
"schemaVersion": "1.0",
"sourceDatasetId": null,
"sourceRunId": null,
"suppliedSourceRunId": null,
"sourceRunIdVerified": null,
"processedAt": "2026-09-23T05:05:23.520Z"
}

found: true, partial: true together is a disclosed warning, not a hard failure — the post still benchmarks normally, every measured value identical to Example 1. Only partial/businessPartial flip to true plus one extra source_admitted_partial evidence entry. This run delivered all 6 posts paid, all carrying partial: true — matching businessPartialResults: 6.

3. Below-price run — free budget_stopped diagnostic, no partial charge

[
{
"product": "instagram-campaign-performance-benchmark",
"schemaVersion": "1.0",
"status": "budget_stopped",
"reason": "Run charge limit cannot cover any result; no source was read.",
"found": false,
"processedAt": "2026-09-23T05:05:35.104Z"
}
]

This run was started with the platform run option maxTotalChargeUsd: 0.005 — below every pricing tier's per-benchmark price (even Diamond's $0.0084). The Actor never reads the source batch, delivers zero paid results, and writes this one free diagnostic.

4. Empty batch — free no_results diagnostic

Actor input:

{ "items": [] }

Dataset output:

[
{
"product": "instagram-campaign-performance-benchmark",
"schemaVersion": "1.0",
"status": "no_results",
"found": false,
"reason": "no_usable_rows_in_batch",
"sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },
"processedAt": "2026-09-23T05:05:46.228Z"
}
]

An empty or entirely-unusable batch is a free diagnostic, never a silent zero-row success and never a charge.

Field dictionary

FieldTypeMeaning
entityIdstringid:<id> when a valid id occurs in the bridged alias cluster; otherwise shortCode:<shortCode>.
foundbooleantrue on every billed benchmark; false on every free diagnostic.
statusstring"benchmarked" on a billed post; a diagnostic reason (invalid_input, source_unavailable, invalid_row, missing_identity, missing_group_key, source_row_error, conflicting_duplicate_source, insufficient_benchmark_data, budget_stopped, no_results, max_results_reached, result_dropped_oversized, source_rows_dropped, partial_source) on a free row.
benchmarkBasisstringcombined | likes_only | comments_only. Non-combined always implies partial: true.
groupKeystring`
cohortSize / comparableCohortSizenumberTotal posts sharing this cohort (incl. conflicts) / non-conflicted posts with the billed metric known — always ≥ 3 for a billed result.
likesMedian / commentsMedian / combinedMediannumber/nullCohort median per metric, over posts where it's known; null if fewer than 3 qualify.
likesPercentile / commentsPercentile / combinedPercentilenumber/null0–100 tie-safe mid-rank percentile per metric independently; an all-tied cohort reads 50. null when not computable.
likesRelativeIndex / commentsRelativeIndex / combinedRelativeIndexnumber/nullThis post's value ÷ cohort median, rounded to 3 decimals. null when not computable.
likesState / commentsStatestringdataset_missing | observed_absent | negative_hidden | invalid | present.
likesCount / commentsCount / combinedEngagementnumber/nullMeasured value when present; combinedEngagement also null if the sum would overflow Number.MAX_SAFE_INTEGER.
actionstringtop_performer (≥75) | weak_performer_review (≤25) | typical_performer — suffixed _likes_only/_comments_only when basis isn't combined.
confidencenumber0.9 for combined, 0.55 single-metric, capped 0.75 with any conflicting-duplicate exclusion. A disclosed rule label, not a probability.
shortCode / urlstring/nullOnly ever a typed, bounded value; an oversized/malformed raw field is withheld as null, with a url_invalid_or_oversized evidence entry when url was rejected.
ownerUsername / type / timestampstring/nullFrom a valid-group occurrence in the bridged cluster. timestamp is null when missing or calendar-invalid.
duplicateSourceRowCountnumberHow many bridged source rows contributed to this post.
evidencearrayUp to 50 entries, each { field, value, rule, sourceRowIndex } — always the billed basis's comparison, plus anything unusual (see Evidence and boundaries).
partial / businessPartialbooleanbusinessPartial is this module's own signal; partial also folds in run-level source-read incompleteness — check partial if you only check one flag.
sourceRowIndexesarrayOriginal source-batch offsets (remapped past oversized-row drops) contributing to this post.
sourceCoverageobjectRun-level: { complete, reason, droppedOversized }.
sourceDatasetId, sourceRunId, suppliedSourceRunId, sourceRunIdVerifiedstring/null, boolean/nullWhere the batch came from; sourceRunId populated only when independently verified against the Dataset.
processedAtstring (ISO 8601)When this Actor processed the row — never a claim about when the post was captured.
reason / errorstringPresent on free diagnostic rows only.

A note on observedAt. The Dataset's Overview view reserves an observedAt column, but this Actor never populates it, and the run-level businessSummary.observedAt is always null. The Instagram post contract this Actor reads carries no "when the scraper captured this row" field distinct from timestamp (the post's own reported posting date) — so rather than relabel processing time as a fabricated capture time, it is left honestly empty. Use each row's timestamp for the post's date, and businessSummary.observedWindow/timestampCoverage for the batch's range.

businessSummary (embedded in OUTPUT/SUMMARY): totalSourceRows, resultsCount, diagnosticsCount, statusBreakdown ({ benchmarked: N } when benchmarks exist, or {} when none exist), observedWindow.earliest/.latest (min/max valid timestamp across unique, non-conflicted accepted posts), acceptedPostCount (the true denominator, not raw source rows), timestampCoverage (fraction with a valid timestamp), and observedAt (always null).

Evidence and boundaries

Identity is bridged before anything else runs. Occurrences connected through shared id or shortCode aliases are merged into a single entity via union-find before unique-post counting or cohort assignment. An id-only row and a shortCode-only row require a connecting occurrence; disconnected aliases cannot be inferred to be one post. Only bounded, typed id/shortCode values are ever join keys. This bridging, and the conflict check below, run over every identity-bearing, non-error row — including one lacking a usable ownerUsername/type — before any row is set aside for lacking a group key, so a contradictory alias can never slip through unopposed.

Genuinely disagreeing duplicates are excluded, never billed on a guess. When two or more bridged rows disagree on a materially observed field (ownerUsername, type, likesCount, commentsCount, timestamp — comparing only fields both sides actually reported), that post is a conflicting duplicate: excluded from its cohort and reported as a free conflicting_duplicate_source diagnostic — never counted toward that cohort's ≥3 quorum. Every occurrence is compared against every other, not just source order. Remaining non-conflicted cohort members carry a cohort_partial_exclusions evidence entry disclosing how many posts were excluded.

A conflict-free complementary cluster is billed from whichever row actually supplied each field. One occurrence may report no engagement metrics while another reports likes, comments, and a timestamp — incompleteness, not a contradiction. Each field is resolved independently from whichever occurrence actually reported it; a field stays unknown only when no occurrence ever reported it, and results are identical regardless of row order. When a metric's supplying row differs from the row supplying ownerUsername/type/url, a metric_aggregated_across_cluster evidence entry names it at that field's real sourceRowIndex; a billed combinedEngagement summed from two rows names both component values and indexes.

Grouping is exact and collision-safe. Cohorts key on ownerUsername.toLowerCase() + the literal type string, so NASA/nasa land in one cohort, but a Reel from @brandx is never compared against an Image from @brandx, or against @brandy. Internally, cohort lookups use a separate, delimiter-collision-safe tuple key, never the delimited display string, so a | inside a raw field can never merge two distinct cohorts.

Counts are never fabricated. A negative likesCount/commentsCount is hidden as unknown, never zero. A non-integer or count beyond Number.MAX_SAFE_INTEGER is invalid. combinedEngagement requires both counts individually present and their sum itself Number.isSafeInteger — an overflowing sum withholds combinedEngagement (disclosed combined_engagement_overflow evidence) rather than rounding, falling back to a single-metric basis.

Timestamps are validated against the real calendar, never a loose prefix match. Leap years and real day/hour/minute/second/timezone-offset ranges are checked explicitly, so an impossible date like 2026-02-30 can never silently roll forward into a valid-looking date. A malformed or missing timestamp marks that post partial: true and is excluded from observedWindow/timestampCoverage.

Aux fields never amplify a hostile input straight into output. url is surfaced only when genuinely bounded (≤2000 char) http(s); otherwise withheld as null with a disclosed url_invalid_or_oversized evidence entry and forced partial: true. shortCode is only ever the already-bounded (≤190 char), typed identity value used for alias bridging.

Bounded, disclosed caps, applied only to display, never to the underlying facts: evidence caps at 50 entries per post; sourceRowIndexes has no separate cap. The combined diagnostic-plus-notice list writes at most 200 entries per run (diagnosticsTruncated flags this); later result-size/cap notices add free rows beyond it — independently verified: a 1000-row batch of 994 synthetic failures plus 6 real posts, run with maxInputRows: 1000 and maxResults: 1, correctly delivered 1 paid post and 201 free rows total (994 diagnostics capped at 200, plus one max_results_reached notice), the surviving post's sourceRowIndex correctly remapped to its true original offset (994), not a post-drop dense index. This fixture also exposed a real, retained limitation: the same run with the schema's default maxInputRows: 100 correctly read only its first 100 rows (all synthetic failures there), delivering 0 paid results and 101 free rows — it never reached a real post. That is not a probe that "passed"; it is honest evidence of what the default does on a batch shaped this way. Raise it when real posts may be preceded by many unusable rows.

No proof of reach, ROI, or audience. A benchmark means this post's own likes/comments sat at a given percentile against at least 3 comparable posts from the same account and format — nothing more. Not a follower-adjusted engagement rate (no follower count is read), not evidence of a successful paid campaign, not a bot/fraud signal, not an audience-demographic inference. Instagram's own counters can be publicly hidden by the poster; a negative_hidden/invalid state reflects that honestly rather than guessing.

Delivery is not transactional. Dataset storage precedes event charging. A lost acknowledgment can leave an unconfirmed row; the run stops without retry and can fail after some deliveries succeeded. Inspect the Dataset and summary before retrying — a new run can bill again, with no cross-run deduplication.

Reading the run summary

FieldMeaning
schemaVersion, product, outcome, processedAtContract version, product identifier, outcome, processing time (not a post date).
requested / usefulResults / deliveredSource rows encountered / useful results after oversized-result filtering / useful Dataset writes with a confirmed complete delivery+charge check.
paid / freeConfirmed named result-found events / acknowledged diagnostic writes without one.
failedOversized useful result rows dropped before delivery, not a count of fatal exceptions.
fatalFailure, errorCodeWhether the run failed and its diagnostic code. Input-policy diagnostics can succeed with a non-null errorCode, so read both.
deliveryUncertainA write/charge acknowledgment could not be confirmed; actual rows may exceed confirmed counters.
replaySafety, replayBlockedReminder a new run bills again; replayBlocked means this run/storage already had a claim or named events, so automatic replay was refused.
budgetStopped, resultsCappedDelivery stopped by the effective charge allowance or maxResults.
sourcePartial, sourcePartialReason, sourceDroppedOversizedSource-read coverage limits and rows discarded for size.
businessPartialResultsUseful transform results carrying partial: true, before delivery filtering.
oversizedDiagnosticsDropped, diagnosticsTruncatedDiagnostics omitted for size or the 200-entry combined limit; later notices are outside it.
partialOverallAt least one source/output/diagnostic/business-coverage limit was recorded — does not replace fatalFailure.
budgetMode, maxTotalChargeUsdVerified finite/unlimited limit mode; null when no finite verified limit is available.
businessSummaryThe full module summary described in Field dictionary above.

Decision routing

  • top_performer (percentile ≥ 75) — this post genuinely out-performed the account's own recent comparable posts, the strongest signal this batch can offer.
  • weak_performer_review (percentile ≤ 25) — worth a manual look before drawing conclusions; reflects this account's own history, not an absolute quality judgment.
  • typical_performer — between the two, including every post in an all-tied cohort (correctly percentile 50, not a false weak signal).
  • benchmarkBasis other than combined (and partial: true) — the combined-engagement cohort didn't qualify, so a single metric was used instead. Real and disclosed, but narrower — check likesState/commentsState for which count is missing.
  • confidence: 0.75 or lower — a single-metric basis, or a conflicting-duplicate exclusion in this cohort. Weight accordingly before treating it the same as a 0.9 combined result.
  • Free conflicting_duplicate_source — a source-data-quality signal worth investigating upstream, not a benchmark verdict.
  • Free insufficient_benchmark_data — this account+format has fewer than 3 comparable posts on every basis; expand your export's date range or post count.
  • Free budget_stopped/max_results_reached — a run-level cap was hit; re-run with a higher maxTotalChargeUsd or maxResults.

Commercial playbooks

Filter a fresh campaign export by action to triage quickly: review weak_performer_review posts for format/timing/caption patterns, and treat top_performer posts as candidates for content reuse or paid amplification — citing evidence and the cohort median, not just the label.

Compare repeated batches using the same entityId/groupKey; a post's percentile can shift as the account publishes more posts into its cohort over time — expected, not noise. Replaying an unchanged saved batch supplies no fresh observation and bills again (see Operating guide). This Actor does not estimate campaign spend, reach, or ROI; pair its output with your own paid-media reporting for that half of the picture.

Integration recipes

Instagram campaign performance benchmark — workflow diagram

This Actor's ID is HpnuTSqBZvFAe70CI. A natural upstream source is Instagram Scraper (shu8hvrXbJbY3Eb9W) or Instagram Profile Scraper (dSCLg0C3YEZ83HzYX, subject to the flattening note in Input contract); any Actor producing the row shape in Input contract works identically. These recipes describe how to configure each mechanism — not a claim any trigger is currently installed on your account; a saved public example is available in the Tasks tab.

1. Console-native chaining (no code). On your Instagram-scraper Actor's run page, open Integrations, add an "Actor/task run" action targeting this Actor, and let Apify's Integrations UI populate the payload with {{resource.defaultDatasetId}}. Map that value explicitly into this Actor's datasetId field — not the generic payload pass-through alone — to request READ access under LIMITED_PERMISSIONS, conditional on resource-picker or integration authorization. Documented at docs.apify.com/integrations/actors.

2. Direct webhook (POST /v2/webhooks), full recipe. Configure a webhook on your upstream Instagram-scraper Actor for ACTOR.RUN.SUCCEEDED, scoped via condition.actorId, targeting this Actor's run-creation endpoint:

{
"isApifyIntegration": true,
"shouldInterpolateStrings": true,
"eventTypes": ["ACTOR.RUN.SUCCEEDED"],
"condition": { "actorId": "shu8hvrXbJbY3Eb9W" },
"requestUrl": "https://api.apify.com/v2/acts/HpnuTSqBZvFAe70CI/runs?maxTotalChargeUsd=0.50",
"doNotRetry": true,
"headersTemplate": "{\"Authorization\": \"Bearer <APIFY_TOKEN>\"}",
"payloadTemplate": "{\"datasetId\": \"{{resource.defaultDatasetId}}\", \"payload\": {\"resource\": {\"defaultDatasetId\": \"{{resource.defaultDatasetId}}\", \"id\": \"{{resource.id}}\"}}, \"maxResults\": 25}"
}

Submit to POST https://api.apify.com/v2/webhooks with your own Authorization: Bearer <APIFY_TOKEN> header — the placeholder is filled in by whichever secrets-management step provisions the webhook, never hardcoded into the template or a URL query string. doNotRetry: true avoids a duplicate-charge risk from an automatic retry. A resource-picker selection or integration can request scoped READ access; the raw datasetId string and payload.resource alone do not grant it. A positive maxTotalChargeUsd on the request URL caps this chained run's spend.

3. Generic automation tools (n8n, Zapier, Make, or a spreadsheet export). Call run-sync-get-dataset-items on this Actor's ID with a JSON body containing datasetId and your chosen maxResults/maxInputRows — a plain authenticated HTTPS POST. Or export any finished run's Dataset via GET /v2/datasets/{datasetId}/items?format=csv and map entityId, groupKey, benchmarkBasis, combinedPercentile, action, and partial into a spreadsheet.

Being publicly searchable as an integration target (once public) does not imply a recommended-placement partnership with any specific upstream Actor — no outreach to a third party was performed to produce this README.

Operating guide

  1. Confirm your source batch shape first. Spot-check a few rows against Input contract — each needs an id or shortCode, an ownerUsername, and a type.
  2. Start with a small maxResults on a new source. Set it low (e.g. 5) to confirm the output matches expectations before a full batch.
  3. Set maxTotalChargeUsd deliberately. Use the platform run option, not an Actor input field. A value below one tier's per-benchmark price guarantees zero paid results and a free budget_stopped diagnostic (Example 3).
  4. Read OUTPUT/SUMMARY, not just the Dataset row count. It reports requested/delivered/paid/free/failed, whether budgetStopped/resultsCapped ended the run early, diagnosticsTruncated, and sourcePartial completeness — e.g. when maxInputRows truncated a larger batch before reaching your real posts (raise it if sourcePartialReason: "max_input_rows_reached" appears with fewer benchmarks than expected — see the mixed-boundary fixture in Evidence and boundaries).
  5. If a run finalizes FAILED, inspect before retrying. Check the Dataset and OUTPUT for what was actually delivered/charged before re-running — a blind retry can duplicate already-successful paid benchmarks.
  6. Re-running is always new billing. No dedup key across runs — reserve re-runs for a genuinely new or updated export.
  7. Use comparableCohortSize alongside the percentile. A percentile against 3 posts is a real, honest read, but thinner than one against 20 — both bill identically, so weigh the cohort size yourself.

FAQ

Does this Actor scrape Instagram itself? No. It only reads an existing post export you or an upstream Actor already collected — never fetches a page from Instagram, never starts another Actor.

Can I use a source other than Instagram Scraper? Yes — any source producing rows with the fields in Input contract works identically. See the Instagram Profile Scraper flattening note if your source returns a nested, profile-shaped record.

Does a benchmark prove a post reached more people or performed better in an ad sense? No. It means this post's own likes/comments sat at a given percentile against at least 3 comparable posts from the same account and format — nothing about reach, impressions, follower-adjusted engagement rate, or paid-campaign ROI.

Why do some posts get likes_only or comments_only instead of combined? Their combined-engagement cohort didn't have 3 qualifying posts, or this post's own likes or comments were unknown — the Actor never fabricates a combined number.

What happens to a post whose duplicate source rows disagree? A free conflicting_duplicate_source diagnostic, excluded entirely from its cohort's statistics — never billed on an arbitrary first-row guess.

Is a cohort of exactly 3 posts as reliable as one of 20? Both bill the same way (≥ 3 is the minimum, not a target), but comparableCohortSize tells you exactly how many posts the median/percentile were computed from.

Does re-running the same batch avoid double-billing? No. Every run is independent with no cross-run deduplication.

What does a FAILED run mean for what was actually delivered? A pricing, budget, ledger, source, algorithm, delivery, free-write or summary-persistence failure stopped the run. Some rows may already have been delivered or charged. Inspect the Dataset, available OUTPUT record and run logs before retrying.

Is the public example Dataset (fuFnFcE7LnzPL0xLc) something I should point production traffic at? No — a small, curated real example for trying the Actor without your own source.

Why do I see fewer than 200 free diagnostic rows even though my batch had many more unmatched rows? The combined-list cap is 200; later result-size/cap notices are additional (see Evidence and boundaries).

Sources and rights

This Actor reads only Datasets and runs your own Apify account token already has permission to read; it never uses another user's credentials and never starts an upstream Actor on your behalf. The public prefill contains six real @nasa posts (three Image, three Sidecar, post dates 2026-09-08 to 2026-09-21). The broader public example Dataset (fuFnFcE7LnzPL0xLc) contains nine posts (six Image, three Sidecar, post dates 2026-09-08 to 2026-09-22). Both are curated, non-sensitive source snapshots originally captured by an upstream apify/instagram-scraper run (sourceActorId shu8hvrXbJbY3Eb9W, sourceRunId ihXlFVzJhgdIQrjRJ, sourceDatasetId yh6mJRFTATd9ABSJR), reused with its original values and timestamps unchanged — a saved example, not a live re-scrape, containing only allowlisted public post fields (no commenter identities, no media). Every output value shown in this README is copied verbatim from real runs of this Actor's accepted build; none are illustrative or invented. This README makes no claim about a post's current, live counter values — Instagram's own counters can change, or be hidden by the poster, after capture.

Related tools for adjacent workflows in social platforms and Instagram data collection. The first three are this project's own Actors for adjacent Instagram/social workflows; the last two are the official Apify Actors this Actor reads its source data from (see Input contract, Integration recipes).

ActorWhat it does
Instagram Public Posts ScraperScrape current public Instagram posts and reels, captions, media links, and source URLs from logged-out pages — a source this Actor can benchmark directly.
Social Preview CheckerAudit observed Open Graph and Twitter Card metadata for up to 100 authorized public pages.
TikTok Public Videos & Profiles ScraperScrape public TikTok video cards, play counts, and current creator profile metrics from official embed endpoints.
Instagram ScraperThe official Apify Actor that produced this README's real captured sample — the flat per-post row shape this Actor reads natively.
Instagram Profile ScraperThe official Apify Actor for profile-level Instagram data — its nested post list needs flattening before use here (see Input contract).