zhyper docs

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 createPost is rejected outright with 400 media_not_supported.

Everything on this page needs posts:write, including deletion. There is no media:read: media has no list endpoint and no separate read permission, because a media object is only ever meaningful through the post that attaches it.

Two ways in

presignMedia + completeMediauploadMedia
Bytes go toObject storage, directlyThe zhyper API
Requests31
SDK supportYesNo — non-JSON body
Use it forEverything, especially videoSmall images, quick scripts

There is a third endpoint under /v1/media, and it is not a third way in. POST /v1/media/upload-direct (uploadMediaDirect) takes a multipart file of up to 25 MiB and answers with url, filename, contentType and size — a bearer-free signed URL to a temporary object that is deleted after seven days. It does not return a media id, so nothing it produces can be attached to a post; the two columns above remain the only ways to get an id. Reach for it when something needs a plain hosted URL for a short while, and never for anything that has to outlive the week.

uploadMedia takes the raw bytes as the request body with the file's own Content-Type; it is not multipart and it is not JSON, which is why ZhyperClient.request refuses it with a TypeError rather than sending something wrong. If you call it, call it with fetch.

The presigned path is the one to build on. It keeps your bytes off our API, which means an upload cannot be affected by our request limits, and a failed upload costs you nothing but a retry of step 2. It is also the only path with room for real video: a direct uploadMedia caps video at 10 MiB, while the presigned one allows 50 MiB.

The presigned flow

export async function uploadWithPresign(
  zhyper: ZhyperClient,
  file: { bytes: Uint8Array; mime: MediaMime },
): Promise<Schemas['MediaDto']> {
  // 1. Yer ayirt: sunucu bir medya satiri ve TEK KULLANIMLIK bir PUT adresi
  //    dondurur. `sizeBytes` gercek boyut olmalidir; kotayi ve tavani sunucu
  //    bu degere gore burada reddeder, baytlar yola cikmadan once.
  const { media, upload } = await zhyper.request<
    Schemas['PresignMediaResponseDto']
  >('presignMedia', {
    body: { mime: file.mime, sizeBytes: file.bytes.byteLength },
  });

  // 2. Baytlari DOGRUDAN depoya koy. Bu istek zhyper API'sine GITMEZ, o yuzden
  //    API anahtarinizi eklemeyin: imza URL'in icindedir. `headers` neyi
  //    veriyorsa aynen gonderin, imza onlarin uzerine atilmistir.
  const put = await fetch(upload.url, {
    method: upload.method,
    headers: upload.headers,
    body: file.bytes,
  });
  if (!put.ok) {
    throw new Error(`presigned upload failed with status ${put.status}`);
  }

  // 3. Kapat: sunucu nesneyi depoda dogrular ve medyayi isleme alir.
  //    Bu cagri yapilmadan medya `pending_upload` durumunda kalir ve bir
  //    gonderiye eklenemez.
  return zhyper.request<Schemas['MediaDto']>('completeMedia', {
    path: { id: media.id },
  });
}

Three steps, and the middle one does not talk to zhyper at all:

  1. presignMedia reserves a media row (status: "pending_upload") and signs a single-use PUT. This is where size and MIME are decided — before any bytes move.
  2. You PUT the bytes to upload.url with exactly the headers in upload.headers. Do not attach your API key: the authorisation is inside the URL, and adding a second credential only risks the signature. The URL is valid for 15 minutes (upload.expiresAt tells you when).
  3. completeMedia verifies the object exists and admits it. Until you call it the media stays pending_upload and no post can attach it.

The reply from step 1 looks like this:

{
  "media": {
    "id": "a1b2c3d4e5f6a7b8c9d0e1f2",
    "kind": "image",
    "status": "pending_upload",
    "mime": "image/png",
    "sizeBytes": 70
  },
  "upload": {
    "method": "PUT",
    "url": "https://storage.example/zhyper-media/...",
    "headers": { "content-type": "image/png" },
    "expiresAt": "2026-08-12T09:15:00.000Z"
  }
}

status walks pending_upload → uploaded → processing → ready, or failed. completeMedia returns as soon as the object is admitted, so the status you see there is normally uploaded, not ready — processing continues behind it.

What is accepted

mime is a closed list: image/jpeg, image/png, image/webp, image/gif, video/mp4, video/quicktime, video/webm, application/pdf, application/zip, application/octet-stream. It also decides kind, which is one of image, video or document.

sizeBytes must be the real byte length. The maximum published in the OpenAPI document (52,428,800) is the ceiling across kinds; the one that applies to your request depends on the kind — 52,428,800 for video, 49,000,000 for documents, and a deployment-configured value for images. Rather than guessing, read the error: 400 invalid_media_size states the actual maximum for the kind you asked for.

checksum is optional and, when present, a lowercase SHA-256 hex digest. Supply it and it is folded into the signature as x-amz-checksum-sha256, so storage rejects bytes that do not match instead of letting a corrupt object reach a platform. A digest in any other shape is 400 invalid_media_checksum.

Attaching to a post

export async function postWithMedia(
  zhyper: ZhyperClient,
  input: { content: string; mediaId: string; altText: string },
): Promise<Schemas['CreatePostResponseDto']> {
  return zhyper.request<Schemas['CreatePostResponseDto']>('createPost', {
    body: {
      content: input.content,
      isDraft: true,
      // `mediaItems` sirayi ve erisilebilirlik metnini birlikte tasir.
      // `mediaIds` ile AYNI ANDA gonderilemez.
      mediaItems: [{ mediaId: input.mediaId, altText: input.altText }],
    },
  });
}

mediaItems and mediaIds carry the same ordered list; mediaItems adds altText per attachment. They are mutually exclusive — send one. Order is preserved, up to 35 attachments, and each target is still bound by its own platform's capability: a list that Mastodon accepts may be too long for another target in the same post. Read GET /v1/platforms before building the picker (Connecting accounts).

An empty content is valid only when at least one attachment survives on the post; otherwise 400 invalid_content.

Deleting

export async function deleteMedia(zhyper: ZhyperClient, mediaId: string): Promise<void> {
  await zhyper.request('deleteMedia', { path: { id: mediaId } });
}

deleteMedia answers 204 and refuses while the object is still spoken for: 409 media_referenced_by_future_posts lists the post ids that are blocking it. That is deliberate — a scheduled post whose image vanished an hour before dispatch would fail at the worst possible moment, with nothing you could do about it from the outside.

Errors worth branching on

CodeStatusMeaning
invalid_media_size400sizeBytes outside 1..maximum for that kind
invalid_media_checksum400Not a lowercase SHA-256 hex digest
invalid_media_id400The id is not 24 lowercase hex characters
media_not_found404No such media for your team
media_too_large413The uploaded body exceeded the ceiling
media_object_missing409completeMedia found nothing in storage
media_referenced_by_future_posts409Still attached to an active post
media_storage_unavailable503Object storage is not answering

media_object_missing almost always means step 2 was skipped, failed, or ran after expiresAt. Re-presign rather than retrying completeMedia: the old URL is single-use and its window is gone.

Next

  • Posts — what you attach media to
  • Errors — the error envelope these codes travel in

On this page