PostFast
Configure PostFast with a workspace API key, scheduled publishing, key-based media uploads, post analytics, and hosted connect links.
PostFast is an optional managed backend. It holds the connected accounts in your PostFast workspace and publishes posts at the time you schedule. PostFast sponsors Social SDK; the adapter follows PostFast’s public API the same way the other hosted adapters follow theirs.
import { readFile } from "node:fs/promises";
import {
createSocial,
connectedAccountRef,
platformPostRef,
type AdapterOperationContext,
type BackendPostRef,
type ConnectedAccountRef,
type ConversationRef,
type DeliveryOutcome,
type Platform,
} from "@opencoredev/social-sdk";
import { postfast } from "@opencoredev/social-sdk/cloud/postfast";
// Get tenantId from your authenticated server session and enforce account grants.
const authorization = { tenantId: "tenant-from-session" };
const social = createSocial({
backend: postfast({
apiKey: process.env.POSTFAST_API_KEY!,
}),
});
const accounts = await social.accounts.list({ backend: "default", authorization });
function selectAccount<P extends Platform>(platform: P): ConnectedAccountRef<P> {
const match = accounts.items.find((item) => item.ref.platform === platform);
if (!match) throw new Error(`Connect a ${platform} account first.`);
return connectedAccountRef({
backend: match.ref.backend,
platform,
accountId: match.ref.accountId,
});
}
const account = selectAccount("x");
const youtubeAccount = selectAccount("youtube");
const tiktokAccount = selectAccount("tiktok");
const at = new Date(Date.now() + 7 * 86_400_000).toISOString();
const video = new Blob([await readFile("./assets/product-demo.mp4")]);
The API key belongs to one PostFast workspace. Keep it on your server; the adapter sends it in the pf-api-key header.
Every post is scheduled
PostFast’s API has no publish-now call. Each target needs a future schedule.at within one year. The adapter checks relative to its configured clock in preparation and publication; preparation reports schedule.required, schedule.past or schedule.horizon otherwise:
const result = await social.posts.publish(
{
targets: [{ account }],
content: { text: "Launching next week." },
schedule: { at },
},
{ authorization: { tenantId: "tenant-from-session" } },
);
A successful submission returns a scheduled outcome whose job ID is the PostFast post ID. If your workspace requires post approval, the outcome is accepted with backend state SCHEDULED/PENDING_APPROVAL until someone approves it in PostFast. Call getDelivery later to see published, failed, or the approval state. An accepted outcome also occurs when creation returns a post ID but the follow-up read fails; retain its delivery reference and read status later. It does not prove pending approval in that case. PostFast has no webhooks, so poll for status.
Drafts and approval
The native submitPost({ account, content, options?, schedule?, submission }) creates a post that does not go straight onto the schedule, with the same options and checks as posts.publish:
{ kind: "draft" }sendsstatus: "DRAFT". A DRAFT needs noschedule; PostFast keeps it until someone schedules it in the app. The outcome isacceptedwith backend stateDRAFT.{ kind: "approval" }sendsstatus: "SCHEDULED"withapprovalStatus: "PENDING_APPROVAL"and needs a futureschedule.at. It publishes only after someone approves it in PostFast.
const native = postfast({ apiKey: process.env.POSTFAST_API_KEY! }).native;
const draft = await native.submitPost(
{ account, content: { text: "Draft for review" }, submission: { kind: "draft" } },
context,
);
The remaining examples pass authorization the same way. Related server helpers are in examples/snippets/backend-postfast.ts.
Poll for status
Store the outcome’s delivery reference and poll it from a job. Keep polling while the post is scheduled or accepted with a delivery reference, and stop on any other state.
import { setTimeout as sleep } from "node:timers/promises";
const delivery = result.outcomes[0]?.delivery;
if (!delivery) throw new Error("No delivery was confirmed; reconcile the submission first.");
let outcome = await social.posts.getDelivery(delivery, { authorization });
for (let check = 1; check < 30 && isWaiting(outcome); check++) {
await sleep(60_000);
outcome = await social.posts.getDelivery(delivery, { authorization });
}
function isWaiting(outcome: DeliveryOutcome): boolean {
switch (outcome.state) {
case "scheduled": // due later
case "accepted": // known approval state or accepted create with an unavailable read
return true;
case "processing": // this adapter does not currently emit processing
case "published":
case "failed":
case "cancelled":
case "not-submitted":
case "unknown": // reconcile in PostFast before retrying
return false;
}
}
If the submission itself was ambiguous, the outcome is unknown with no delivery. Check PostFast before you publish again.
Media
PostFast references media by storage key, not by URL. The adapter asks PostFast for a signed upload URL, uploads the bytes there without your API key, and sends the key with the post. HTTPS media URLs are rejected at preparation, because the SDK does not fetch remote files for you. Supported types are JPEG, PNG, GIF, and WebP images up to 10 MB, MP4, WebM, and MOV (QuickTime) videos up to 250 MB, and for LinkedIn one PDF, DOC, DOCX, PPT or PPTX document up to 60 MB.
A video’s thumbnail, given as a JPEG or PNG Blob with its type set, is uploaded and sent as the media item’s coverImageKey, the cover image; on YouTube it becomes youtubeThumbnailKey instead, which needs a phone-verified channel. TikTok coverTimestampMs and Instagram thumbOffsetMs pick a cover frame instead and are sent as coverTimestamp in milliseconds.
native.uploadCaptions({ file, format }) uploads an SRT (application/x-subrip) or WebVTT (text/vtt) caption file up to 10 MB and returns its key. Pass it as the YouTube captionKey with the video’s language; PostFast adds it as one caption track after the upload.
Pass a Blob in the post, or upload it first with media.upload and publish the returned reference. The reference resolves through the adapter’s mediaStore, which is in memory by default, so upload and publish with the same adapter instance or configure a shared store.
const media = await social.media.upload(
{
kind: "video",
source: { kind: "blob", blob: video, fingerprint: "product-demo-v1" },
mimeType: "video/mp4",
filename: "product-demo.mp4",
},
youtubeAccount,
{ authorization },
);
await social.posts.publish(
{
targets: [
{
account: youtubeAccount,
options: { title: "Product demo", visibility: "public", madeForKids: false },
},
],
content: {
text: "A short demo of the new editor.",
media: [{ kind: "video", source: { kind: "media-ref", ref: media }, mimeType: "video/mp4" }],
},
schedule: { at },
},
{ authorization },
);
Bluesky video is supported: PostFast’s Bluesky guide (updated October 2026) accepts one MP4 video up to 100 MB per post, with no images alongside it. Other Bluesky videos are rejected at preparation with bluesky.video.
Supported operations
The adapter supports account listing, scheduled publishing, delivery status, posts.list, cancelling a future scheduled post, deleting a failed backend record, media upload, post analytics and follower counts. Cancelling deletes the PostFast record.
Account listing maps PostFast’s connectionStatus to the account status:
PostFast connectionStatus |
status |
|---|---|
CONNECTED |
connected |
DISABLED |
reconnect-required |
| Missing or any other value | unknown |
When PostFast sends a disabledReason string, the account keeps it unchanged as native.disabledReason. Show it to the user when asking them to reconnect. Accounts without one have no native field.
posts.list reads an account’s posts from GET /social-posts?socialMediaIds=, 1 to 50 per page, ordered by schedule time. It includes the posts PostFast imports from the last 60 days on Facebook Pages, Instagram, Threads and TikTok when an account connects, including posts made outside PostFast.
The native bulkDelete({ ids }) deletes 1 to 100 posts in one bulk request (POST /social-posts/bulk-delete) and returns deletedIds and notFoundIds. Like the single delete, it removes PostFast records only; published posts stay live.
if (outcome.state === "scheduled") {
const { backendRecord } = await social.posts.cancelScheduled(outcome.job, { authorization });
}
if (outcome.state === "failed" && outcome.delivery) {
const record: BackendPostRef = {
kind: "backend-post",
version: 1,
backend: outcome.delivery.backend,
platform: outcome.delivery.platform,
accountId: outcome.delivery.accountId,
recordId: outcome.delivery.deliveryId,
};
await social.posts.deleteBackendRecord(record, { authorization });
}
Post analytics need the platform post reference from a published outcome, because it carries the PostFast record ID. Metrics include likes, comments, shares, impressions, reach, interactions, and video views where the platform reports them, plus watch time, Instagram save rate and reelsSkipRate (the share of Reel viewers who skipped in the first three seconds), and TikTok favorites from extras.
getAccountMetrics returns followers from the account’s followerCount, refreshed daily around 04:00 UTC, with followerCountUpdatedAt as the measured day. X and Google Business Profile have no count yet. The native followerHistory({ account, from?, to? }) reads daily snapshots and the net change from GET /social-media/:id/follower-history (90 days by default, up to 365).
if (outcome.state === "published") {
const metrics = await social.analytics.getPostMetrics(outcome.post, { authorization });
}
Pinterest, Telegram, and Google Business Profile accounts are listed as pinterest, telegram, and google-business. Pinterest needs boardId (and accepts link), Google Business Profile needs locationId, and Telegram takes no options. PostFast accepts uploaded media only, so pins need uploaded bytes rather than a URL. Accounts on other networks are left out unless you set unknownPlatforms: "include", which lists them for reading only. Comments are covered by the Social Inbox below.
Platform options
| Platform | Social SDK option | PostFast control |
|---|---|---|
| YouTube | title, visibility, madeForKids |
youtubeTitle, youtubePrivacy, youtubeMadeForKids |
| YouTube | isShort |
youtubeIsShort (defaults to false) |
| YouTube | tags, playlistId, containsSyntheticMedia |
youtubeTags, youtubePlaylistId, youtubeContainsSyntheticMedia |
| YouTube | language, captionKey |
youtubeLanguage, youtubeCaptionKey |
shareToFeed |
instagramPostToGrid |
|
placement: feed, story, reels |
instagramPublishType: TIMELINE, STORY, REEL |
|
collaborators, locationId, aiGenerated |
instagramCollaborators, instagramLocationId, instagramIsAiGenerated |
|
trialReel: "performance" |
instagramTrialReelStrategy: "SS_PERFORMANCE" |
|
reel, story |
facebookContentType: REEL, STORY |
|
collaborators, locationId, geoRestriction |
facebookReelsCollaborators, facebookPlaceId, facebookTargetCountries |
|
carouselCards, carouselLink, carouselEndCard |
mediaLink, mediaCaption, mediaDescription per image; facebookCarouselMainLink; facebookCarouselShowEndCard |
|
| TikTok | title, autoAddMusic, musicSoundId |
tiktokTitle, tiktokAutoAddMusic, tiktokMusicSoundId |
| X | repostUrl |
xRetweetUrl |
| Threads | topicTag |
threadsTopicTag |
documentTitle with one document |
linkedinAttachmentTitle, linkedinAttachmentKey |
|
| TikTok | disableComments, disableDuet, disableStitch |
tiktokAllowComments, tiktokAllowDuet, tiktokAllowStitch |
| TikTok | ownBrand, brandedContent, aiGenerated, draft |
tiktokBrandOrganic, tiktokBrandContent, tiktokIsAigc, tiktokIsDraft |
isShort: true publishes a YouTube Short (youtubeIsShort); without it the adapter sends youtubeIsShort: false for an ordinary video. tags are up to 100 characters each, 500 combined; playlistId comes from native.youtubePlaylists; captionKey needs language, a BCP-47 code such as en or pt-BR.
firstComment posts a first comment about ten seconds after publication on X, Instagram, Facebook, YouTube, Threads and TikTok (up to 1,200 characters on TikTok, where comments must be on). PostFast does not support it on LinkedIn, Pinterest, Bluesky or Google Business Profile, so it is rejected there.
Instagram placement: "reels" needs one video; locationId (a Page ID from native.searchPlaces) works on a single image, video, Reel or Story, not carousels. PostFast documents only the performance trial Reel strategy, so trialReel: "manual" is rejected. Facebook locationId and geoRestriction (up to 25 ISO country codes) apply to feed posts; a link carousel takes 2 to 10 images, one card each, and its main link defaults to the first card’s. carouselEndCard: true sends facebookCarouselShowEndCard, which adds Facebook’s end card after the last card; PostFast leaves it off by default, and the option needs carouselCards. TikTok musicSoundId comes from native.tiktokSounds and cannot be combined with autoAddMusic: true or a draft. X repostUrl reposts an X post at the scheduled time and needs no text (PostFast sends empty content); Threads topicTag is 1 to 50 characters without . or &.
Google Business Profile takes topicType (STANDARD, EVENT or OFFER), callToActionType (BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP or CALL) with callToActionUrl, and for events and offers eventTitle (up to 58 characters), eventStart and eventEnd (ISO 8601, required for EVENT and OFFER), offerCouponCode, offerRedeemUrl and offerTerms. They map to gbpTopicType, gbpCallToActionType and the other gbp* controls. A post takes at most one image.
PostFast TikTok videos use account-default privacy and photos default public. The deprecated tiktokPrivacy control does not override privacy. Every non-draft TikTok target must request PUBLIC_TO_EVERYONE; other values are rejected before dispatch. Only explicit draft: true avoids direct publication. Obtain creator consent after preview and pass all choices:
const tiktokOptions = {
privacy: "PUBLIC_TO_EVERYONE",
consentGiven: true,
disableComments: false,
disableDuet: false,
disableStitch: false,
brandedContent: false,
ownBrand: false,
aiGenerated: false,
draft: false,
} satisfies import("@opencoredev/social-sdk").PublishOptionsFor<"tiktok">;
X reply settings other than everyone have no PostFast mapping and are rejected.
Connect links
native.createConnectLink creates a hosted page where someone connects social accounts to your workspace without a PostFast account. The URL carries a signed token, so send it only to the intended person. Call it on the adapter with an operation context:
const context: AdapterOperationContext = {
backendInstance: "default",
correlationId: crypto.randomUUID(),
retryBudget: { maxAttempts: 1, maxElapsedMs: 30_000 },
authorization: { tenantId: "tenant-from-session" },
};
const adapter = postfast({ apiKey: process.env.POSTFAST_API_KEY! });
const { url } = await adapter.native.createConnectLink(
{ platforms: ["X", "LINKEDIN"], expiryDays: 7, externalId: "customer-42" },
context,
);
After connection, list accounts on the server and verify the new returned account IDs against the pending tenant connection. externalId links the provider flow to your customer record; it is not a social account ID or an authorization grant. Store the returned account IDs, platforms, and backend name in your tenant grants before using them. See capabilities for the generated matrix.
Lookups
Native lookups return the IDs that platform options need:
pinterestBoards({ account })lists Pinterest boards (boardIdforoptions.boardId) fromGET /social-media/:id/pinterest-boards.youtubePlaylists({ account })lists the channel’s playlists (playlistId).gbpLocations({ account })lists Google Business Profile locations (locationId).tiktokSounds({ account, genre?, countryCode?, dateRange? })lists up to 100 trending licensed sounds (musicSoundId), already ranked.searchPlaces({ query })finds Facebook Pages with an address; eachidworks as Facebook and InstagramlocationId. PostFast allows 110 searches an hour.
Social Inbox
PostFast’s Social Inbox collects comments on the workspace’s own posts on TikTok, Instagram, Facebook Pages and Threads, from the moment an account connects. The native adapter reads and answers it through /social-inbox:
listInboxConversations({ account, page?, limit?, unreadOnly?, statuses?, assignedToUserId? })lists comment threads withcanReply,maxReplyLength,unreadCount, triagestatusandassignedToUserId; gate replies on those fields.statusesfilters by triage state (OPEN,SNOOZED,CLOSED, sent comma-separated) andassignedToUserId(a PostFast user UUID) keeps only that teammate’s conversations.listInboxItems({ conversationId, page?, limit?, order? })lists a thread’s comments and your replies, oldest first unlessorderisDESC; each itemidis what the next calls take.replyToInboxItem({ itemId, text, idempotencyKey? })replies publicly under a comment. Pass anidempotencyKeyso a retry does not double-send.privateReply({ itemId, text })sends a private reply, delivered as one DM, to an Instagram comment that hascanPrivateReply, within seven days and up to 1,000 bytes.moderateInboxItem({ itemId, action })takeshide,unhideordeleteand changes the comment on the platform throughPOST /social-inbox/items/:id/state. Delete cannot be undone and is unavailable on Threads.markInboxRead({ conversationId })andinboxUnreadCount()manage PostFast’s own unread state; nothing changes on the platform.setInboxStatus({ conversationId, status })sets the triage state toOPEN,SNOOZEDorCLOSEDthroughPOST /social-inbox/conversations/:id/statusand returns the updated conversation. Triage is PostFast state only.assignInboxConversation({ conversationId, assigneeUserId })assigns the conversation to a workspace member (a PostFast user UUID) throughPOST /social-inbox/conversations/:id/assign. PassassigneeUserId: nullto unassign; the adapter then sends{}. PostFast rejects a user outside the workspace withinbox.assigneeNotMember.
Both replies return the created OUTBOUND item, whose deliveryStatus stays PENDING until the platform accepts it. If a reply response does not show the item’s id and OUTBOUND direction, or a moderation response does not show the requested state, the call throws ambiguous_outcome with a reconcile-first retry disposition: the write may have landed, so read the conversation with listInboxItems before sending it again.
Both triage calls return the conversation fields that listInboxConversations returns. If the response does not show the requested status or assignedToUserId, the call throws ambiguous_outcome with a reconcile-first retry disposition: read the conversation with listInboxConversations before sending it again.
await adapter.native.setInboxStatus({ conversationId, status: "CLOSED" }, context);
await adapter.native.assignInboxConversation(
{ conversationId, assigneeUserId: "c7d8e9f0-1234-5678-9abc-def012345678" },
context,
);
Raw requests
adapter.native.request(input, context) sends one request to a PostFast endpoint the SDK does not wrap, with the pf-api-key header.
The only host is api (https://api.postfa.st).
const native = postfast({ apiKey }).native;
const response = await native.request(
{ method: "GET", path: "/social-media/my-social-accounts" },
context,
);
if (response.responseType === "json") console.log(response.status, response.body);
path is relative to the base URL and starts with /; put parameters in query. Before any request, the adapter rejects absolute URLs, // paths, .. segments, encoded separators, and ? or # in the path. It also rejects credential and framing headers such as Authorization, Cookie and Content-Type, plus pf-api-key. It does not follow redirects. A body has a kind of json, text, bytes, form or urlencoded, and its kind sets Content-Type; text and bytes also take a contentType. Set responseType to "text" or "bytes" for a body that is not JSON. Response bodies are capped at 2 MiB, and maxResponseBytes raises the cap to at most 16 MiB. The result has status, responseType, body and lowercase headers. Name extra headers in responseHeaders; set-cookie is never returned. A non-2xx response rejects with a SocialError.
The API key reaches the whole workspace, and the SDK cannot tell which accounts a raw path touches, so check the tenant’s grants yourself before the call. A raw request makes one attempt. It adds no idempotency key and records no outcome, so you reconcile the writes you send. A write rejects with ambiguous_outcome when it fails without a definitive answer: a timeout, a cancellation after dispatch, a 5xx, or a 2xx body that cannot be decoded. Prefer a typed method when one exists.
Limits and requirements
Not supported
- Publishing now: PostFast’s API has no publish-now call, so every target needs a future
schedule.at; see Every post is scheduled. - Removing a published post: cancelling and deleting change only the PostFast record, so neither removes a post that already published.
- Alt text: PostFast’s schema has no alt text field, so attachments with
altTextare rejected. - Direct messages are not part of this adapter.
- Normalized
commentsAPI: it is not mapped to the Social Inbox, because the inbox groups comments by conversation rather than by post. - TikTok
full_video_watched_rate: the raw value is not mapped because PostFast does not document its scale. - PostFast’s
sendEmailconnect-link option, which emails the link from PostFast, is not exposed; send the link through your own app.