Adding content

Most of the Analyst API reads your workspace. The content routes are the exception: they let a key add posts to your team the way Add New does in the Siftsy app, so a pipeline can feed Siftsy instead of a teammate pasting links.

They work exactly like the dashboard:

  • Same credits. Every accepted post reserves one MAP (or one Ad Hoc credit when MAPs run out), or five with a Full Read. Posts already on the team and links Siftsy cannot process are never charged.
  • Same team. Content lands on the key’s team. Enterprise keys can name a sub-team with teamId, and that sub-team’s credits are used.
  • Same processing. Posts show up in the app as they process and become readable through GET /posts when they finish, tagged addedVia: api. (Posts a teammate added are dashboard; posts a Siftsy Automation pulled in on a schedule are automation.)

You get back a content job that tells you what was accepted, what was skipped, what was rejected, and how the accepted posts are progressing.

The content routes need the posts:write / accounts:write scopes, which are off by default. A team admin enables them per key under Team Settings → API. Keys with these scopes can spend your team’s credits — treat them like a payment token.

How adding content works

Both routes feed the same pipeline the app uses. Knowing the order matters, because it decides what you are charged for and when a post becomes readable.

Posts by URL

request ──▶ 1. validate ──▶ 2. target team ──▶ 3. collections ──▶ 4. replay check
──▶ 5. dedupe ──▶ 6. fund check ──▶ 7. reserve + create ──▶ 8. queue ──▶ 202 ContentJob
9. fetch ──▶ score ──▶ complete (follow on GET /content/jobs/{id})
  1. Validate. Each link is classified by host. Hosts Siftsy does not process are set aside as rejected (unsupported_platform / invalid_url) and never reach the credit step. Malformed bodies fail the whole request with 400.
  2. Target team. Content lands on the key’s team, or — for Enterprise keys — the sub-team named in teamId. Anything else is 404. The target must be on a plan that includes the API.
  3. Collections. collections[] refs resolve by id or name before any credit is touched. A name that matches nothing creates a campaign; a name that matches more than one active collection stops the request with 409 collection_name_ambiguous.
  4. Replay check. If an identical request (same team, links, collections, tags, credits, capture) has a job within the retention window, that job is returned with 200 and meta.replayed: true. Nothing runs again.
  5. Dedupe. Links are normalized and checked against the team’s existing posts. A link already on the team is skipped (already_complete / already_processing); the same link twice in one call is duplicate_in_batch. Collections and tags are still applied to skipped posts. Nothing is charged.
  6. Fund check. Siftsy estimates the batch cost (1 credit per post, 5 with capture.fullRead) against the team’s remaining MAPs and Ad Hoc credits. If none of it can be funded, the request returns 402 and no post is created. This is why a 402 never leaves half a batch behind.
  7. Reserve and create. For each remaining link, in order: reserve one post’s worth of credits (MAPs first, then Ad Hoc — or Ad Hoc only with credits.source: adhoc), then create the post with status: fetching, stamped addedVia: api. If credits run out partway, the rest of the batch is rejected with insufficient_credits and the job still succeeds for the posts that were funded. A post that fails to create is rejected with create_failed and its reservation is returned.
  8. Queue. Created posts are handed to the processing queue and the request returns 202 with the job. Everything up to here happened inside the request, so accepted[], skipped[], and rejected[] are final at this point.
  9. Process. Workers fetch the post and its comments, run scoring, and move the post to complete. This is the part you follow on the job.

Public accounts

request ──▶ 1–4 as above ──▶ save account ──▶ 202 ContentJob (discovering)
worker: 5. discover ──▶ 6. filter ──▶ [dryRun? → completed]
──▶ 7. reserve + create + queue (steps 5–8 above) ──▶ syncing

