Posts
A post is one piece of content plus the list of places it goes. Each of those places is a target: one account, on one platform, with its own status.
That split is the thing to hold on to. You create one post and get back one post
id and n target ids, and from that moment the targets move independently. A
post to three accounts can end up with two published targets and one failed one
— the post's own status becomes partial, and nothing about the successful two
is rolled back.
| Statuses | |
|---|---|
| Post | draft scheduled queued publishing published partial failed cancelled |
| Target | scheduled queued publishing published failed held cancelled |
partial exists only on the post, and held only on the target. A held target
is one the dispatcher stopped on purpose and is waiting for a decision about;
see resumeHeldTarget and cancelHeldTarget in the
API reference.
Reads need posts:read; everything else on this page needs posts:write.
Creating a post
export async function schedulePost(
zhyper: ZhyperClient,
input: {
content: string;
targets: PostTarget[];
scheduledFor: string;
timezone: string;
},
): Promise<Schemas['CreatePostResponseDto']> {
const response = await zhyper.request<Schemas['CreatePostResponseDto']>(
'createPost',
{
body: {
content: input.content,
// Her hedef AYRI bir satirdir ve ayri bir durum tasir. Tek bir cagri
// uc platforma gonderdiginde ucunun de basarili olmasi gerekmez;
// gonderi `partial` durumunda kalabilir.
platforms: input.targets,
scheduledFor: input.scheduledFor,
// Zamanlama IANA bolgesine gore cozulur; gonderi ile birlikte saklanir.
timezone: input.timezone,
},
},
);
return response;
}
createPost answers with three different success codes, and they are not
interchangeable:
| Status | What happened |
|---|---|
201 | A draft was created |
202 | The post was accepted for publishing (now, scheduled, or queued) |
200 | Nothing new happened — a previous request was replayed |
The 200 is the five-minute replay window. Send an x-request-id header (up to
200 characters of [A-Za-z0-9_-]) and a repeat of the same request inside five
minutes returns the original post with existingPost: true instead of creating a
second one. That window is scoped by team and execution mode. For retries that
outlive five minutes, use Idempotency-Key
(Rate limits and idempotency) instead.
The response carries the post and its targets:
{
"message": "Post scheduled successfully",
"existingPost": false,
"post": {
"id": "a1b2c3d4e5f6a7b8c9d0e1f2",
"profileId": "c3d4e5f6a7b8c9d0e1f2a3b4",
"status": "scheduled",
"scheduledFor": "2026-08-12T06:30:00.000Z",
"timezone": "Europe/Istanbul",
"scheduleKind": "absolute",
"queueId": null
},
"targets": [
{
"id": "b2c3d4e5f6a7b8c9d0e1f2a3",
"accountId": "c3d4e5f6a7b8c9d0e1f2a3b4",
"platform": "mastodon",
"status": "scheduled",
"scheduledFor": "2026-08-12T06:30:00.000Z"
}
]
}
There are four ways to say when — publishNow, scheduledFor (with
timezone), a queue (queuedFromProfile — see Queues), and
isDraft — and they are mutually exclusive. Two of them in one body is
400 conflicting_schedule_mode. None of them is a draft: that is the default,
not an error.
Two adjacent rules fall out of the same parser. queueId without
queuedFromProfile is 400 queue_id_requires_queue, and exactTiming is only
meaningful alongside now, absolute or queue scheduling — on a draft it is
400 exact_timing_requires_schedule.
Drafts
A draft is the only post with no targets at all — platforms is required and
non-empty for every other mode, and a draft persists no targets even if you send
some. The other end of that range is 100 targets per post; more than that is
400 too_many_platforms, and it is the same ceiling validatePost accepts for a
dry run, so a body that validates cleanly will not be rejected on count when you
send it for real.
// Taslak, hedefi OLMAYAN tek gonderi turudur: `platforms` diger her zamanlama
// kipinde zorunlu ve bos olamaz, taslakta ise hic hedef yazilmaz. Bu yuzden
// baglanmis bir hesap olmadan da bir taslak olusturulabilir.
export async function createDraft(
zhyper: ZhyperClient,
content: string,
): Promise<Schemas['CreatePostResponseDto']> {
return zhyper.request<Schemas['CreatePostResponseDto']>('createPost', {
body: { content, isDraft: true },
});
}
A targetless draft needs a profile-scoped key. Every other post learns its profile from the accounts you targeted. A draft has no accounts, so there is nothing to derive it from, and an unscoped key gets
400 draft_profile_required. Use a key scoped to exactly one profile, or give the post a target.
Deletion is narrow for the same reason the split above matters:
deletePost returns 409 post_delete_not_allowed for anything that is not a
targetless draft. A post that has targets is cancelled, not deleted — the
targets are records of what was attempted.
export async function deleteDraft(zhyper: ZhyperClient, postId: string): Promise<void> {
await zhyper.request('deletePost', { path: { postId } });
}
Reading posts back
export async function listScheduledPosts(
zhyper: ZhyperClient,
profileId: string,
): Promise<Array<Schemas['PostDetailDto']>> {
const posts: Array<Schemas['PostDetailDto']> = [];
for await (const post of zhyper.paginate<Schemas['PostDetailDto']>('listPosts', {
query: { profileId, status: 'scheduled', limit: 50 },
})) {
posts.push(post);
}
return posts;
}
listPosts filters on platform, accountId, profileId, status,
search, source, sort and sortBy, and returns the standard cursor
envelope (data, hasMore, nextCursor) plus two compatibility fields:
posts, an alias of data, and pagination, which carries page totals only
when you asked for an explicit page rather than a cursor. Read data.
source=external changes what is in data: instead of posts it returns the
provider history rows recorded by syncExternalPosts, which carry only the
columns that table persists.
The single read is wider than the list:
export async function readPost(
zhyper: ZhyperClient,
postId: string,
): Promise<Schemas['GetPostResponseDto']> {
// Tekil okuma, listede olmayan iki seyi getirir: hedeflerin saglayici
// kimlikleri (`platformPostId`, `platformPostUrl`) ve ekli medyanin
// sirasi. Bir hedefin neden dustugunu `targets[].errorMessage` soyler.
return zhyper.request<Schemas['GetPostResponseDto']>('getPost', {
path: { postId },
});
}
getPost adds each target's platformPostId, platformPostUrl, editedContent
and errorMessage, plus the ordered mediaIds / mediaItems. When a target
failed, errorMessage is where the provider's own words are.
Changing a post
export async function retitleDraft(
zhyper: ZhyperClient,
postId: string,
content: string,
): Promise<Schemas['PostDetailResponseDto']> {
// Govde bir MERGE PATCH'tir: yazilmayan alan korunur. `platforms` ya da
// `mediaIds` YAZILIRSA o kumenin TAMAMI degistirilir — bir hedef eklemek
// icin mevcut listeyi de gondermek gerekir.
return zhyper.request<Schemas['PostDetailResponseDto']>('updatePost', {
path: { postId },
body: { content },
});
}
updatePost is a merge patch: fields you omit keep their current values.
Two exceptions to have in mind — platforms and mediaIds/mediaItems are
replaced wholesale when present, so adding one target means sending the
complete list, and scheduling fields must arrive as a complete mode rather than
a partial edit.
Once dispatch begins, editing stops: 409 post_already_in_flight. Change the
plan before the worker picks it up, not during.
When something fails
export async function retryFailedTargets(
zhyper: ZhyperClient,
postId: string,
): Promise<Schemas['PostDetailResponseDto']> {
// `targetIds` verilmezse BASARISIZ hedeflerin hepsi birlikte yeniden
// denenir. Yayinlanmis bir hedef yeniden denenmez; bu uc yalnizca
// `failed` durumundakileri hareket ettirir.
return zhyper.request<Schemas['PostDetailResponseDto']>('retryPost', {
path: { postId },
});
}
retryPost moves only failed targets. Omit targetIds and every failed
target is retried atomically; name them to retry a subset. Published targets are
never re-sent, so a retry cannot duplicate a live post.
export async function cancelPendingTargets(
zhyper: ZhyperClient,
postId: string,
): Promise<Schemas['PostDetailResponseDto']> {
// Henuz yayinlanmamis hedefleri iptal eder. Yayinlanmis olanlara
// DOKUNMAZ — yayindan kaldirmak ayri bir karardir (`unpublishPost`).
return zhyper.request<Schemas['PostDetailResponseDto']>('cancelPendingTargets', {
path: { id: postId },
});
}
cancelPendingTargets cancels the targets that have not gone out yet and leaves
the published ones alone. Removing something already on a platform is a separate,
explicit decision (unpublishPost).
Errors worth branching on
| Code | Status | Meaning |
|---|---|---|
draft_profile_required | 400 | Targetless draft, key not scoped to one profile |
accounts_must_share_profile | 400 | One post cannot span two profiles |
account_platform_mismatch | 400 | platform does not match the account |
too_many_platforms | 400 | More than 100 targets on one post |
invalid_content | 400 | Blank content with no media attached |
media_not_supported | 400 | You sent media; use mediaIds or mediaItems |
media_not_found | 404 | A mediaId your team does not own |
account_disconnected | 409 | The target account is not active |
duplicate_content | 409 | Same text to the same target within 24 hours |
post_already_in_flight | 409 | Dispatch already started |
post_delete_not_allowed | 409 | Only targetless drafts can be deleted |
duplicate_content names the offending entry in param (platforms.0). It is a
content fence, not a request fence: skipDuplicateCheck: true disables the
24-hour comparison and nothing else — the five-minute request replay stays
active either way.
Next
Connecting accounts
A profile holds accounts. There are two ways to attach one, and which you use is decided by the platform, not by you.
Media
Media is uploaded before the post that uses it. You get back a media id, and that id is what a post carries — a post never references a URL you host. Sending a media field to creat