@apicity/meta

中文文档
0.27.0发布于 2026/11/06MITTypeScriptES Module近 30 天下载 3766

Instagram Graph API provider for posting reels via the public-URL flow (graph.instagram.com).

原始使用文档
文档目录13 节
  1. Installation
  2. Quick Start
  3. Setup
  4. 1. Configure your Meta App
  5. 2. Verify Instagram account type
  6. 3. Mint a long-lived access token
  7. 4. Use the token
  8. Real-world example: publish a Reel via the public-URL flow
  9. API Reference
  10. container
  11. media
  12. mediaPublish
  13. License

@apicity/meta

npm dependencies TypeScript

Instagram Graph API provider for posting reels via the public-URL flow (graph.instagram.com).

Runtime dependencies:

  • zod@^4.6.6 — request schemas attached to every POST endpoint as .schema

Installation

npm install @apicity/meta
# or
pnpm add @apicity/meta

Quick Start

import { createMeta } from "@apicity/meta";

const meta = createMeta({ accessToken: process.env.IG_ACCESS_TOKEN! });

Setup

Instagram requires a long-lived (60-day) user access token from the Instagram Login OAuth flow. The token is bound to a specific Instagram Business or Creator account; personal accounts have no programmatic publishing access.

1. Configure your Meta App

Open developers.facebook.com, create a new app (type: Business), then add the Instagram product. Choose API setup with Instagram login and configure:

  • Valid OAuth Redirect URI: http://127.0.0.1:8765/callback
  • Permissions: instagram_business_basic + instagram_business_content_publish
  • Save the Instagram App ID and Instagram App Secret

2. Verify Instagram account type

The Instagram account you're publishing to must be Business or Creator (not Personal). Switch in the Instagram mobile app under Settings → Account type and tools. No Facebook Page link is required for the Instagram Login flow.

3. Mint a long-lived access token

Save the script below as mint-ig-token.mjs and run it:

IG_CLIENT_ID=<your-instagram-app-id> \
IG_CLIENT_SECRET=<your-instagram-app-secret> \
  node mint-ig-token.mjs

It prints an authorize URL — open it, click Allow, and the helper captures the redirect on 127.0.0.1:8765, exchanges the code for a short-lived token, then upgrades it to a long-lived (60-day) token and prints { access_token, user_id, expires_in }. Save both access_token and user_id — you need both to call the API.

mint-ig-token.mjs — zero-dep OAuth 2.0 helper
import http from "node:http";
import crypto from "node:crypto";

const CLIENT_ID = process.env.IG_CLIENT_ID;
const CLIENT_SECRET = process.env.IG_CLIENT_SECRET;
const REDIRECT = "http://127.0.0.1:8765/callback";
const SCOPES = [
  "instagram_business_basic",
  "instagram_business_content_publish",
].join(",");

if (!CLIENT_ID || !CLIENT_SECRET) {
  console.error("Set IG_CLIENT_ID and IG_CLIENT_SECRET");
  process.exit(1);
}

const state = crypto.randomBytes(16).toString("hex");
const authURL = new URL("https://www.instagram.com/oauth/authorize");
authURL.searchParams.set("client_id", CLIENT_ID);
authURL.searchParams.set("redirect_uri", REDIRECT);
authURL.searchParams.set("response_type", "code");
authURL.searchParams.set("scope", SCOPES);
authURL.searchParams.set("state", state);

console.log("Open this URL and click Allow:\n" + authURL.toString());

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, REDIRECT);
  if (!url.pathname.startsWith("/callback")) {
    res.writeHead(404).end();
    return;
  }
  const code = url.searchParams.get("code");
  if (!code || url.searchParams.get("state") !== state) {
    res.writeHead(400).end("bad state");
    server.close();
    process.exit(1);
  }

  // 1. short-lived
  const shortRes = await fetch(
    "https://api.instagram.com/oauth/access_token",
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: "authorization_code",
        redirect_uri: REDIRECT,
        code,
      }),
    }
  );
  const short = await shortRes.json();

  // 2. long-lived (60-day)
  const longURL = new URL("https://graph.instagram.com/access_token");
  longURL.searchParams.set("grant_type", "ig_exchange_token");
  longURL.searchParams.set("client_secret", CLIENT_SECRET);
  longURL.searchParams.set("access_token", short.access_token);
  const longRes = await fetch(longURL);
  const long = await longRes.json();

  console.log(JSON.stringify({
    access_token: long.access_token,
    expires_in: long.expires_in,
    user_id: short.user_id,
  }, null, 2));
  res.writeHead(200).end("Authorized — check your terminal.");
  server.close();
});