Validation, target team, collections, and the replay check run inside the request, then the account is saved on the team (as when you add an account in the app) and the job is returned immediately as discovering. A worker takes it from there:

  1. Discover. Siftsy pulls the account’s recent public posts from its data provider, newest first, honoring since / until, limit, and keywords at the source. The pull is retried up to three times on provider errors; if it still fails, the job is failed with an error and nothing is charged.
  2. Filter. minViews, minLikes, and postType are applied to what came back, duplicates are dropped, and the list is cut to limit. filters on the job echoes exactly what was applied. With dryRun: true the job completes here: discovered[] lists the matches and credits.estimated is what adding them would cost.
  3. Add. The discovered links go through the same dedupe → fund check → reserve + create → queue path as a posts request. Posts already on the team are skipped; if the batch cannot be funded at all the job is failed with error.code naming why (insufficient_credits, no_active_plan, …); partial funding gives a partial job. The job moves to syncing and settles like a posts job.

An account that matches nothing completes with counts.discovered: 0 and no charge.

What a post goes through

accepted[].postStatus on the job, and status on the post itself, follow the app’s processing states:

Post statusPhaseCredits
fetching, queued, processing…In flight: pulling the post and comments, then scoring. Not yet in GET /posts (default status=complete); visible with status=pending or status=allReserved
completeScored and readable through GET /posts/{id}, /metrics, /analysisConsumed — unless the post finished with zero comments, in which case it is refunded
failed, unreachable, cancelledCould not be processed — private, deleted, or unsupported after fetchRefunded
holdingHeld by Siftsy because the post could not be processed right nowRefunded; the job keeps it in syncing until it resolves
archivedHidden from the team’s view by a teammate. A catalog action, not a processing outcomeUnchanged

The job’s credits block is the rollup of that column: reserved counts accepted posts, settled those that reached complete, refunded those that ended in a failure state. Poll GET /content/jobs/{id} at pollAfterMs; each read refreshes those counts from the posts and moves the job from syncing to completed, partial, or failed. Once a job is terminal it never reopens.

For a pipeline, the simplest loop is: POST, then poll the job until it is terminal, then read accepted[].postId with GET /posts/{id}/analysis. If you sync on a schedule instead, the new posts will simply show up in GET /posts?updated_since=… once they are complete, tagged addedVia: api.

Add posts by URL

POST /analyst/v1/content/posts/add
{
"links": [
"https://www.tiktok.com/@dove/video/7412345678901234567",
"https://www.instagram.com/reel/C9AbCdEfGhI/",
"https://example.com/not-a-social-post"
],
"collections": [{ "name": "Q4 Launch" }],
"tags": ["launch", "paid"]
}
HTTP/1.1 202 Accepted
Location: /analyst/v1/content/jobs/cj_5f2c…
Retry-After: 5
{
"data": {
"id": "cj_5f2c…",
"kind": "posts",
"status": "syncing",
"collections": [{ "id": "col_9a1", "name": "Q4 Launch", "type": "campaign", "created": true }],
"tags": ["launch", "paid"],
"credits": { "source": "auto", "perPost": 1, "reserved": 2, "settled": 0, "refunded": 0 },
"counts": { "requested": 3, "accepted": 2, "skipped": 0, "rejected": 1, "syncing": 2, "complete": 0, "failed": 0 },
"accepted": [
{ "link": "https://www.tiktok.com/@dove/video/7412345678901234567", "postId": "post_01j9y", "postStatus": "fetching" },
{ "link": "https://www.instagram.com/reel/C9AbCdEfGhI/", "postId": "post_01j9z", "postStatus": "fetching" }
],
"skipped": [],
"rejected": [{ "link": "https://example.com/not-a-social-post", "reason": "unsupported_platform" }],
"pollAfterMs": 5000
},
"meta": { "status": "syncing", "pollAfterMs": 5000 }
}

Up to 100 links per call, on TikTok, Instagram, YouTube, Facebook, LinkedIn, X, Reddit, or Pinterest. The reserve-and-create step runs inside the request, so the per-link result is final when you get the 202; what remains is processing, which you follow on the job.

Collections: by id or by name

collections takes a list of refs. Use { "id": "col_…" } for a collection you already know, or { "name": "…" }:

  • If exactly one active collection on the team has that name (case-insensitive), the posts go there.
  • If none does, Siftsy creates a campaign with that name and reports created: true on the job.
  • If more than one does, the request fails with 409 collection_name_ambiguous and error.candidates listing the ids, before anything is charged. Retry with an id.

