Upload-Post
Configure Upload-Post to publish text, photos, video, and LinkedIn documents, schedule posts, chain X and Bluesky replies, and verify webhooks.
Upload-Post is an optional managed backend. Your Upload-Post account holds profiles, and each profile connects one account per platform. The adapter publishes through Upload-Post’s REST API with one profile and one platform per request, so every destination keeps its own outcome.
import { readFile } from "node:fs/promises";
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { uploadPost } from "@opencoredev/social-sdk/cloud/upload-post";
const adapter = uploadPost({
apiKey: process.env["UPLOAD_POST_API_KEY"]!,
webhookSecret: process.env["UPLOAD_POST_WEBHOOK_SECRET"],
});
const social = createSocial({ backend: adapter });
// Get tenantId from your authenticated server session and enforce account grants.
const authorization = { tenantId: "tenant-from-session" };
const accounts = await social.accounts.list({ backend: "default", authorization });
const x = connectedAccountRef({ backend: "default", platform: "x", accountId: "my-brand" });
const linkedin = connectedAccountRef({
backend: "default",
platform: "linkedin",
accountId: "my-brand",
});
The adapter sends the key as Authorization: Apikey <key>. Keep it on your server.
Accounts are profile and platform pairs
accounts.list reads /uploadposts/users and returns one account for each connected platform on each profile. The account ID is the Upload-Post profile username, the user value in every Upload-Post call. Platform and profile together identify the destination. A slot that needs reauthentication is reported as reconnect-required. Listing and publishing cover different sets. Listing returns slots on every network the SDK recognizes, such as Pinterest and Reddit. Publishing covers X, Threads, Bluesky, YouTube, TikTok, Instagram, LinkedIn, and Facebook; a target on any other listed network fails preparation with platform.unsupported before any request.
Slots on networks the SDK does not know are skipped. Pass unknownPlatforms: "include" to list them under Upload-Post’s own name, for reading only:
const adapter = uploadPost({
apiKey: process.env["UPLOAD_POST_API_KEY"]!,
unknownPlatforms: "include",
});
Publish
const result = await social.posts.publish(
{
targets: [{ account: x }, { account: linkedin }],
content: { text: "Shipping today." },
idempotencyKey: "launch-2026-10-24",
},
{ authorization },
);
The adapter picks Upload-Post’s route from the content:
| Content | Route | Platforms |
|---|---|---|
| Text only | /upload_text |
X, Threads, Bluesky, LinkedIn, Facebook |
| Images (Instagram and Threads also accept mixed carousels) | /upload_photos |
All except YouTube |
| One video | /upload |
All eight |
| One PDF, PPT, PPTX, DOC, or DOCX | /upload_document |
Each request sends the SDK’s derived target key as Idempotency-Key, which Upload-Post uses to return the existing job on a retry instead of creating a duplicate.
A synchronous result with a native post_id becomes published. A success without a native post ID becomes unknown, because the SDK never reports accepted work as published. An async response (request_id) becomes accepted with a delivery reference.
Media
Upload-Post has no standalone upload endpoint that returns a reusable handle, so media.upload is unsupported. Pass media in the publish request as a public HTTPS URL, which Upload-Post downloads, or a Blob, which the adapter sends as a multipart file:
const deck = new Blob([await readFile("./assets/deck.pdf")]);
await social.posts.publish(
{
targets: [{ account: linkedin, options: { visibility: "public" } }],
content: {
text: "Our Q3 results",
media: [
{
kind: "document",
mimeType: "application/pdf",
filename: "q3-results.pdf",
source: { kind: "blob", blob: deck, fingerprint: "q3-results-v1" },
},
],
},
},
{ authorization },
);
The document’s filename is shown as its title, and content.text is the commentary. Upload-Post documents no scheduling for documents, so a scheduled document fails preparation with upload_post.document_schedule instead of publishing immediately. Streams, media-ref sources, alt text, per-attachment captions, and thumbnails are rejected at preparation because Upload-Post documents no mapping for them.
Options
| Platform | Supported options | Upload-Post fields |
|---|---|---|
| X | replySettings |
reply_settings (everyone sends nothing) |
| YouTube | title, visibility, madeForKids, categoryId |
title, privacyStatus, selfDeclaredMadeForKids, categoryId; content.text is the description |
shareToFeed (video) |
share_to_feed |
|
visibility |
visibility; video and documents default to PUBLIC, connections-only works for video and documents only |
|
| TikTok | Every required choice, plus photoCoverIndex |
privacy_level, post_mode (draft), disable_*, brand toggles, AI disclosure |
Any other option fails preparation with options.unmapped. Upload-Post strips URLs from X posts unless your account enables its link add-on. On Facebook, Upload-Post selects the Page automatically only when the profile has one connected Page.
Replies and thread chains
Upload-Post documents reply_to_id for X on every route and for Bluesky on text posts. A target with a platform-post replyTo on those destinations is published as a reply. That makes publishSequence with replyToPrevious work across several destinations, each building its own chain:
const bluesky = connectedAccountRef({
backend: "default",
platform: "bluesky",
accountId: "my-brand",
});
await social.posts.publishSequence(
{
idempotencyKey: "launch-thread",
replyToPrevious: true,
items: [
{ targets: [{ account: x }, { account: bluesky }], content: { text: "1/ We shipped." } },
{
targets: [{ account: x }, { account: bluesky }],
content: { text: "2/ Here's what changed." },
},
],
},
{ authorization },
);
Other platforms, Bluesky media replies, and comment references are rejected with reply.use_comments before anything is sent.
Schedule, status, and cancel
const scheduled = await social.posts.publish(
{
targets: [{ account: x }],
content: { text: "See you Monday." },
schedule: { at: "2026-11-02T09:00:00Z", timeZone: "Europe/Madrid" },
},
{ authorization },
);
const outcome = scheduled.outcomes[0];
if (outcome?.state === "scheduled" && outcome.delivery) {
const status = await social.posts.getDelivery(outcome.delivery, { authorization });
await social.posts.cancelScheduled(outcome.job, { authorization });
}
schedule.at is sent as scheduled_date and timeZone as timezone. The response’s job_id becomes the scheduled job.
getDelivery reads /uploadposts/status. Pending jobs stay scheduled; queued or running uploads are processing; failed or skipped platforms are failed. Upload-Post’s status endpoint has no native post IDs, so for a completed upload the adapter looks up platform_post_id in the latest 100 entries of upload history. If it is not there, the outcome is unknown; check the Upload-Post dashboard before you publish again.
cancelScheduled first reads the job and refuses one that does not belong to exactly this profile and platform. It then deletes the job, which Upload-Post documents as deleting its assets too, so the result’s backendRecord is deleted.
Remove published posts
removeFromPlatform calls /uploadposts/posts/unpublish for Facebook, YouTube, X, LinkedIn, and Threads. Upload-Post cannot delete Instagram or TikTok posts. A response without success: true is reported as ambiguous_outcome.
Webhooks
Upload-Post signs each delivery with X-Upload-Post-Signature: sha256=<hex>, an HMAC-SHA256 of <X-Upload-Post-Timestamp>.<raw body> keyed with your account’s webhook_secret. Pass that secret as webhookSecret. adapter.webhooks.verify checks the raw bytes in constant time and rejects timestamps more than five minutes away from its clock.
const context = {
backendInstance: "default",
correlationId: "upload-post-webhook",
retryBudget: { maxAttempts: 1, maxElapsedMs: 10_000 },
};
export async function POST(request: Request) {
const body = new Uint8Array(await request.arrayBuffer());
const input = { headers: request.headers, body };
// Throws an `unauthorized` SocialError for a bad signature or stale timestamp.
await adapter.webhooks.verify(input);
const event = await adapter.webhooks.decode(input, context);
// Store `event` in your inbox, then acknowledge.
return new Response(null, { status: 204 });
}
upload_completed decodes to publication.updated with the request or job ID as backendRecordId; account connection changes decode to account.updated; comment_received and dm_received decode to comment.received and message.received. accountIds holds the profile username. The owner’s email address is removed from the decoded payload. Upload-Post sends no event ID, so the event’s identity is a digest of the body.
Limits
- Comments, messages, post reads, and analytics are not implemented for this backend.
- Upload-Post’s API reference is OpenAPI 1.0.0, checked 2026-10-24 at docs.upload-post.com. No live Upload-Post account was used to verify this adapter.