server.listen(8765, "127.0.0.1");

4. Use the token

import { createMeta } from "@apicity/meta";

const meta = createMeta({ accessToken: process.env.IG_ACCESS_TOKEN! });
const igUserId = process.env.IG_USER_ID!;

// Public-URL flow: host the mp4 somewhere (e.g. via @apicity/free-media-upload) and
// pass its URL. Meta GETs the video and processes it asynchronously.
const container = await meta.post.v25.media(igUserId, {
  media_type: "REELS",
  video_url: "https://example.com/clip.mp4",
  caption: "hello from @apicity/meta",
});

// Poll until the container is ready.
let status = "IN_PROGRESS";
while (status === "IN_PROGRESS") {
  await new Promise((r) => setTimeout(r, 5000));
  const s = await meta.get.v25.container(container.id, {
    fields: "status_code,status",
  });
  status = s.status_code ?? "FINISHED";
}

// Publish.
const post = await meta.post.v25.mediaPublish(igUserId, {
  creation_id: container.id,
});
console.log(post.id);

Real-world example: publish a Reel via the public-URL flow

Instagram's Graph API doesn't take video bytes directly — Meta needs a publicly reachable URL it can GET and transcode asynchronously. The snippet below chains @apicity/free-media-upload (catbox public hosting, free + zero auth) into @apicity/meta to land an mp4 on disk as a published Reel, mirroring tests/integration/ig-post-video.test.ts step-for-step. The catbox upload replays against tests/recordings/free_2578706139/; the IG calls land in tests/recordings/ig_*/post-video_*/recording.har once a Business/Creator account's IG_ACCESS_TOKEN is recorded.

import { readFileSync } from "node:fs";
import { createMeta } from "@apicity/meta";
import { createFreeMediaUpload } from "@apicity/free-media-upload";

const meta = createMeta({ accessToken: process.env.IG_ACCESS_TOKEN! });
const igUserId = process.env.IG_USER_ID!; // 17-digit numeric, e.g. "17841471234567890"

// 1. Host the mp4 publicly. catbox.moe is auth-free and persistent —
//    Meta's transcode worker will fetch this URL once during step 2,
//    so any host that returns the bytes within ~30s works (S3 presigned
//    URL, R2, your own CDN). @apicity/free-media-upload wraps the multipart upload
//    and returns the resolved file URL as a string.
const bytes = readFileSync("./jump.mp4");
const blob = new Blob([bytes], { type: "video/mp4" });

const freeMediaUpload = createFreeMediaUpload({});
const videoUrl = await freeMediaUpload.catbox.upload({
  file: blob,
  filename: "jump.mp4",
});
console.log(videoUrl);
// → "https://files.catbox.moe/nn9sei.mp4"
//   catbox returns a permanent public URL of the form
//   `https://files.catbox.moe/<6char>.<ext>` — that's what Meta will GET.

// 2. Create a media container. For Reels you must pass `media_type:
//    "REELS"` (NOT "VIDEO" — that's the legacy IGTV path Meta
//    deprecated in 2024). The container is a server-side handle: Meta
//    queues the transcode against `video_url` and returns its id
//    immediately. Containers expire 24h after creation if you don't
//    publish them.
const container = await meta.post.v25.media(igUserId, {
  media_type: "REELS",
  video_url: videoUrl,
  caption: "jump #reels",
});
console.log(container.id);
// → "17889012345678901" (17-digit container id)

// 3. Poll the container's status_code until it leaves IN_PROGRESS.
//    The state machine is IN_PROGRESS → FINISHED on success;
//    FINISHED is the only state media_publish accepts. ERROR and
//    EXPIRED are terminal failure states; PUBLISHED is what you'll
//    see if you re-poll AFTER calling media_publish. The `fields`
//    query param is required — by default the GET only returns `id`.
let statusCode: string = "IN_PROGRESS";
while (statusCode === "IN_PROGRESS") {
  await new Promise((r) => setTimeout(r, 5000));
  const s = await meta.get.v25.container(container.id, {
    fields: "status_code,status",
  });
  statusCode = s.status_code ?? "FINISHED";
  if (statusCode === "ERROR" || statusCode === "EXPIRED") {
    throw new Error(`container ${container.id} ${statusCode}: ${s.status}`);
  }
}
// statusCode → "FINISHED"
// s.status   → "Finished: Media is ready to be published."