To create a nested group instead of a campaign: { "name": "Wave 1", "type": "group", "parentId": "col_launch" }.

Tags

tags are plain strings. They are trimmed, a leading # is dropped, and duplicates are merged case-insensitively. Tags are also merged onto posts that were already on the team (skipped), so re-adding a link with a new tag is a cheap way to tag it.

Credits and capture

FieldDefaultMeaning
credits.sourceautoauto spends MAPs first and overflows into Ad Hoc credits; adhoc spends Ad Hoc credits only
capture.fullReadfalseCapture the full comment section. 5 credits per post instead of 1
capture.commentLimitCap comments captured per post (ignored with fullRead)

If the team cannot fund any of the links, the response is 402 and nothing is created. If it can fund some, the request succeeds and the unfunded links show up in rejected[] with reason: insufficient_credits.

Retries are safe

An identical request (same team, links, collections, tags, credits, capture) within the job retention window returns the existing job with 200 and meta.replayed: true instead of charging twice. Change any of those and it is a new job.

Add a public account

POST /analyst/v1/content/accounts/add
{
"account": { "url": "https://www.tiktok.com/@dove" },
"filters": { "since": "2026-08-13", "limit": 50, "minViews": 10000, "postType": "video" },
"collections": [{ "name": "Dove creator watch" }],
"tags": ["creator"]
}
HTTP/1.1 202 Accepted
Location: /analyst/v1/content/jobs/cj_7c0e…
Retry-After: 5
{ "data": { "id": "cj_7c0e…", "kind": "account", "status": "discovering", "account": { "url": "https://www.tiktok.com/@dove", "platform": "tiktok" },} }

Siftsy discovers the account’s recent public posts, applies your filters, and adds what matches — the same as adding an account in the app. Discovery takes a little while, so this always runs as a job: it starts discovering, moves to syncing once posts are created, and settles like a posts job.

Supported: TikTok, Instagram, YouTube, Facebook pages, LinkedIn, X.

Filters

These are the account filters the app offers. You cannot hand-pick posts beyond them — if you know which posts you want, use POST /content/posts/add.

FilterMeaning
since, untilPublish window (ISO 8601; a bare date works). until defaults to now
limitPosts to add, newest first. Default 20, max 100
keywordsCaption filters: "scrub" or { "term": "scrub", "match": "includes" | "exact" | "excludes" }
minViews, minLikesEngagement floors
postTypevideo, image, carousel, reel, short, story, text, other

Price it first with dryRun

Add "dryRun": true and the job completes without adding or charging anything. discovered[] lists the posts that matched and credits.estimated is what adding them would cost:

{
"status": "completed",
"dryRun": true,
"counts": { "requested": 34, "discovered": 34, "accepted": 0,},
"credits": { "source": "auto", "perPost": 1, "reserved": 0, "estimated": 34 },
"discovered": [{ "link": "https://www.tiktok.com/@dove/video/…", "postedAt": "2026-09-10T14:02:00.000Z", "postType": "video", "engagement": { "views": 182000, "likes": 9100 } },]
}

Refresh posts already on the team

Comments keep arriving after a post is added. Resync in the app re-fetches them and re-scores the post; POST /content/posts/refresh does the same programmatically, for up to 100 posts by id and/or URL.

