If you are building anything that writes in a brand's voice — an agent, a draft generator, a scheduler with a "sounds like us" button — the first thing you need is the brand's back catalogue. Not the analytics. The actual post text, in order, with the media attached.
On Instagram there are two routes to that, and they cost very different amounts of work.
Route 1: Instagram's own API
Meta ships two configurations, and which one you need depends on how your users log in:
- Instagram API with Instagram Login — for Instagram-only professional accounts. Permissions:
instagram_business_basic,instagram_business_content_publish,instagram_business_manage_comments,instagram_business_manage_messages. - Instagram API with Facebook Login — for professional accounts linked to a Facebook Page. Permissions:
instagram_basic,instagram_content_publish,instagram_manage_comments,instagram_manage_insights, pluspages_show_listandpages_read_engagement.
Both require an Instagram professional account — business or creator. A personal Instagram account cannot be read this way at all, which is the first thing to check before you write any code.
Once you have a token, past posts come off the media edge:
1curl -G "https://graph.instagram.com/v21.0/me/media" \
2 -d "fields=id,caption,media_type,media_url,permalink,timestamp" \
3 -d "limit=100" \
4 -d "access_token=$IG_TOKEN"The response is cursor-paginated: follow paging.next until it stops coming back.
That is the whole story for Instagram, and it is genuinely fine if Instagram is the only network you care about. The cost is not the request. The cost is everything around it — a Meta app, the right configuration, App Review before you can use those permissions on accounts you do not own, token refresh, and then a second complete implementation the first time someone asks for LinkedIn.
Route 2: one import endpoint, ten networks
Outstand's import endpoint pulls a connected account's published history into your org as an async job:
1curl -X POST "https://api.outstand.so/v1/social-accounts/$ACCOUNT_ID/imports" \
2 -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "since": "2025-01-01T00:00:00Z",
6 "limit": 500
7 }'Three optional parameters, and that is the entire request surface:
since— import posts published after this ISO 8601 timestampuntil— import posts published before this ISO 8601 timestamplimit— maximum posts to import; the accepted range is0 < value <= 1000
It returns 202 with the job, status queued:
1{
2 "id": "imp_9f2c...",
3 "orgId": "org_3a81...",
4 "socialAccountId": "acc_7b40...",
5 "status": "queued",
6 "since": "2025-01-01T00:00:00Z",
7 "until": null,
8 "limit": 500,
9 "imported": 0,
10 "skipped": 0,
11 "failed": 0,
12 "error": null,
13 "createdAt": "2026-09-19T09:14:22.000Z",
14 "updatedAt": "2026-09-19T09:14:22.000Z",
15 "completedAt": null
16}Waiting for the job
Two ways to find out it finished. Poll the job:
1type ImportJob = {
2 id: string;
3 status: string;
4 imported: number;
5 skipped: number;
6 failed: number;
7 error: string | null;
8 completedAt: string | null;
9};
10
11async function waitForImport(
12 accountId: string,
13 importId: string,
14 apiKey: string,
15): Promise<ImportJob> {
16 const url =
17 `https://api.outstand.so/v1/social-accounts/${accountId}/imports/${importId}`;
18
19 for (let attempt = 0; attempt < 60; attempt++) {
20 const res = await fetch(url, {
21 headers: { Authorization: `Bearer ${apiKey}` },
22 });
23 const job: ImportJob = await res.json();
24
25 if (job.completedAt) return job;
26
27 // Back off: imports of several hundred posts are not instant.
28 await new Promise((r) => setTimeout(r, Math.min(2000 * 2 ** attempt, 30_000)));
29 }
30
31 throw new Error(`Import ${importId} did not complete in time`);
32}Or skip polling entirely and subscribe to the import.completed and import.failed webhooks. Every webhook body has the same three root fields — event, timestamp and data — and for imports the data carries the job status plus the imported, skipped and failed counts.
Verify the signature before you trust it. Outstand sends X-Outstand-Signature as sha256= followed by an HMAC-SHA256 of the raw request body, computed with your signing secret:
1import { createHmac, timingSafeEqual } from "node:crypto";
2
3function verifyOutstandSignature(rawBody: string, header: string, secret: string): boolean {
4 const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
5 const a = Buffer.from(expected);
6 const b = Buffer.from(header);
7 return a.length === b.length && timingSafeEqual(a, b);
8}Return a 2xx. Anything else is treated as a failure and retried up to five times with exponential backoff — which is useful, but it also means a handler that throws on a duplicate will be hammered five times.
For a backfill dashboard, GET /v1/social-accounts/{id}/imports lists every job for an account in reverse chronological order.
The limits, stated plainly
This is the part most comparison pages leave out, so here it is in full.
- X is not importable. API tier restrictions prevent user timeline access. There is no workaround on our side — if your product needs a brand's X history, you are buying it from X.
- LinkedIn is organization accounts only. Personal LinkedIn profiles cannot import posts. If a customer connects a personal profile, plan for that path to return nothing and say so in your UI.
- Vimeo is not on the import list. Outstand publishes to twelve networks; import covers ten of them: Bluesky, Facebook, Google Business, Instagram, LinkedIn (organizations), Pinterest, Reddit, Threads, TikTok and YouTube.
- 1,000 posts is the ceiling per job. For a deeper backfill, window it with
sinceanduntiland run several jobs. - Imports are billed. Each successfully imported post counts as one
social_postsusage unit, billed the same day the import runs. A 1,000-post backfill is 1,000 units. Work that out before you loop the import over every account in your database.
Reading the corpus back
Imported posts land in the same collection as everything else you publish, so you read them with the ordinary list endpoint:
1curl -G "https://api.outstand.so/v1/posts" \
2 -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3 -d "social_account_id=$ACCOUNT_ID" \
4 -d "limit=100" \
5 -d "offset=0"limit runs 1–100 and defaults to 50; page with offset against the total in the pagination object.
One thing to design around: there is no imported flag on a post. If you need to tell an imported post apart from one you published through the API, record the job's date range on your side, or filter on created_before the moment you started publishing.
Turning a back catalogue into a voice
Worth being precise about what the import does and does not get you. It imports posts. It does not train anything — there is no voice model in Outstand, and the modelling is yours to do.
What a corpus of real posts is actually good for:
- Few-shot examples. Twenty to fifty of a brand's real posts, dropped into the prompt verbatim, will do more for voice than any style description you can write.
- Extractable rules. Median caption length, emoji rate, hashtag count and placement, whether links go in the caption or the bio, how posts open. These are cheap to compute and easy to enforce as a post-generation check.
- A do-not-repeat check. Embed the corpus once and you can tell a drafting agent when it has just rewritten a post from four months ago.
- Ranking by what worked. Per-post metrics come back from
GET /v1/posts/{id}/analytics, so you can sort the corpus by engagement instead of by whichever posts you happen to like.
A minimal version of the first one:
1type ImportedPost = { text: string; publishedAt: string };
2
3function buildVoicePrompt(corpus: ImportedPost[], brief: string): string {
4 const examples = corpus
5 .slice(0, 40)
6 .map((post, i) => `<example index="${i + 1}">\n${post.text}\n</example>`)
7 .join("\n\n");
8
9 return [
10 "Below are real posts published by this brand. Match their voice:",
11 "sentence length, punctuation, how they open, how much they hedge.",
12 "Do not imitate the specific topics.",
13 "",
14 examples,
15 "",
16 `Now write one post about: ${brief}`,
17 ].join("\n");
18}Nothing clever. The leverage is entirely in having the real posts rather than a paragraph of adjectives about them.
Which route to pick
- Instagram only, on an account you own: use Meta's API directly. One app, one token, done.
- Instagram plus anything else, or accounts your customers connect: use the import endpoint. One OAuth flow, one job shape, one webhook, and the networks you have not added yet come for free.
The import endpoint is documented at Import posts from a social account, with the job status and webhook payloads at Get import job status and Webhooks.
If you are wiring the corpus into an agent rather than a dashboard, giving an AI agent the ability to post picks up where this leaves off. And if you are costing out the Instagram-native route, Instagram API pricing has the numbers.