@illya1123/social-network-sdk
v1.5.0
Published
Framework-independent TypeScript SDK for the Meta social-network family (Facebook Pages, Facebook Login, Instagram Business, Threads), TikTok, YouTube, Zalo Official Accounts, and the Instagram API with Instagram Login.
Maintainers
Readme
@illya1123/social-network-sdk
A framework-independent TypeScript SDK for the Meta social-network family — Facebook Pages, Facebook Login, Instagram Business, and Threads — plus TikTok, YouTube, and Zalo Official Accounts, with a read-only client for the Instagram API with Instagram Login.
- Isomorphic — works with Node.js 18+, Deno, Bun, Cloudflare Workers, and browsers
- ESM + CJS +
.d.tswith subpath exports for network-specific tree shaking - Zero runtime dependencies
pnpm add @illya1123/social-network-sdkUsing npm instead:
npm install @illya1123/social-network-sdkRelease history
npm renders this table directly from the package README. It is generated from
CHANGELOG.md, so the package page and release notes stay in sync.
| Version | Highlights |
| --- | --- |
| 1.5.0 | YouTube provider (YOUTUBE_CHANNEL, @illya1123/social-network-sdk/youtube): Google OAuth with offline access, refresh and revoke; video publishing through the resumable upload protocol with automatic resume; metadata updates, Data API and YouTube Analytics metrics, channel posts, read-only comments, and WebSub push notifications.Zalo Official Account provider (ZALO_OA, @illya1123/social-network-sdk/zalo): OAuth v4 with mandatory PKCE and rotating refresh tokens, consultation replies, ZBS template messages by phone, OA and contact profiles, follower paging, and X-ZEvent-Signature webhook verification and parsing.InstagramLoginClient (@illya1123/social-network-sdk/instagram-login) for the Instagram API with Instagram Login: long-lived token exchange and refresh, profile, media and permalink lookup, media metrics, and webhook subscription. |
| 1.4.1 | Release history được sinh từ changelog và hiển thị trực tiếp trong README trên npm.Sơ đồ luồng SDK/TikTok và bảng API reference mô tả hàm, phương thức, đầu vào, đầu ra, provider options và trạng thái trả về.Công cụ release:plan / release:auto tự suy ra patch, minor hoặc major từ Conventional Commits và chỉ cho phép đúng một bước SemVer kế tiếp. |
| 1.4.0 | Opt-in TikTok Direct Post for videos and photo carousels. The existing INBOX flow and default video.upload authorization remain unchanged.fetchTikTokCreatorInfo() and TikTokProviderAdapter.fetchCreatorInfo() for the creator identity, available privacy levels, interaction settings, and per-creator maximum video duration required by TikTok's Export UI.TIKTOK_DIRECT_POST_SCOPES as an explicit authorization set containing video.publish, plus Direct Post consent, privacy, duration, branded-content, AI-content, interaction, music, and cover options. |
| 1.3.0 | TikTok photo and carousel upload through the Content Posting MEDIA_UPLOAD flow, with title, description, cover selection, and the documented 35-photo limit.fetchTikTokPublishStatus() and TikTokProviderAdapter.fetchPublishStatus() to turn an INBOX publish_id into processing state, failure detail, and final public post IDs.TikTok is no longer marked videoOnly; existing video INBOX publishing and the default video.upload scope remain unchanged. Direct Post is still intentionally opt-in only at the authorization layer and is not performed by the adapter. |
| 1.2.0 | TikTokProviderAdapter — Login Kit OAuth, Display API, Content Posting INBOX. Subpath mới: @illya1123/social-network-sdk/tiktok.Compatibility manifest sở hữu luôn version, endpoint, scope và giới hạn của TikTok. listSocialCompatibility() liệt kê cả bốn provider trong một bảng; listMetaCompatibility() giữ nguyên ba provider Meta như cũ.classifyTikTokError() — TikTok dùng mã lỗi dạng chuỗi và gửi kèm error.code: "ok" trong cả response thành công, nên không dùng chung quy tắc phân loại của Meta được. |
| 1.1.3 | Phát hành nhầm số: nội dung đúng bằng 1.2.0 nhưng đánh số patch trong khi thực tế là thêm tính năng. Dùng 1.2.0 thay cho bản này. |
| 1.1.2 | Thêm quy trình local bằng pnpm cho install, dev, test và build; GitLab CI vẫn dùng npm cho verify, build và Trusted Publishing.Chuyển toàn bộ README hiển thị trên npm sang tiếng Anh. |
| 1.1.1 | Bỏ deploy demo lên GitLab Pages khỏi CI. Trang demo giờ chạy tại máy bằng npm run dev, tiết kiệm ~40% compute minutes mỗi lần push.Thêm thông báo Telegram cho trạng thái build và phát hành, gửi được vào đúng topic của nhóm.Thêm RUNNER.md: hướng dẫn chạy verify và build_lib trên runner tự host. |
| 1.1.0 | README trên npm gọn lại: chỉ còn hướng dẫn dùng hàm. Phần dev/CI/release chuyển sang CONTRIBUTING.md, không đi kèm tarball.Đổi sang phát hành theo tag: push nhánh chỉ verify + build, chỉ khi push tag vX.Y.Z mới publish lên npm. Bỏ semantic-release.Xác thực npm bằng Trusted Publishing (OIDC) thay cho access token, nên không còn secret nào trong CI. |
| 1.0.0 | Bản phát hành đầu tiên.Compatibility manifest sở hữu phiên bản API, endpoint, scope và giới hạn của Meta.Contract SocialProviderAdapter với adapter cho Facebook Pages, Instagram Business và Threads. |
Read the complete changelog for migration notes and every change.
Getting started
All providers are accessed through a registry. Adapters read credentials from
environment variables or from an explicit env object.
import { createSocialProviderRegistry } from "@illya1123/social-network-sdk";
const registry = createSocialProviderRegistry();
// Or load credentials from your vault:
// createSocialProviderRegistry({ env: { META_BUSINESS_LOGIN_APP_ID: "...", ... } })
const facebook = registry.get("FACEBOOK_PAGE");
// Also "INSTAGRAM" | "THREADS" | "TIKTOK" | "YOUTUBE_CHANNEL" | "ZALO_OA"
facebook.isConfigured(); // false when the app ID or secret is missingAfter an account is connected, pass its SocialChannelRef to every operation:
import type { SocialChannelRef } from "@illya1123/social-network-sdk/contracts";
const channel: SocialChannelRef = {
id: "chan_01", // Your internal ID
provider: "FACEBOOK_PAGE",
externalId: "1122334455", // Page / IG user / Threads user / TikTok open_id / YouTube channel / Zalo OA ID
accessToken: "EAA...",
refreshToken: null,
webhookSigningSecret: null,
// Optional, and required only by TikTok: it rejects a whole request when a
// requested field falls outside the scopes the connection actually holds.
scopesGranted: undefined,
};How the SDK works
Every provider follows the same normalized flow, while its adapter owns the provider-specific URL, payload, error codes, and response mapping:
Host application
│
├─ createSocialProviderRegistry({ env, fetch, logger })
│ │
│ └─ registry.get("FACEBOOK_PAGE" | "INSTAGRAM" | "THREADS" | "TIKTOK"
│ | "YOUTUBE_CHANNEL" | "ZALO_OA")
│ │
│ ▼
│ SocialProviderAdapter
│ ├─ OAuth ───────► SocialTokenSet
│ ├─ health ──────► SocialChannelHealth
│ ├─ publish ─────► SocialPublishResult
│ ├─ metrics ─────► SocialMetricSnapshot[]
│ └─ webhooks ────► SocialNormalizedEvent[]
│
└─ Store tokens/channel IDs and schedule retries in the host applicationThe SDK owns provider communication and normalization. Your application still owns token storage, tenancy, user consent, queues, retries, and database writes.
Core API at a glance
| Function or method | Input | Output | Purpose |
| --- | --- | --- | --- |
| createSocialProviderRegistry(options?) | Optional env, injected fetch, logger, provider list | SocialProviderRegistry | Creates all provider adapters without binding the SDK to a framework. |
| registry.get(provider) | A SocialProvider string | SocialProviderAdapter | Selects one normalized adapter. |
| adapter.isConfigured() | None | boolean | Checks whether that provider's app credentials exist. |
| adapter.buildAuthorizeUrl(input) | Signed state, redirect URI, scopes | OAuth URL string | Starts account connection. |
| adapter.exchangeToken(code, redirectUri, options?) | OAuth code, exact callback URI, optional PKCE codeVerifier | Promise<SocialTokenSet> | Exchanges the callback code and resolves the external account. |
| adapter.refreshToken(token) | Provider refresh or renewable access token | Promise<SocialTokenSet> | Renews credentials using provider-specific rules. |
| adapter.healthCheck(channel) | SocialChannelRef | Promise<SocialChannelHealth> | Distinguishes connected, expired, missing-permission, and disconnected states. |
| adapter.fetchChannelProfile(channel) | SocialChannelRef | Promise<SocialChannelProfile> | Reads the normalized label, avatar, username, and counters. |
| adapter.publishContent(channel, input) | Channel, caption, media URLs/assets, provider options | Promise<SocialPublishResult> | Publishes or initializes provider-specific publishing. |
| adapter.fetchMetrics(channel, postIds) | Channel and external post IDs | Promise<SocialMetricSnapshot[]> | Returns normalized likes, comments, views, reach, and native metrics. |
| adapter.verifyWebhook(request) | Raw body, signature, headers | Promise<boolean> | Verifies the provider's signature over the exact raw bytes; fails closed. |
| adapter.parseInboundEvent(rawBody) | Raw webhook body | SocialNormalizedEvent[] | Converts provider webhook payloads into one event shape. |
Capability-gated methods such as fetchPostComments, replyPostComment,
fetchEmbeddablePosts, deletePost, and sendMessage are optional. Check the
method or adapter.capabilities before exposing the corresponding UI.
Common input and output shapes
| Type | Important fields | How to use it |
| --- | --- | --- |
| SocialChannelRef | provider, externalId, accessToken, refreshToken, scopesGranted | Persist one per connected social account, then pass it to adapter operations. |
| SocialTokenSet | accessToken, refreshToken, expiresInSeconds, externalId, label | Store the result of token exchange/refresh; never expose secrets to browser bundles. |
| SocialPublishInput | caption, mediaUrls, assets, providerOptions | Describe content once; use providerOptions only for network-specific switches. |
| SocialPublishResult | outcome, externalPostId, externalPublishId, rateLimitedUntil, providerErrorCode | Save IDs on success, requeue rate limits, and surface deterministic failures. |
| SocialSdkError | code, providerCode, httpStatus, retryAfter, traceId | Branch on stable SDK codes while retaining provider diagnostics. |
Connecting accounts
buildAuthorizeUrl(input)
Meta pins redirect_uri to the app configuration, so all request-specific
context must travel in state. The SDK does not sign or verify state; your
application must do both.
const url = facebook.buildAuthorizeUrl({
signedState: signedJwt,
redirectUri: "https://app.example.com/oauth/meta/callback",
scopes: [], // An empty array uses the provider's default scopes
});exchangeToken(code, redirectUri) and refreshToken(token)
const tokens = await facebook.exchangeToken(code, redirectUri);
// { accessToken, refreshToken, expiresInSeconds, scopesGranted, externalId, label }
// Meta does not issue refresh tokens. Renewal exchanges the same long-lived token.
const renewed = await facebook.refreshToken(tokens.accessToken);PKCE
Zalo OA requires PKCE, TikTok Login Kit for Web refuses the consent screen
without it, and Google (YouTube) accepts it. Meta ignores it. Generate a pair,
keep the verifier server-side next to your state, and hand it back on exchange:
import { createPkcePair } from "@illya1123/social-network-sdk";
const pkce = await createPkcePair(); // { codeVerifier, codeChallenge, codeChallengeMethod: "S256" }
const url = adapter.buildAuthorizeUrl({
signedState,
redirectUri,
scopes: [],
codeChallenge: pkce.codeChallenge,
});
// In the callback:
const tokens = await adapter.exchangeToken(code, redirectUri, {
codeVerifier: pkce.codeVerifier,
});The verifier is 43 alphanumeric characters with at least one uppercase letter, lowercase letter, and digit — valid for RFC 7636 and for Zalo's stricter rule.
healthCheck(channel)
const health = await facebook.healthCheck(channel);
// state: CONNECTED | NEEDS_REAUTH | PERMISSION_MISSING | TOKEN_EXPIRED | ...Low-level token helpers
Publishing and insights reject user tokens because they require a Page token:
import {
metaDebugToken,
metaListAccounts,
metaResolvePageToken,
} from "@illya1123/social-network-sdk/graph";
const pages = await metaListAccounts(userToken);
const pageToken = await metaResolvePageToken(userToken, pageId, false);
// OAuth responses do not include the scopes that were actually granted, and a
// user can decline individual permissions. Inspect the token to get the truth.
const debug = await metaDebugToken(userToken, `${appId}|${appSecret}`);
debug.scopes; // string[]
debug.expiresAt; // Date | nullPublishing
publishContent(channel, input)
Rate limits are returned as an outcome instead of being thrown, which makes them easy for a scheduler to handle.
const result = await registry.get("INSTAGRAM").publishContent(channel, {
caption: "Our new collection",
mediaUrls: ["https://cdn.example.com/1.jpg", "https://cdn.example.com/2.jpg"],
assets: [{ assetType: "IMAGE" }, { assetType: "IMAGE" }],
});
switch (result.outcome) {
case "PUBLISHED":
await save(result.externalPostId);
break;
case "RATE_LIMITED":
await requeue(result.rateLimitedUntil);
break;
case "FAILED":
await markFailed(result.providerErrorCode);
break;
}publishFacebook, publishInstagram, and publishThreads
Call the low-level functions directly when you need to control polling or the transport:
import { publishThreads } from "@illya1123/social-network-sdk/publishing";
await publishThreads({
accessToken: pageToken,
externalId: threadsUserId,
caption: "Text-only post",
mediaUrls: [],
shareToInstagram: true, // crossreshare_to_ig
polling: { intervalMs: 3000, maxAttempts: 30 },
});The SDK handles these provider-specific workflows:
| Scenario | Behavior |
| --- | --- |
| Instagram or Threads media | Creates a container, waits for FINISHED, then publishes. Skipping the wait commonly causes Media ID is not available. |
| Instagram carousel | Creates and waits for every child container before creating the parent container. |
| One Facebook video | Publishes a Reel. Set providerOptions.facebookPublishFormat to "FEED" to force a feed video. |
| Multiple Facebook images | Uploads unpublished photos and attaches them to /feed with attached_media[n]. |
| Mixed Facebook images and videos | Rejects the input before calling the API because Graph otherwise accepts the request and silently drops the videos. |
| Provider limits, such as more than 10 Instagram items or 500 Threads characters | Throws before spending a Graph API request. |
| TikTok | Sends one video or up to 35 photos to the creator inbox by default; supports explicit Direct Post opt-in. See below. |
TikTok: INBOX by default, Direct Post by explicit opt-in
SocialPublishInput
│
├─ tiktokPostMode omitted / MEDIA_UPLOAD
│ ├─ VIDEO ─► inbox/video/init ─► creator completes the post in TikTok
│ └─ PHOTO ─► content/init ─► creator completes the post in TikTok
│
└─ tiktokPostMode = DIRECT_POST
├─ creator_info/query
├─ validate consent + privacy + interactions + video duration
├─ video/init or content/init
└─ publish_id ─► fetchPublishStatus() ─► final public post ID(s)TikTok publishing uses the Content Posting Upload (INBOX) flow. Direct Post is never requested by default, so upgrading does not silently add a permission or change where content goes.
A successful call therefore means delivered to the creator's inbox, not published. The creator finishes the post inside the TikTok app. Video upload keeps its existing behavior:
const result = await registry.get("TIKTOK").publishContent(channel, {
caption: null, // Ignored — the creator writes the caption in the app
mediaUrls: ["https://cdn.example.com/clip.mp4"], // Exactly one video
});
result.outcome; // "PUBLISHED"
result.providerErrorCode; // "TIKTOK_INBOX_USER_ACTION_REQUIRED"Photo upload uses the same video.upload scope and supports one photo or a
carousel of up to 35 photos. TikTok requires every source URL to belong to a
domain or URL prefix verified for the app:
import type { TikTokProviderAdapter } from "@illya1123/social-network-sdk/tiktok";
const tiktok = registry.get("TIKTOK") as TikTokProviderAdapter;
const result = await tiktok.publishContent(channel, {
caption: "Launch day #new", // Photo description in TikTok's editing flow
mediaUrls: ["https://cdn.example.com/one.jpg", "https://cdn.example.com/two.jpg"],
assets: [{ assetType: "IMAGE" }, { assetType: "IMAGE" }],
providerOptions: {
tiktokPhotoTitle: "Launch day",
tiktokPhotoCoverIndex: 1,
},
});
const status = await tiktok.fetchPublishStatus(channel, result.externalPublishId ?? "");
status.status; // "PROCESSING_DOWNLOAD", "SEND_TO_USER_INBOX", "PUBLISH_COMPLETE", ...
status.publiclyAvailablePostIds; // Populated after the creator publishesIf asset metadata is unavailable, set providerOptions.tiktokMediaType to
"PHOTO". Photo titles are limited to 90 UTF-16 code units and descriptions to
4,000. See TikTok's photo-post reference
and post-status reference.
Treat TIKTOK_INBOX_USER_ACTION_REQUIRED as "awaiting the creator" rather than
as a live post. A post without media comes back as FAILED with MEDIA_REQUIRED
before any request is spent.
Direct Post
Direct Post is opt-in for hosts that implement TikTok's required Export UI. Request the separate scope set during authorization, then use the latest creator info to render the creator identity, privacy choices, and enabled interaction controls:
import {
TIKTOK_DIRECT_POST_SCOPES,
type TikTokProviderAdapter,
} from "@illya1123/social-network-sdk/tiktok";
const authorizeUrl = tiktok.buildAuthorizeUrl({
signedState,
redirectUri,
scopes: TIKTOK_DIRECT_POST_SCOPES,
});
const creator = await tiktok.fetchCreatorInfo(channel);
// Render creator.nickname/avatarUrl, creator.privacyLevelOptions, and the
// commentDisabled/duetDisabled/stitchDisabled controls before the user consents.After the user manually selects privacy/interactions and expressly consents, set
tiktokPostMode to "DIRECT_POST". The adapter re-queries creator info just before
initialization, rejects a stale privacy choice, and fails closed for creator-disabled
interactions. Video duration is required so it can enforce the creator-specific limit:
const result = await tiktok.publishContent(channel, {
caption: "Launch day #new",
mediaUrls: ["https://verified.example.com/clip.mp4"],
assets: [{ assetType: "VIDEO" }],
providerOptions: {
tiktokPostMode: "DIRECT_POST",
tiktokUserConsent: true,
tiktokPrivacyLevel: "MUTUAL_FOLLOW_FRIENDS",
tiktokDisableComment: false,
tiktokDisableDuet: true,
tiktokDisableStitch: true,
tiktokBrandContent: false,
tiktokBrandOrganic: false,
tiktokIsAiGenerated: false,
tiktokVideoDurationSeconds: 42,
tiktokVideoCoverTimestampMs: 1_500,
},
});
result.providerErrorCode; // "TIKTOK_DIRECT_POST_PROCESSING"
const status = await tiktok.fetchPublishStatus(channel, result.externalPublishId ?? "");Direct Post provider options:
| Option | Required | Input | Effect |
| --- | --- | --- | --- |
| tiktokPostMode | Yes | "DIRECT_POST" | Selects Direct Post instead of the default INBOX flow. |
| tiktokUserConsent | Yes | true | Confirms the host collected explicit consent in its Export UI. |
| tiktokPrivacyLevel | Yes | A value from creator.privacyLevelOptions | Sets who can view the post; stale values are rejected. |
| tiktokVideoDurationSeconds | Video only | Positive number | Checked against maxVideoPostDurationSeconds. |
| tiktokDisableComment | No | boolean | Defaults to disabled and cannot override creator restrictions. |
| tiktokDisableDuet / tiktokDisableStitch | Video only | boolean | Controls video interactions; creator restrictions win. |
| tiktokBrandContent / tiktokBrandOrganic | No | boolean | Sends TikTok's commercial-content disclosure flags. |
| tiktokIsAiGenerated | Video only | boolean | Requests TikTok's AI-generated label. |
| tiktokVideoCoverTimestampMs | Video only | Non-negative integer | Chooses a cover frame within the video duration. |
| tiktokPhotoTitle / tiktokPhotoCoverIndex | Photo only | string / zero-based integer | Sets the photo title and carousel cover. |
| tiktokAutoAddMusic | Photo only | boolean | Lets TikTok add recommended music. |
fetchCreatorInfo() returns { avatarUrl, username, nickname,
privacyLevelOptions, commentDisabled, duetDisabled, stitchDisabled,
maxVideoPostDurationSeconds }. fetchPublishStatus() returns { publishId,
status, failReason, publiclyAvailablePostIds, uploadedBytes, downloadedBytes }.
For photos, use image assets plus the existing tiktokPhotoTitle and
tiktokPhotoCoverIndex; tiktokAutoAddMusic is also available. Direct Post requires
TikTok app configuration/audit and a host UI that follows the
content-sharing guidelines.
All PULL_FROM_URL media must come from a domain or URL prefix verified for the app.
Threads publish options
Threads-only switches travel in providerOptions and are validated before the
first request — an invalid combination throws instead of being silently dropped:
await threads.publishContent(channel, {
caption: "Which shade should we restock?",
mediaUrls: [],
providerOptions: {
threadsReplyControl: "accounts_you_follow",
threadsTopicTag: "lipstick",
threadsPollOptions: ["Rose", "Coral", "Nude"],
},
});Also available: threadsQuotePostId, threadsReplyToId, threadsAltText,
threadsLinkAttachmentUrl (text-only posts), threadsIsSpoilerMedia,
threadsAllowlistedCountryCodes, and threadsSpoilerTextEntities.
YouTube: resumable uploads
publishContent downloads mediaUrls[0] (HTTPS only, redirects refused) and
uploads it through YouTube's resumable protocol. The caption's first line
becomes the title (100 characters max) and the rest the description; uploads are
private unless you say otherwise:
const youtube = registry.get("YOUTUBE_CHANNEL");
const result = await youtube.publishContent(channel, {
caption: "Launch day\nBehind the scenes of our new range #launch",
mediaUrls: [signedHttpsVideoUrl],
providerOptions: {
youtubePrivacyStatus: "unlisted",
youtubeTags: ["launch", "behind the scenes"],
youtubeMadeForKids: false,
},
});
// Quota and upload limits come back as outcome "RATE_LIMITED".The whole file is held in memory for that call (up to 256 MiB, with a peak of
roughly three times the file size while the request body is copied). For large files, stream from
your storage with the low-level functions instead — startYouTubeUploadSession,
uploadYouTubeVideoContent, queryYouTubeUploadStatus and resumeYouTubeUpload
accept a Blob, ArrayBuffer or Uint8Array and pick up an interrupted upload
from the byte the server confirmed. updateYouTubeVideoMetadata edits title,
description and tags later (needs the youtube.force-ssl scope).
TikTok: uploading a file instead of a URL
PULL_FROM_URL needs a verified domain. Without one, pass the bytes and the
adapter uses TikTok's chunked FILE_UPLOAD flow for INBOX videos:
await tiktok.publishContent(channel, {
caption: "New drop",
mediaUrls: [],
providerOptions: { tiktokVideoFile: videoBlob }, // Blob | ArrayBuffer | Uint8Array
});planTikTokFileUploadChunks(size) exposes the chunk plan (5–64 MB chunks, the
last one absorbing the remainder), and publishTikTokInboxVideoFile is the
standalone function.
Webhooks
Verify before parsing, using the exact original request bytes.
JSON.stringify(JSON.parse(body)) produces a different HMAC and always fails
verification.
resolveMetaWebhookChallenge(query, verifyToken)
import { resolveMetaWebhookChallenge } from "@illya1123/social-network-sdk/webhooks";
app.get("/webhooks/meta", (req, res) => {
const challenge = resolveMetaWebhookChallenge(
{
mode: req.query["hub.mode"],
token: req.query["hub.verify_token"],
challenge: req.query["hub.challenge"],
},
process.env.META_WEBHOOK_VERIFY_TOKEN,
);
return challenge ? res.send(challenge) : res.sendStatus(403);
});verifyMetaWebhookSignature(input) and parseMetaWebhookEvents(rawBody, provider)
import {
parseMetaWebhookEvents,
verifyMetaWebhookSignature,
} from "@illya1123/social-network-sdk/webhooks";
// express.raw() preserves the exact bytes signed by Meta.
app.post("/webhooks/meta", express.raw({ type: "*/*" }), async (req, res) => {
const rawBody = req.body.toString("utf8");
const ok = await verifyMetaWebhookSignature({
rawBody,
signature: req.get("x-hub-signature-256"),
appSecret: process.env.META_BUSINESS_LOGIN_APP_SECRET,
});
if (!ok) return res.sendStatus(401);
res.sendStatus(200); // Respond immediately; Meta waits only a few seconds.
for (const event of parseMetaWebhookEvents(rawBody, "FACEBOOK_PAGE")) {
await enqueue(event);
}
});Every provider produces the same normalized event shape:
{
kind: "MESSAGE" | "COMMENT" | "POST_STATUS" | "DELIVERY",
provider: "FACEBOOK_PAGE",
channelExternalId: "1122334455",
externalUserId: "7788990011",
externalThreadId: "1122334455_9988776655",
externalMessageId: "m_AbCdEf",
isComment: false,
body: "Is size M still available?",
attachments: [{ providerUrl: "...", mimeType: "image/*" }],
// POST_STATUS events only:
externalPostId: "1122334455_9988776655",
postStatus: "PUBLISHED" | "UPDATED" | "PROCESSING" | "FAILED" | "UNAVAILABLE" | "DELETED",
occurredAt: Date,
}The SDK removes Page echoes to prevent ingesting messages your Page just sent,
ignores hide verbs and reactions, reports removed feed posts as POST_STATUS
with postStatus: "DELETED", and normalizes Meta's mixed seconds and
milliseconds timestamps.
standby deliveries — messages in threads another app controls under the
handover protocol — are skipped unless you opt in, so a host that auto-replies to
every MESSAGE never answers a conversation it does not own:
parseMetaWebhookEvents(rawBody, "FACEBOOK_PAGE", { includeStandby: true });subscribeFacebookPageWebhooks({ pageId, accessToken: pageToken, appId })
(from .../meta) subscribes a Page to feed, messages, messaging_postbacks
and standby, keeping fields that were already subscribed and retrying without
standby when the Page cannot use it.
Threads
Signed with the Threads app secret (X-Hub-Signature-256). replies and
mentions become COMMENT events and delete becomes POST_STATUS:
const threads = registry.get("THREADS");
const challenge = threads.resolveWebhookChallenge({ mode, token, challenge }); // THREADS_WEBHOOK_VERIFY_TOKEN
if (await threads.verifyWebhook({ rawBody, signature: req.get("x-hub-signature-256") ?? null, headers: {} })) {
events = threads.parseInboundEvent(rawBody);
}TikTok
Tiktok-Signature: t=…,s=… is an HMAC-SHA256 of `${t}.${rawBody}` with the
client secret, checked against a 5-minute replay window. TikTok's portal sends an
unsigned "Test URL" request that must still get HTTP 200, so tell the three
cases apart with classifyWebhook:
const verdict = await tiktok.classifyWebhook({
rawBody,
signature: req.get("tiktok-signature") ?? null,
headers: {},
});
if (verdict === "INVALID") return res.sendStatus(401);
res.sendStatus(200); // TikTok requires exactly 200
if (verdict === "VERIFIED") enqueue(tiktok.parseInboundEvent(rawBody)); // UNSIGNED: ingest nothingpost.publish.* events become POST_STATUS; parseTikTokWebhookEnvelope exposes
everything else (for example authorization.removed). Deliveries are
at-least-once — deduplicate on your side.
YouTube (WebSub)
Subscribe a channel feed at https://pubsubhubbub.appspot.com/subscribe
(buildYouTubeWebSubSubscriptionForm builds the form). The hub verifies with a
GET challenge, then pushes Atom documents signed with X-Hub-Signature
(sha1= — Google still signs with SHA-1):
import { resolveYouTubeWebSubChallenge } from "@illya1123/social-network-sdk/youtube";
const echo = resolveYouTubeWebSubChallenge(
{ mode: q["hub.mode"], topic: q["hub.topic"], challenge: q["hub.challenge"] },
{ expectedTopics: [feedUrl] },
);
if (await youtube.verifyWebhook({ rawBody, signature: req.get("x-hub-signature") ?? null, headers: {} })) {
events = youtube.parseInboundEvent(rawBody); // POST_STATUS: PUBLISHED / UPDATED / DELETED
}Zalo OA
X-ZEvent-Signature is a plain SHA-256 of app_id + rawBody + timestamp + OA
Secret Key (the OA secret, not the app secret). User messages become MESSAGE
events; parseWebhookEvent also returns follows, unfollows, and delivery/read
receipts:
const zalo = registry.get("ZALO_OA");
if (await zalo.verifyWebhook({ rawBody, signature: null, headers: req.headers })) {
for (const event of zalo.parseInboundEvent(rawBody)) await enqueue(event); // answer 200 within 2 s
}Multi-tenant hosts keep each OA's secret on the channel and call
verifyChannelWebhook(channel, request) instead.
Metrics
fetchMetrics(channel, postIds)
const snapshots = await facebook.fetchMetrics(channel, ["1122334455_1", "1122334455_2"]);
// [{ externalPostId, likeCount, commentCount, viewCount, reach }]
// Deleted posts are skipped instead of failing the entire batch.TikTok additionally fills rawMetrics with its Business Organic insights — watch
time, quartile retention, profile visits — once the app is approved for them.
Before approval that endpoint answers 401/403, and the four normalized counters
are returned on their own instead of the whole call failing.
fetchMetaPostMetrics and fetchThreadsChannelMetrics
Use the low-level functions for a more detailed result:
import {
fetchMetaPostMetrics,
fetchThreadsChannelMetrics,
} from "@illya1123/social-network-sdk/metrics";
const post = await fetchMetaPostMetrics({
accessToken: pageToken,
postId: "17999000000000001",
target: "INSTAGRAM",
});
// { likeCount, commentCount, viewCount, reachCount, saveCount, shareCount, clickCount }
const stats = await fetchThreadsChannelMetrics({ accessToken, userId });Facebook and Instagram use different metric names, such as
post_impressions_unique and reach. The SDK selects the right provider metrics
and maps them to one normalized result.
Threads also reports rawMetrics (views, likes, replies, reposts, quotes,
shares); fetchThreadsPostInsights and fetchThreadsAccountInsights read the
insights endpoints directly. YouTube combines Data API counters with YouTube
Analytics watch time (estimatedMinutesWatched, averageViewDuration, …) in
rawMetrics, falling back to the counters alone when the Analytics scope is
missing.
Comments
const comments = await adapter.fetchPostComments(channel, { postId, limit: 25 });
await adapter.replyPostComment(channel, { commentId, message: "Please check your inbox." });
await adapter.hidePostComment(channel, { commentId, hidden: true });
await adapter.deletePostComment(channel, { commentId });
await adapter.deletePost(channel, { postId });Facebook nests replies under /{comment-id}/comments, Instagram uses
/{comment-id}/replies, and Threads calls comments replies. All are normalized to
SocialPostComment.
TikTok reads comments through the Business Organic API and covers owned posts
only — the Research API that exposes other creators' public comments is
restricted to non-commercial researchers. While that approval is pending,
fetchPostComments returns an empty array rather than throwing. TikTok exposes no
comment write operations, so replyPostComment, hidePostComment and
deletePostComment are absent from its adapter.
YouTube comments are read-only (commentThreads, one level of replies). Threads
adds fetchPostConversation for the full reply tree and fetchMentions for the
mentions inbox.
Zalo Official Account
ZALO_OA is a messaging channel: OAuth v4 with mandatory PKCE, consultation
replies, approved template messages by phone number, OA profile, contact
profiles, and follower paging. Publishing and post metrics are unsupported
(publishContent rejects with UNSUPPORTED, fetchMetrics resolves to []).
const zalo = registry.get("ZALO_OA"); // ZALO_APP_ID, ZALO_APP_SECRET, ZALO_OA_SECRET_KEY
const tokens = await zalo.exchangeToken(code, redirectUri, { codeVerifier }); // externalId = OA ID
await zalo.sendMessage(channel, { externalThreadId: zaloUserId, body: "Xin chào!" });Refresh tokens are single-use and rotate. The refresh token you pass to
refreshToken()is dead afterwards; persist the new pair with compare-and-swap on the token you just used, or two concurrent refreshes strand the OA until an admin re-authorizes.
ZaloProviderAdapter also exposes sendTemplateMessage (ZBS Template Message,
which replaced ZNS on 2026-01-01 and needs a trackingId), fetchContactProfile,
and listFollowers. Zalo reports failures as HTTP 200 with a non-zero error;
classifyZaloError maps them, including quiet hours (-234) and quota codes as
RATE_LIMITED with a retryAfter.
Instagram API with Instagram Login
A read-only creator client for accounts that log in with Instagram rather than
through a Facebook Page (graph.instagram.com). It is not a registry provider:
use it to verify ownership of a creator account and read its media metrics.
import { InstagramLoginClient } from "@illya1123/social-network-sdk/instagram-login";
const instagram = new InstagramLoginClient(); // INSTAGRAM_LOGIN_APP_ID / _APP_SECRET
const url = instagram.buildAuthorizeUrl({ redirectUri, state: signedState });
// Code → short-lived token → 60-day long-lived token → profile, in one call:
const { tokenSet, account, expiresAt } = await instagram.exchangeCodeForGrant({ code, redirectUri });
const renewed = await instagram.refreshGrant(tokenSet.accessToken); // before expiresAt
const mediaId = await instagram.findMediaIdByPermalink(tokenSet.accessToken, permalink);fetchMediaMetrics returns likes, comments and — with
instagram_business_manage_insights (INSTAGRAM_LOGIN_REQUEST_INSIGHTS=1) —
views, reach, saves and shares. subscribeWebhooks subscribes the account to
comments, mentions and messaging fields.
Facebook Login
Facebook Login uses a separate consumer app and never falls back to Business Login credentials. Keeping them separate prevents the login flow from accidentally requesting permissions intended for a marketing app.
import {
FacebookLoginClient,
readFacebookLoginConfig,
} from "@illya1123/social-network-sdk/facebook";
const client = new FacebookLoginClient(readFacebookLoginConfig());
const url = client.buildAuthorizeUrl({ redirectUri, state: signedState });
const profile = await client.exchangeCodeForProfile({ code, redirectUri });
// { provider, providerUserId, email, name, emailVerified, pictureUrl }Meta returns an email address only when it is verified, so its presence is also
the source of emailVerified.
parseFacebookSignedRequest(signedRequest, appSecret)
Use this for Data Deletion callbacks and the Facebook Login JavaScript SDK. The signature is compared in constant time before the JSON payload is parsed.
import { parseFacebookSignedRequest } from "@illya1123/social-network-sdk/facebook";
const payload = await parseFacebookSignedRequest(signedRequest, appSecret);Error handling
Every SDK error is a SocialSdkError with a stable code suitable for branching:
import { SocialSdkError } from "@illya1123/social-network-sdk/errors";
try {
await adapter.publishContent(channel, input);
} catch (error) {
if (!(error instanceof SocialSdkError)) throw error;
switch (error.code) {
case "TOKEN_INVALID":
return promptReauth(channel);
case "PERMISSION_MISSING":
return showScopeUpgrade(error.message);
case "RATE_LIMITED":
return retryAfter(error.retryAfter);
case "NOT_FOUND":
return markDeleted();
default:
logger.error(error.toJSON());
throw error;
}
}| Code | Meaning |
| --- | --- |
| NOT_CONFIGURED | Required app ID or secret is missing. |
| INVALID_INPUT | The provider protocol cannot represent the input. |
| AUTH_EXCHANGE_FAILED | The provider rejected an authorization code or token exchange. |
| TOKEN_INVALID | The token expired, was revoked, or belongs to the wrong asset. |
| PERMISSION_MISSING | Granted scopes do not cover the requested operation. |
| RATE_LIMITED | The provider throttled the request; inspect error.retryAfter. |
| NOT_FOUND | The object does not exist or is not visible to this token. |
| PROVIDER_ERROR | A documented provider error without a more specific mapping. |
| NETWORK_ERROR / TIMEOUT | A transport-layer failure. |
| UNSUPPORTED | The provider does not support this feature. |
| SIGNATURE_INVALID | Webhook signature verification failed. |
error.toJSON() is safe to log and never contains tokens or secrets.
Meta reuses HTTP 400 for authentication, permission, and rate-limit failures, so
error.code and error_subcode are more reliable than the HTTP status:
import { classifyMetaError } from "@illya1123/social-network-sdk/errors";
classifyMetaError(400, { code: 190, error_subcode: 463 }); // "TOKEN_INVALID"
classifyMetaError(400, { code: 4 }); // "RATE_LIMITED"
classifyMetaError(400, { code: 10 }); // "PERMISSION_MISSING"TikTok needs its own classifier: its codes are strings, it answers rejected calls
with HTTP 200 as readily as with a matching status, and — unlike Meta — it puts an
error object in successful responses too, carrying code: "ok".
import { classifyTikTokError } from "@illya1123/social-network-sdk/errors";
classifyTikTokError(200, { code: "access_token_invalid" }); // "TOKEN_INVALID"
classifyTikTokError(200, { code: "scope_not_authorized" }); // "PERMISSION_MISSING"
classifyTikTokError(200, { code: "spam_risk_too_many_posts" }); // "RATE_LIMITED"Injecting fetch and env
Every provider request goes through an injectable runtime. This makes the SDK
testable without module mocks:
import { publishInstagram } from "@illya1123/social-network-sdk/publishing";
const calls: string[] = [];
await publishInstagram({
accessToken: "test-token",
externalId: "IG_1",
caption: "hi",
mediaUrls: ["https://cdn/a.jpg"],
assets: [{ assetType: "IMAGE" }],
polling: { intervalMs: 0, maxAttempts: 3 }, // Avoid a real delay in tests.
runtime: {
env: {}, // Isolate the test from process.env.
fetch: async (url, init) => {
calls.push(`${init?.method ?? "GET"} ${new URL(url).pathname}`);
return Response.json({ id: "container-1", status_code: "FINISHED" });
},
},
});
expect(calls).toEqual([
"POST /v25.0/IG_1/media",
"GET /v25.0/container-1",
"POST /v25.0/IG_1/media_publish",
]);The runtime accepts env, fetch, signal, timeoutMs, and logger.
Provider capabilities
Every adapter implements isConfigured, buildAuthorizeUrl, exchangeToken,
refreshToken, healthCheck, verifyWebhook, parseInboundEvent,
publishContent, fetchMetrics, and fetchChannelProfile.
Optional methods are guarded by capabilities; check them before calling:
if (adapter.capabilities.messaging && adapter.sendMessage) {
await adapter.sendMessage(channel, { externalThreadId, body: "Hello" });
}| Provider | Messaging | Publishing | Ads | Shop | Video only |
| --- | :-: | :-: | :-: | :-: | :-: |
| FACEBOOK_PAGE | ✅ | ✅ | ✅ | — | — |
| INSTAGRAM | ✅ | ✅ | — | — | — |
| THREADS | — | ✅ | — | — | — |
| TIKTOK | — | ✅ | — | — | — |
| YOUTUBE_CHANNEL | — | ✅ | — | — | ✅ |
| ZALO_OA | ✅ | — | — | — | — |
Upgrading from 1.4.x
1.5.0 only adds. Nothing was removed or renamed, and existing calls return what they returned before, with three deliberate exceptions:
TikTokProviderAdapter.verifyWebhook()now verifiesTiktok-Signatureinstead of always returningfalse. Unsigned or forged requests still returnfalse.ThreadsProviderAdapter.verifyWebhook()/parseInboundEvent()now verify and parse Threads webhooks instead of failing closed / returning[].parseMetaWebhookEventsno longer emits an emptyCOMMENTfor a reaction or like on a comment, and now reports removed feed posts asPOST_STATUS.
SocialProvider gained "YOUTUBE_CHANNEL" and "ZALO_OA", so exhaustive maps
and switches over it need two more branches — or pin the older set:
// The default registry now has six adapters. Pin the list to keep the 1.4 shape:
createSocialProviderRegistry({ providers: ["FACEBOOK_PAGE", "INSTAGRAM", "THREADS", "TIKTOK"] });listConfiguredProviders() still reports only providers whose credentials are
present, so a deployment without YOUTUBE_* / ZALO_* variables sees no change.
Upgrading from 1.1.x
TikTok was added in 1.2.0 without removing or renaming anything. Every export the 1.1.x surface had is still exported, and the fields added to the contracts are all optional, so existing code keeps compiling and behaving the same.
Two additive changes are worth knowing about:
// 1. The default registry now has four adapters instead of three.
// Pin the list to keep the old shape:
createSocialProviderRegistry({ providers: ["FACEBOOK_PAGE", "INSTAGRAM", "THREADS"] });
// 2. `SocialProvider` gained "TIKTOK". Only exhaustive maps and switches notice.
// `SocialMetaProvider` is that union as it stood before TikTok:
import type { SocialMetaProvider } from "@illya1123/social-network-sdk/contracts";
const handlers: Record<SocialMetaProvider, Handler> = {
FACEBOOK_PAGE: handleFacebook,
INSTAGRAM: handleInstagram,
THREADS: handleThreads,
};listConfiguredProviders() still reports only the providers whose credentials are
actually present, so a deployment with no TIKTOK_CLIENT_KEY never sees TikTok.
listMetaCompatibility() still covers the three Meta networks alone — use the new
listSocialCompatibility() when you want TikTok in the table too.
Subpath exports
| Import | Contents |
| --- | --- |
| @illya1123/social-network-sdk | All public exports |
| .../compatibility | API versions, endpoints, scopes, and limits |
| .../contracts | Pure types such as SocialProviderAdapter and SocialChannelRef |
| .../errors | SocialSdkError and the per-provider classifiers (classifyMetaError, classifyTikTokError, classifyYouTubeError, classifyZaloError) |
| .../graph | Graph/Threads transport, token exchange, and oEmbed |
| .../facebook | Facebook Login and signed_request helpers |
| .../publishing | publishFacebook, publishInstagram, and publishThreads |
| .../metrics | Post and channel metrics |
| .../webhooks | Meta and Threads signature verification and event normalization |
| .../meta | MetaProviderAdapter, comments, and configuration |
| .../threads | ThreadsProviderAdapter, replies, conversations, mentions, keyword search, profile lookup, and repost |
| .../tiktok | TikTokProviderAdapter, creator info, photo/video INBOX (URL or chunked file) and Direct Post publishing, status, webhooks, transport, and configuration |
| .../youtube | YouTubeProviderAdapter, Google OAuth, resumable upload, metadata updates, metrics, posts, comments, and WebSub webhooks |
| .../zalo | ZaloProviderAdapter, PKCE OAuth, consultation and template messages, OA/user reads, webhooks |
| .../instagram-login | InstagramLoginClient for the Instagram API with Instagram Login |
Environment variables
Values are read in this order: an explicit runtime.env, process.env when
available, then built-in defaults.
| Variable | Used for |
| --- | --- |
| META_BUSINESS_LOGIN_APP_ID / _APP_SECRET | Facebook Pages and Instagram |
| META_BUSINESS_LOGIN_CONFIG_ID | Facebook Login for Business (optional) |
| META_WEBHOOK_VERIFY_TOKEN | Webhook subscription challenge |
| THREADS_CLIENT_ID / THREADS_CLIENT_SECRET | Threads; legacy aliases are THREADS_APP_ID / _APP_SECRET |
| TIKTOK_CLIENT_KEY / TIKTOK_CLIENT_SECRET | TikTok; the public half is a client key, not a client id |
| THREADS_WEBHOOK_VERIFY_TOKEN | Threads webhook subscription challenge |
| YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET | YouTube (Google OAuth client) |
| YOUTUBE_WEBHOOK_SECRET | WebSub hub.secret for YouTube push notifications |
| ZALO_APP_ID / ZALO_APP_SECRET | Zalo OA; ZALO_PLATFORM_* names are accepted as aliases |
| ZALO_OA_SECRET_KEY | Zalo webhook signatures (the OA Secret Key, not the app secret) |
| INSTAGRAM_LOGIN_APP_ID / _APP_SECRET | Instagram API with Instagram Login |
| META_LOGIN_APP_ID / _APP_SECRET | Facebook Login using a separate consumer app |
| META_GRAPH_TIMEOUT_MS / TIKTOK_OPEN_API_TIMEOUT_MS / ZALO_OA_TIMEOUT_MS | Per-request timeout, clamped to 1,000–120,000 ms |
| META_GRAPH_BASE_URL / TIKTOK_OPEN_API_BASE_URL / YOUTUBE_API_BASE_URL / ZALO_OA_OPENAPI_BASE_URL | Redirects SDK requests to a stub host for testing |
App secrets are server-side credentials. Bundling one into browser code exposes it. In client applications, use only pure operations such as the compatibility manifest, authorization URL construction, and event normalization.
License
MIT © illya1123 · Source code and contributing guide