POST /analyst/v1/content/posts/refresh
{ "postIds": ["post_01j9y", "post_01j9z"], "links": ["https://x.com/dove/status/1812345678901234567"] }
HTTP/1.1 202 Accepted
Location: /analyst/v1/content/jobs/cj_9a1b…
{
"data": {
"id": "cj_9a1b…",
"kind": "refresh",
"status": "syncing",
"minRefreshIntervalHours": 12,
"credits": { "source": "auto", "perPost": 1, "reserved": 1, "included": 1, "settled": 0, "refunded": 0 },
"counts": { "requested": 3, "accepted": 2, "skipped": 1, "rejected": 0, "syncing": 2, "complete": 0, "failed": 0 },
"accepted": [
{ "postId": "post_01j9y", "link": "", "postStatus": "fetching", "creditSource": "included", "creditCost": 0, "dataVersion": 3 },
{ "postId": "post_01j9z", "link": "", "postStatus": "fetching", "creditSource": "credit", "creditCost": 1, "dataVersion": 2 }
],
"skipped": [
{ "postId": "post_01ja0", "link": "https://x.com/dove/status/1812345678901234567", "reason": "min_interval", "nextRefreshAt": "2026-09-16T23:10:00.000Z" }
],
"rejected": []
},
"meta": { "status": "syncing", "pollAfterMs": 5000 }
}

Two rules make this predictable:

A post is refreshed at most once every 12 hours through the API (and through Siftsy Automations). A post refreshed more recently — by anyone, in the app or by API — is skipped with reason: min_interval and a nextRefreshAt. It is never charged. Schedule refreshes twice a day at most; anything more frequent just returns skips.

It spends the same credits as the app, by the same rules. When the plan’s included refresh cadence (hourly, daily, or weekly depending on plan) allows it, the refresh is free — creditSource: included, creditCost: 0. Otherwise it costs one MAP or Ad Hoc credit (creditSource: credit), or five with capture.fullRead. There is no separate refresh credit, and credits.source picks auto or adhoc as on the other content routes.

Posts that need a credit the team cannot fund are rejected with insufficient_credits; if nothing could be refreshed for that reason the response is 402. Posts still processing are skipped with already_processing. Ids or URLs that are not on the team come back rejected (not_found / not_on_team) — the API never reveals other teams’ posts.

Unlike adding posts, identical refresh requests are not replayed: the 12‑hour floor already makes a repeated call free. Accepted posts reprocess, bump dataVersion, and surface in GET /posts?updated_since=… when done — so a warehouse can refresh its stale posts and pick up the new scores on the next incremental sync.

Follow the job

GET /analyst/v1/content/jobs/cj_5f2c…

Poll at meta.pollAfterMs (default 5 s) while the job is discovering, queuing, or syncing. Each read refreshes counts.syncing / complete / failed from the posts’ processing status and settles the job:

StatusMeaning
discoveringAccount job: finding posts that match the filters
queuingPosts are being reserved and created
syncingPosts are created and processing. accepted[].postStatus shows each one
completedEvery requested link was added and finished processing
partialSome links were skipped, rejected, or failed to process. Check skipped[], rejected[], and counts.failed
failedNothing was added. error says why

credits on the job is an accounting view: reserved for accepted posts, settled for the ones that finished, refunded for accepted posts that could not be processed (Siftsy returns those credits, as in the app).

GET /content/jobs?status=syncing lists recent jobs (summaries, newest first) for the team; jobs are kept for 30 days.

Reasons you will see

ReasonOnMeaning
already_complete, already_processingskippedThe post is already on the team. Not charged; collections and tags still applied
duplicate_in_batchskippedThe same link appeared twice in links
unsupported_platform, invalid_urlrejectedSiftsy cannot process this host. Not charged
insufficient_creditsrejectedThe team ran out of MAPs and Ad Hoc credits partway through the batch
create_failedrejectedA transient failure creating the post. The credit was returned; retry
min_intervalskipped (refresh)The post was refreshed less than 12 hours ago. nextRefreshAt says when it is eligible. Not charged
cooldownskipped (refresh)Inside the plan’s included cadence on a plan that cannot spend credits on refreshes
not_found, not_on_teamrejected (refresh)The id / URL is not a post on the target team
unreachableskipped (refresh)The post’s source is unreachable; Siftsy cannot re-fetch it

Rate limits

ClassPer minutePer dayRoute
content_add_posts10200POST /content/posts/add
content_add_accounts220POST /content/accounts/add
content_refresh10200POST /content/posts/refresh
content_jobs602,000GET /content/jobs, GET /content/jobs/{id}

Credits, not requests, are the real governor here; these caps only stop a runaway loop. See Rate limits.