// 4. Publish. media_publish takes the container id (NOT the media url)
//    and returns the new post's id — that's the permanent ig_id you'd
//    use to construct an https://www.instagram.com/reel/<shortcode>/
//    URL or to query insights later via Graph API.
const post = await meta.post.v25.mediaPublish(igUserId, {
  creation_id: container.id,
});
console.log(post.id);
// → "17912345678901234" (17-digit post id, distinct from container id)

Notes

  • Meta requires a Business or Creator Instagram account plus a Meta App approved for instagram_business_content_publish. Personal accounts get 190 ("Invalid OAuth access token") even with a syntactically valid token.
  • video_url must be reachable from Meta's IPs and serve Content-Type: video/mp4. Common gotchas: presigned S3 URLs that expire before the transcoder pulls, hosts that require a User-Agent, and CDNs that redirect to a different origin. catbox.moe sidesteps all three.
  • The Reel itself must satisfy Meta's Reel constraints — 9:16 aspect, 3–90s duration, ≤ 1GB, H.264 video, AAC audio. Mismatches surface as status_code: "ERROR" during the poll, with the human-readable reason in status (e.g. "Error: The video is too short.").
  • Containers and posts use distinct 17-digit ids. The container id is throwaway — you only need it for the GET poll and the subsequent media_publish creation_id. The post id is permanent and survives user deletion of the post.
  • Errors throw MetaError with status (HTTP code), body (the parsed Meta error envelope), and an optional code. Meta's two error shapes — error.error_user_msg for user-facing validation and error.message for everything else — are both surfaced in MetaError.message, so a single try/catch reads naturally.

API Reference

3 endpoints across 3 groups. Each method mirrors an upstream URL path.

container

GET meta.v25.container

GET https://graph.instagram.com/v25.0/{containerId}{query}

Cost tier: cheap

Upstream docs ↗

const res = await meta.get.v25.container({ /* ... */ });

Source: packages/provider/meta/src/meta.ts

media

POST meta.v25.media

POST https://graph.instagram.com/v25.0/{igUserId}/media

Cost tier: cheap

Upstream docs ↗

const res = await meta.post.v25.media({ /* ... */ });

Source: packages/provider/meta/src/meta.ts

mediaPublish

POST meta.v25.mediaPublish

POST https://graph.instagram.com/v25.0/{igUserId}/media_publish

Cost tier: cheap

Upstream docs ↗

const res = await meta.post.v25.mediaPublish({ /* ... */ });

Source: packages/provider/meta/src/meta.ts

Part of the apicity monorepo.

License

MIT — see LICENSE.

版本与发布记录

@apicity/meta 共发布 90 个版本,首次发布于 2026/05/10;过去 12 个月发布了 90 个版本。

版本发布日期距今
0.27.0latest2026/11/06今天
0.26.02026/11/051 天前
0.25.02026/11/033 天前
0.24.12026/11/024 天前
0.24.02026/11/014 天前
0.23.12026/11/015 天前

关于 @apicity/meta 的常见问题

@apicity/meta 是什么?

Instagram Graph API provider for posting reels via the public-URL flow (graph.instagram.com).

如何安装 @apicity/meta?

在项目目录运行 npm install @apicity/meta,或使用 pnpm add / yarn add / bun add。

@apicity/meta 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@apicity/meta 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@apicity/meta 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@apicity/meta 可以商用吗?

@apicity/meta 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@apicity/meta 的下载量有多少?

根据 npm 官方下载统计,@apicity/meta 在 2026/10/06 – 2026/11/04 这 30 天内被下载 3,766 次,日均约 126 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@apicity/meta 还在维护吗?

最新版本 0.27.0 发布于 2026/11/06(今天);至今共发布 90 个版本,其中过去 12 个月发布了 90 个;首次发布于 2026/05/10。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。