zhyper docs

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
Postdraft scheduled queued publishing published partial failed cancelled
Targetscheduled 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:

StatusWhat happened
201A draft was created
202The post was accepted for publishing (now, scheduled, or queued)
200Nothing 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 whenpublishNow, 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

CodeStatusMeaning
draft_profile_required400Targetless draft, key not scoped to one profile
accounts_must_share_profile400One post cannot span two profiles
account_platform_mismatch400platform does not match the account
too_many_platforms400More than 100 targets on one post
invalid_content400Blank content with no media attached
media_not_supported400You sent media; use mediaIds or mediaItems
media_not_found404A mediaId your team does not own
account_disconnected409The target account is not active
duplicate_content409Same text to the same target within 24 hours
post_already_in_flight409Dispatch already started
post_delete_not_allowed409Only 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

  • Media — attaching images, video and documents
  • Queues — recurring schedules instead of timestamps
  • Accounts — what you are posting to

On this page