Postiz
Configure Postiz Cloud or a self-hosted instance with an API key or OAuth, publish-now and scheduled posts, media uploads, analytics, and connect links.
Postiz is an optional managed backend. It holds the channels connected to your Postiz workspace and posts to them now or at the time you schedule. Postiz is open source, so the same adapter works with Postiz Cloud and with an instance you host yourself. Postiz sponsors Social SDK; the adapter follows Postiz’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 { postiz } from "@opencoredev/social-sdk/cloud/postiz";
// Get tenantId from your authenticated server session and enforce account grants.
const authorization = { tenantId: "tenant-from-session" };
const social = createSocial({
backend: postiz({
apiKey: process.env.POSTIZ_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 instagramAccount = selectAccount("instagram");
const at = new Date(Date.now() + 7 * 86_400_000).toISOString();
const image = new Blob([await readFile("./assets/launch.jpg")]);
Create the API key in Postiz under Settings, Developers. On Postiz Cloud, that tab needs a plan that includes the public API. The key belongs to one workspace. Keep it on your server; the adapter sends it as the Authorization header value.
Self-hosted Postiz
A self-hosted instance serves the public API under /api/public/v1 on your Postiz host. Pass that URL as baseUrl:
const selfHostedSocial = createSocial({
backend: postiz({
apiKey: process.env.POSTIZ_API_KEY!,
baseUrl: "https://postiz.example.com/api/public/v1",
}),
});
The base URL must use HTTPS and cannot carry credentials, a query, or a fragment. Without baseUrl, the adapter calls Postiz Cloud at https://api.postiz.com/public/v1.
Connect workspaces with OAuth
An API key connects your own Postiz workspace. If your users already have Postiz accounts, let each of them approve your app instead and store the token Postiz issues for their workspace. The token works anywhere an API key does.
In Postiz, open Settings, Developers and choose Create OAuth App. The Developers tab appears on plans that include the public API; the people who approve your app need no particular plan to do so. Set its Redirect URL to a route on your own server, such as https://app.example.com/postiz/callback. Postiz shows the client secret, which starts with pcs_, only once, so save it with the client ID in your server configuration.
Send the person to the authorization URL with a state value you keep in their session:
import { exchangePostizCode, postizAuthorizationUrl } from "@opencoredev/social-sdk/cloud/postiz";
interface OAuthSession {
postizState?: string | undefined;
}
export function startOAuth(session: OAuthSession): Response {
const state = crypto.randomUUID();
session.postizState = state;
return Response.redirect(
postizAuthorizationUrl({ clientId: process.env.POSTIZ_CLIENT_ID!, state }),
);
}
Postiz asks which workspace to connect, then redirects to your Redirect URL with code and state. If the person declines, the redirect carries error=access_denied instead. In the callback route, compare state with the value in the session before you use the code:
export async function finishOAuth(
request: Request,
session: OAuthSession,
tenantId: string,
saveTenantPostizToken: (
tenantId: string,
token: string,
organizationId: string | undefined,
) => Promise<void>,
) {
const callback = new URL(request.url);
const expectedState = session.postizState;
session.postizState = undefined;
const code = callback.searchParams.get("code");
if (!expectedState || !code || callback.searchParams.get("state") !== expectedState) {
throw new Error("Invalid Postiz callback.");
}
const token = await exchangePostizCode({
clientId: process.env.POSTIZ_CLIENT_ID!,
clientSecret: process.env.POSTIZ_CLIENT_SECRET!,
code,
});
await saveTenantPostizToken(tenantId, token.accessToken, token.organizationId);
return createSocial({ backend: postiz({ apiKey: token.accessToken }) });
}
The code works once and expires after 10 minutes. An expired or reused code fails with invalid_input, and a wrong client ID or secret fails with invalid_config. If the request fails after it was sent, the error is ambiguous_outcome, because Postiz may have used the code. In each case, start the authorization again.
The token starts with pos_ and does not expire. Store it encrypted and scoped to the tenant, like any other credential. The workspace owner can revoke it in Postiz under Settings, Approved Apps, and deleting your OAuth app revokes every token it issued. After a revocation, requests fail with reconnect_required.
For a self-hosted instance, pass your Postiz host as frontendUrl when you build the authorization URL, and the same baseUrl you give the adapter when you exchange the code:
export function startSelfHostedOAuth(session: OAuthSession): Response {
const state = crypto.randomUUID();
session.postizState = state;
return Response.redirect(
postizAuthorizationUrl({
clientId: process.env.POSTIZ_CLIENT_ID!,
state,
frontendUrl: "https://postiz.example.com",
}),
);
}
export async function finishSelfHostedOAuth(
request: Request,
session: OAuthSession,
tenantId: string,
saveTenantPostizToken: (
tenantId: string,
token: string,
organizationId: string | undefined,
) => Promise<void>,
) {
const callback = new URL(request.url);
const expectedState = session.postizState;
session.postizState = undefined;
const code = callback.searchParams.get("code");
if (!expectedState || !code || callback.searchParams.get("state") !== expectedState) {
throw new Error("Invalid Postiz callback.");
}
const baseUrl = "https://postiz.example.com/api/public/v1";
const token = await exchangePostizCode({
clientId: process.env.POSTIZ_CLIENT_ID!,
clientSecret: process.env.POSTIZ_CLIENT_SECRET!,
code,
baseUrl,
});
await saveTenantPostizToken(tenantId, token.accessToken, token.organizationId);
return createSocial({ backend: postiz({ apiKey: token.accessToken, baseUrl }) });
}
Your server handles the whole exchange, so Social SDK needs no hosted callback. A related server helper is in examples/snippets/backend-postiz.ts.
Publish now or schedule
Omit schedule to post now, or set schedule.at to a future time:
const result = await social.posts.publish(
{
targets: [{ account }],
content: { text: "Launching next week." },
schedule: { at },
},
{ authorization: { tenantId: "tenant-from-session" } },
);
Postiz queues every post and a worker posts it when it is due. A confirmed queued row with a future time returns scheduled; a due queued row returns processing. If creation returns an ID but the follow-up read fails, the outcome is accepted with a delivery reference and no invented queue state, so read it again with getDelivery to see published or failed. When Postiz reports ERROR, the outcome is failed with a fixed message. The platform’s reason stays out of the message, because it is free text that can repeat post content or media URLs; native.listPosts returns it as the post’s error field, so check the tenant’s grant before you show it.
The delivery, job, and record IDs have the form <postiz post id>@<publish time>. Postiz lists posts only by publish date, so the adapter keeps the time in the ID to find the post again. Store the IDs as opaque strings. If someone moves the post in Postiz by more than a day, the adapter can no longer find it, and reads, cancellation, and analytics fail with not_found. Manage a moved post in Postiz.
The remaining examples pass authorization the same way. Related server helpers are in examples/snippets/backend-postiz.ts.
Poll for status
Postiz can call a webhook from its dashboard, but those requests carry no signature and fire only when a post succeeds. The adapter does not accept them. Store the outcome’s delivery reference and poll it from a job instead:
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 "processing": // due, the Postiz worker is posting it
case "accepted": // confirmed create with an unavailable read, or a draft
return true;
case "published":
case "failed":
case "cancelled":
case "not-submitted":
case "unknown": // reconcile in Postiz before retrying
return false;
}
}
If the submission itself was ambiguous, the outcome is unknown with no delivery. Check Postiz before you publish again. TikTok can finish a post before it returns the post ID; Postiz then stores the ID as missing, and the adapter returns unknown until you resolve it, in Postiz or with native.listMissingContent and native.setReleaseId (see Native methods).
Media
The adapter uploads media to Postiz and sends the returned file ID and path with the post. A Blob or stream is uploaded as a file. Postiz takes each file in one request, so the adapter reads a stream into memory first; for large videos, prefer a Blob backed by a file, or an HTTPS URL. An HTTPS URL is imported by Postiz. That upload route detects MIME from bytes; the post-create schema separately checks storage-path extensions. This adapter requires source URL paths and returned storage paths ending in .png, .jpg, .jpeg, .gif, .webp, or .mp4. Upload acceptance alone does not prove that a path can be used to create a post. Supported publish types are JPEG, PNG, GIF, and WebP images up to 10 MB, and MP4 videos up to 1 GB. Documents are rejected. A thumbnail is accepted only on a Reddit video, where it is required (see below), and on a YouTube video, where it becomes the custom thumbnail; it is rejected everywhere else. altText is sent with each file.
You can also upload 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.
Set adjustMedia: true on the adapter to send adjust with each media.upload. Postiz then scales videos to a 1080 px short side (long side at most 1920 px) and re-encodes them as H.264 MP4 with AAC audio, and scales down larger images, in the background. Before it publishes a reference uploaded this way, the adapter reads GET /media/:id/status once and posts the adjusted file. While Postiz is still processing it, the target fails with media_error; check native.getMediaStatus(ref, context) and publish again once it reports ready. When the adjustment fails, Postiz keeps the original and reports ready with a warning, or failed when the original cannot be published. Media uploaded inline during posts.publish is never adjusted.
await social.posts.publish(
{
targets: [{ account: instagramAccount }],
content: {
text: "Behind the scenes at the launch.",
media: [
{
kind: "image",
source: { kind: "blob", blob: image, fingerprint: "launch-photo-v1" },
mimeType: "image/jpeg",
filename: "launch.jpg",
altText: "The team around a laptop",
},
],
},
schedule: { at },
},
{ authorization },
);
Supported operations
The adapter supports account listing, publishing now or on a schedule, delivery status, post listing, cancelling a future scheduled post, deleting a failed or draft record, media upload, post analytics, and channel analytics. Cancelling deletes the Postiz record.
Postiz deletes posts by group: deleting one post also deletes every other post in its group. Postiz’s public API ignores a group in the create request and gives each saved post a new group of its own (posts.repository.ts, read 2026-11-04). After a create, the adapter reads the post back and puts that group in the delivery, job, and record identifiers it returns. Cancelling and deleting need one of those identifiers: the adapter deletes the post only while it is still in that group and no other nearby post shares it. An identifier from posts.list, or one whose group Postiz has since changed, is refused (see Not supported).
if (outcome.state === "scheduled") {
const { backendRecord } = await social.posts.cancelScheduled(outcome.job, { authorization });
}
Post analytics need the platform post reference from a published outcome, because it carries the Postiz record ID. The adapter reads the last 30 days and returns the latest value for each metric Postiz reports. Names are Postiz’s labels in camelCase, such as impressions or likes, and vary by platform.
if (outcome.state === "published") {
const metrics = await social.analytics.getPostMetrics(outcome.post, { authorization });
}
analytics.getAccountMetrics reads GET /analytics/:integration for the channel over the last 30 days and returns the latest value of each series, such as followers or impressions, the same way.
const channelMetrics = await social.analytics.getAccountMetrics(account, { authorization });
posts.list reads GET /posts, which Postiz filters only by date range. Each page covers one week, newest first: the first page ends 30 days from now, and paging stops one year back. Only the account’s own posts are returned, so a page can be empty while later pages are not; keep following nextCursor. Each item carries the record id (the same <postiz post id>@<publish time> form), publishDate, state, text, and url once published.
const page = await social.posts.list(account, { limit: 25, authorization });
A disabled channel is listed with status unknown, because Postiz does not say why it was disabled. To list one customer’s channels, pass that customer’s ID as group when you create the adapter; Postiz then filters GET /integrations by it. native.listGroups lists the customers (GET /groups). Pinterest, Reddit, Mastodon, Telegram, Discord, Twitch, and Google My Business (gmb) channels are listed as pinterest, reddit, mastodon, telegram, discord, twitch, and google-business. Pinterest needs a numeric boardId, Reddit needs subreddit and a title of at least two characters, and Discord needs channelId. Postiz’s Reddit integration uploads exactly one attachment, so a Reddit post takes text, one image, or one MP4 video, never a carousel. Set content.link with only a url to make a Reddit link post (type: "link" with that url); a link post needs no text and takes no attachments. An MP4 also needs a thumbnail at a public HTTPS URL ending in .png, .jpg, .jpeg, .gif, or .webp; Postiz uploads it to Reddit as the video poster. Postiz posts Twitch content as a chat message: it sends at most 500 characters and drops attachments, so preparation rejects Twitch media (twitch.media_unsupported) and longer text (twitch.text_too_long) before anything is uploaded. Set messageType: "announcement" to send a highlighted announcement instead of a regular message. Postiz-only channels such as Slack, Medium, and WordPress are covered in Postiz-only channels. Any other provider identifier is left out unless you set unknownPlatforms: "include", which lists it for reading only. Provider-specific variants such as linkedin-page and instagram-standalone are mapped to the normalized linkedin and instagram platforms instead. Facebook in this adapter means Facebook Pages, not personal profiles. Comments and messages are not part of this adapter. See Postiz’s integration list for the provider’s wider set; providers beyond the ones above are not declared by this SDK.
Postiz limits how many create requests one workspace can make per hour. Its documentation gives 100 per hour on Postiz Cloud, and the self-hosted code defaults to 90 when API_LIMIT is unset. The stock Docker Compose explicitly sets API_LIMIT=30; your deployment configuration determines the effective limit. Postiz counts a request once even when it carries several posts in one request, but this adapter does not batch: each target is its own create request, so each target keeps its own outcome and group. Past the limit Postiz answers with HTTP 429, which the adapter reports as rate_limited. The adapter never retries a create, because a retry after a timeout can post twice.
Post options
These options work on every Postiz target and set fields of the create request:
| Social SDK option | Postiz field | What it does |
|---|---|---|
saveToDraft: true |
type: "draft" |
Save the post as a Postiz draft instead of scheduling it. It keeps schedule.at as its date, or now. The outcome is accepted, and nothing publishes until you move it with native.changeStatus(ref, "schedule", context) or in Postiz. |
workspaceTags |
tags |
Postiz tag names, such as ["Launch"]. Postiz attaches only tags that already exist in the workspace, matched by name, and ignores the rest. |
shortLink: true |
shortLink |
Let Postiz shorten the URLs in the text. The adapter sends false unless you ask. |
repeatEveryDays |
inter |
Repeat the post every N days, a whole number of 1 or more, like the composer’s “Repeat Post Every”. Postiz publishes it again each interval after the first date. Its post list then shows the post once per occurrence in the range, with the same Postiz post ID; native.listPosts reports the interval as repeatEveryDays. |
thread |
extra value items |
Follow-up parts, each { text, delayMinutes? }. Where the platform threads natively, such as X, they publish as a thread of replies; elsewhere Postiz posts them as comments on the first post, and LinkedIn and Instagram comments carry text only. delayMinutes waits that many minutes after the previous part (delay). Parts carry text only. YouTube, TikTok, Pinterest, MeWe, and Tumblr take no extra parts, so preparation rejects them there. |
await social.posts.publish(
{
targets: [
{
account,
options: {
workspaceTags: ["Launch"],
thread: [
{ text: "2/ What changed" },
{ text: "3/ Try it: https://example.com", delayMinutes: 5 },
],
},
},
],
content: { text: "1/ We shipped the new scheduler." },
},
{ authorization },
);
thumbnailTimestamp sets the cover frame of a single video, in milliseconds into the video, on Instagram and TikTok. Postiz sends it as thumbnailTimestamp on the video’s media object. TikTok applies it only to direct posts (not draft: true), and Postiz reports that in its testing the cover applied only to public TikTok posts.
Platform options
| Platform | Social SDK option | Postiz setting |
|---|---|---|
| X | replySettings: everyone, following, mentionedUsers, subscribers, or verified |
who_can_reply_post, default everyone |
| X | community, an X Community URL such as https://x.com/i/communities/123 |
community, to post in that community instead of the main timeline |
| X | aiGenerated, paidPartnership |
made_with_ai, paid_partnership disclosure labels |
| X | article: { title, status }, status draft or published |
post_type: "article", article_title, article_status |
postType: "story" |
post_type: "story", from exactly one image or video |
|
trialReel: "manual" or "performance", or trialReel: true with an optional graduationStrategy (MANUAL or SS_PERFORMANCE) |
is_trial_reel, graduation_strategy (MANUAL or SS_PERFORMANCE), for a single-video reel |
|
collaborators, a list of usernames |
collaborators, each invited as { label } |
|
audio: { id, audioVolume?, videoVolume? } |
audio with id, audio_volume, video_volume (0 to 100) |
|
content.link with only a url |
url, a link preview |
|
postType: "story" |
post_type: "story"; each attachment publishes as its own story |
|
textFormatPresetId |
text_format_preset_id, a text background |
|
title |
title of a video post |
|
| YouTube | title, visibility, madeForKids |
title, type, selfDeclaredMadeForKids |
| YouTube | tags, a list of strings |
tags, as { value, label } |
| YouTube | the video’s thumbnail |
thumbnail, an uploaded Postiz file |
| TikTok | privacy |
privacy_level |
| TikTok | disableComments, disableDuet, disableStitch |
comment, duet, stitch, each inverted |
| TikTok | brandedContent, ownBrand, aiGenerated |
brand_content_toggle, brand_organic_toggle, video_made_with_ai |
| TikTok | draft |
content_posting_method of UPLOAD or DIRECT_POST |
| TikTok | title, up to 90 characters |
title |
| TikTok | autoAddMusic |
autoAddMusic of yes or no, for a direct photo post |
carousel, carouselName |
post_as_images_carousel, carousel_name |
|
boardId, title, link |
board, title, link |
|
dominantColor, a hex color such as #FF5733 |
dominant_color |
|
subreddit, title |
subreddit[0].value with subreddit, title, and type |
|
| Google Business | topicType: STANDARD, EVENT, or OFFER |
topicType |
| Google Business | callToAction: { type, url } |
callToActionType, callToActionUrl |
| Google Business | event: { title, startDate, endDate, startTime?, endTime? } |
eventTitle, eventStartDate, eventEndDate, eventStartTime, eventEndTime |
| Google Business | offer: { couponCode?, redeemUrl?, terms? } |
offerCouponCode, offerRedeemUrl, offerTerms |
| Twitch | messageType: message (default) or announcement |
messageType; an announcement is highlighted in chat |
| Twitch | announcementColor: primary, blue, green, orange, or purple |
announcementColor, the banner color; needs messageType: "announcement" |
Instagram targets post to the feed. Postiz has no setting that keeps a reel off the grid, so shareToFeed: false is rejected. Instagram stories carry one image or video each. A trial reel starts with limited visibility; with SS_PERFORMANCE Instagram graduates it to full visibility based on its performance, and with MANUAL you graduate it yourself. Reel audio attaches only to a single-video reel on an Instagram account linked to a Facebook Page; Postiz rejects it on instagram-standalone channels. Find audio IDs with native.triggerTool(account, { methodName: "audioSearch", data: { q: "summer vibes", type: "music" } }, context).
An X article posts long-form HTML: Postiz keeps p, h1 to h3, ul, ol, li, strong, u, and a, raises the length limit to 100,000 characters, and takes images only. An article saved as a draft cannot have thread replies. The adapter does not map article_cover.
A Facebook link sends only the URL as url, and Facebook builds the preview from the page itself; a content.link with a title or description is still rejected rather than dropping them. A Facebook story needs at least one attachment and takes no link. Facebook draws a text background only on a text-only Page post of up to 130 characters, so the adapter rejects textFormatPresetId with media or longer text. Facebook publishes no list of the presets; pick one in Postiz to find its ID. A Facebook title applies only to a post whose first attachment is a video.
A YouTube thumbnail must be a public HTTPS image URL ending in .png, .jpg, .jpeg, .gif, or .webp. The adapter imports it into Postiz with /upload-from-url and sends the returned file as the thumbnail setting.
A TikTok UPLOAD draft goes to the creator’s inbox and must be published there within 24 hours. TikTok keeps only the title in this mode, so the adapter makes no claim that a caption, privacy, interaction or disclosure setting is retained, and warns about this boundary. The title also applies to direct posts. autoAddMusic applies only to direct photo posts; without it the adapter sends autoAddMusic: "no".
A LinkedIn carousel posts two or more images as one swipeable document instead of a collage, with carouselName as its name.
To mention a LinkedIn company page, look up its ID with native.triggerTool and the mention tool, passing the page’s vanity name (the part after linkedin.com/company/) as data.query. The output is a list of { id, label, image }. Put the match in the text as a span, which the adapter sends unchanged and Postiz renders as a link to the page:
<p>Built with <span data-mention-id="{id}" data-mention-label="{label}">@{label}</span></p>
Only company pages can be found, by their exact vanity name; LinkedIn does not let Postiz tag people.
A Google Business EVENT post needs event with a title and dates (YYYY-MM-DD, times HH:MM); an OFFER post can carry event dates and offer details. A call-to-action of any type except NONE needs a public HTTPS url.
Postiz-only channels
Postiz publishes to channel types that have no shared Social SDK platform. The adapter lists each one under its Postiz identifier as the platform, such as slack or medium, and publishes to it with posts.publish, posts.schedule, status reads, posts.list, cancellation, and record deletion. Postiz fills __type from the channel, so the adapter sends only the settings below. Postiz documents each provider at docs.postiz.com/public-api/providers (checked 2026-11-04). Preparation checks each channel’s text limit from Postiz’s platform overview and the attachments it accepts, and rejects a missing required setting before anything is uploaded. Analytics are not declared for these channels.
| Platform | Text limit | Attachments | Social SDK options | Postiz settings |
|---|---|---|---|---|
slack |
400,000 | images | channelId (required): the Slack channel ID, such as C0123456789 |
channel |
lemmy |
10,000 | none | communities (required): a list of { id, name, title, url? }; name and title at least 2 characters |
subreddit[].value with subreddit, id, title, url |
warpcast |
800 | images | channels: Farcaster channel IDs; omit to cast to the main feed |
subreddit[].value.id |
nostr |
100,000 | images | none; the relays come from the channel | none |
vk |
2,048 | images | none; the wall or community comes from the channel | none |
kick |
500 | images | none | none |
medium |
100,000 | none | title and subtitle (required, 2+ characters), canonicalUrl, publicationId, tags (up to 4 names) |
title, subtitle, canonical, publication, tags |
devto |
100,000 | none | title (required, 2+ characters), mainImage, canonicalUrl, organizationId, tags (up to 4 names) |
title, main_image, canonical, organization, tags |
hashnode |
10,000 | none | title (required, 6+ characters), subtitle, mainImage, canonicalUrl, publicationId (required), tags (required) |
title, subtitle, main_image, canonical, publication, tags |
wordpress |
100,000 | none | title (required), postType (required), status, mainImage, categoryIds, tagIds |
title, type, status, main_image, categories, tags |
listmonk |
300,000 | none | subject, preview, listId (all required), templateId |
subject, preview, list, template |
dribbble |
40,000 | exactly one image | title (required), teamUrl |
title, team |
tumblr |
32,768 | up to 30 images and one video | title, link, sourceUrl, tags (a list of names) |
title, link, sourceUrl, tags as one comma-separated string |
mewe |
63,206 | none | postType (required): timeline or group; groupId, required for group |
postType, group |
moltbook |
300 | none | submolt (required): the community ID |
submolt |
skool |
50,000 | images | groupId, labelId, title (all required) |
group, label, title |
whop |
50,000 | images | companyId, experienceId (both required), title |
company, experience, title |
- Slack takes Slack’s own formatting (
*bold*,_italic_,:emoji:), and Postiz sendsaltTextwith Slack and Tumblr images. - Lemmy and Warpcast reuse Postiz’s
subredditfield for communities and channels. Each Lemmy community gets the post under its owntitle;urlturns it into a link post. - Farcaster channels are named
wrapcastin Postiz’s API andwarpcaston its settings page; the adapter lists either aswarpcast. - Medium, Dev.to, and Hashnode take Markdown; WordPress and Listmonk take HTML.
canonicalUrlpoints search engines at the original when you syndicate an article. mainImageis the cover of a Dev.to or Hashnode article, or the featured image of a WordPress post: a public HTTPS image URL ending in.png,.jpg,.jpeg,.gif, or.webp. The adapter imports it with/upload-from-urland sends the returned file asmain_image.- Hashnode
tagsare{ id, name }pairs of Hashnode tag IDs; Medium and Dev.totagsare names, sent as{ value, label }. Dev.to tags should be lowercase existing Dev.to tags. - WordPress
postTypedepends on the site, such aspostorpage.statusispublish,draft,pending(submit for review), orprivate.categoryIdsandtagIdsare numeric WordPress IDs, not names. - Listmonk sends a newsletter campaign to the list
listId;previewis the text email clients show before the message opens, and the default template applies withouttemplateId. - A Dribbble shot is one GIF, JPG, or PNG image of exactly 400×300 or 800×600 pixels, up to 8 MB. Dribbble rejects other sizes; the adapter checks only the count.
teamUrlposts for a team you belong to. - Tumblr
sourceUrlcredits reblogged or quoted content. Each Tumblr blog is its own channel. - MeWe, Skool, and Whop IDs come from the Postiz composer: select the group, label, company, or forum there once to find the ID. A Whop
experienceIdis the forum to post in. - Moltbook posts go to the
submoltcommunity.
await social.posts.publish(
{
targets: [
{
account: wordpressAccount,
options: {
title: "Company updates",
postType: "post",
status: "draft",
categoryIds: [12],
mainImage: "https://cdn.example.com/featured.jpg",
},
},
{ account: slackAccount, options: { channelId: "C0123456789" } },
],
content: { text: "<p>What shipped this month.</p>" },
},
{ authorization },
);
Connect links
native.createConnectLink returns an OAuth URL that connects a channel to your Postiz workspace. The link expires after one hour. Pass a Postiz provider identifier: x, threads, youtube, tiktok, tiktok-business, instagram, instagram-standalone, linkedin, linkedin-page, facebook, pinterest, reddit, gmb, discord, or twitch. To reconnect an existing channel, pass its ID as refreshAccountId. Bluesky connects with an app password and Mastodon with an instance URL in the Postiz dashboard, so they have no link; Telegram is not offered either.
const context: AdapterOperationContext = {
backendInstance: "default",
correlationId: crypto.randomUUID(),
retryBudget: { maxAttempts: 1, maxElapsedMs: 30_000 },
authorization: { tenantId: "tenant-from-session" },
};
const adapter = postiz({ apiKey: process.env.POSTIZ_API_KEY! });
const { url } = await adapter.native.createConnectLink({ provider: "linkedin-page" }, context);
After the person approves, Postiz redirects to its own dashboard, not to your application. Map the new channels to tenants in your own database before you use them.
Native methods
adapter.native exposes the rest of Postiz’s public API. Each method takes the same AdapterOperationContext as above and sends one request, or two when it first reads a post to prove it belongs to the account. Native methods run outside createSocial, so check the tenant’s grant for the account yourself before you call one. Post methods take the delivery, job, or record reference an outcome returned, and work only on posts the adapter can find by that reference.
| Method | Postiz route | What it does |
|---|---|---|
native.me(context) |
GET /me |
The workspace the credentials act on. With an OAuth token it also returns the approving user; with an API key user is absent. |
native.isConnected(context) |
GET /is-connected |
true when Postiz accepts the credentials. A rejected key fails with reconnect_required. |
native.listGroups(context) |
GET /groups |
The workspace’s customers, as { id, name }, for the group option. |
native.deleteChannel(account, { confirmAccountId }, context) |
DELETE /integrations/:id |
Delete the channel. Postiz also deletes every scheduled post on it. Pass the channel ID again as confirmAccountId. |
native.getChannelSettings(account, context) |
GET /integration-settings/:id |
The channel’s posting rules, maxLength, settings schema, and the tools it offers. |
native.triggerTool(account, { methodName, data }, context) |
POST /integration-trigger/:id |
Run one of those tools with string parameters and return its output, such as Instagram audioSearch, LinkedIn mention, Reddit flairs, or Discord channels. |
native.findSlot(account, context) |
GET /find-slot/:id |
The channel’s next free posting time, as { at }, to pass as schedule.at. |
native.listMedia({ page, search }, context) |
GET /media |
The workspace media library, newest first, 18 files per page from page 1, filtered by original file name. |
native.getMediaStatus(ref, context) |
GET /media/:id/status |
Whether a file uploaded with adjustMedia is processing, ready, or failed. |
native.listNotifications({ page }, context) |
GET /notifications |
Workspace notifications, such as publishing errors, newest first, 100 per page from page 0. |
native.changeStatus(ref, status, context) |
PUT /posts/:id/status |
Move a queued or draft post to draft, which stops its publishing workflow, or to schedule, which publishes it at its stored date. A date already past publishes it right away. |
native.updateSettings(ref, settings, context) |
PUT /posts/:id/settings |
Merge provider settings, in Postiz’s own keys such as who_can_reply_post, into a queued or draft post. Content and date stay as they are, and Postiz validates the merged settings. |
native.editPost(ref, { text, media, republish? }, context) |
POST /posts |
Replace the text and media of the post’s first part, sending its own value[].id. A queued or draft post is saved with type: "update", which keeps its date, state, settings, workspace tags, and repeat interval. A published or failed post changes only with republish: true: Postiz then publishes the edited post again now, sent as republish: true. See below. |
native.listPosts({ startDate, endDate, customer? }, context) |
GET /posts |
Every top-level post in the workspace in the date range, with its state, failure error, repeatEveryDays, tags, and channel. customer limits it to one customer’s channels, by an ID from native.listGroups. The list is not scoped to one account, so check the tenant’s grants before you show it. |
native.listMissingContent(ref, context) |
GET /posts/:id/missing |
For a published post whose platform ID is missing, recent content on the platform as { id, url }. |
native.setReleaseId(ref, releaseId, context) |
PUT /posts/:id/release-id |
Connect that post to the platform content ID you matched, so status and analytics work. |
native.generateVideo(request, context) |
POST /generate-video |
Generate a video with Postiz’s AI tools (image-text-slides or veo3). It spends the workspace’s Postiz credits and returns the files by ID and path. |
native.videoFunction(input, context) |
POST /video/function |
Helpers for video generation, such as loadVoices for image-text-slides. |
native.startClipping(request, context) |
POST /clipping |
Cut a long YouTube video into up to 10 vertical clips. Clips land in the media library, plus one draft per clip on each listed channel; nothing is scheduled. It spends the plan’s clipping minutes. |
native.getClipping(id, context), native.listClippings({ page }, context) |
GET /clipping/:id, GET /clipping |
Poll a clipping until its status is completed or failed, and list past clippings. |
const settings = await adapter.native.getChannelSettings(account, context);
const audio = await adapter.native.triggerTool(
instagramAccount,
{ methodName: "audioSearch", data: { q: "summer vibes", type: "music" } },
context,
);
const { at: nextSlot } = await adapter.native.findSlot(account, context);
if (outcome.state === "unknown" && outcome.delivery) {
const candidates = await adapter.native.listMissingContent(outcome.delivery, context);
const match = candidates[0];
if (match) await adapter.native.setReleaseId(outcome.delivery, match.id, context);
}
native.editPost reads the post first and returns its delivery outcome after the save. Postiz’s post list does not return a post’s current media, so pass every file the post should keep; media: [] removes them all. Follow-up parts stay as they were, and a custom thumbnail cannot be set in an edit. The adapter re-sends the stored settings, because Postiz replaces them. A queued or draft post with republish is rejected, because it keeps its date anyway. Postiz gives the edited post a new group. When you edit with an identifier the adapter returned, the outcome’s identifiers carry the new group, so cancel or delete the post with those; the identifiers from before the edit are refused.
const outcome = await adapter.native.editPost(
delivery,
{ text: "Launching Thursday, not Wednesday.", media: [] },
context,
);
const posts = await adapter.native.listPosts(
{ startDate: "2026-11-01T00:00:00Z", endDate: "2026-11-30T23:59:59Z", customer: customerId },
context,
);
Raw requests
adapter.native.request(input, context) sends one request to a Postiz public API endpoint the SDK does not wrap, with the API key in the Authorization header.
The only host is api: the configured baseUrl, by default https://api.postiz.com/public/v1. Paths are below it, such as /integrations.
const native = postiz({ apiKey }).native;
const response = await native.request({ method: "GET", path: "/integrations" }, 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. 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
- Deleting a whole group: Postiz’s Delete Post by Group (
DELETE /posts/group/:group) removes every post in a group at once. The adapter does not wrap it, for the same reason it refuses group deletes below: it cannot show that a group holds only posts you mean to remove. Send it as a raw request if you own the whole group. - Removing a published post: cancelling and deleting change only the Postiz record, so neither removes a post that already published.
- Cancelling or deleting other posts: the adapter refuses a post it did not create or edit, such as one created in the Postiz dashboard or through another API client, and a post edited in the Postiz dashboard since, because Postiz deletes the whole group and the adapter cannot show the group holds only that post. It also refuses when the read after a create failed, because the identifier then has no group.
See capabilities for the generated matrix.