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 + completeMedia | uploadMedia | |
|---|---|---|
| Bytes go to | Object storage, directly | The zhyper API |
| Requests | 3 | 1 |
| SDK support | Yes | No — non-JSON body |
| Use it for | Everything, especially video | Small 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 withurl,filename,contentTypeandsize— 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:
presignMediareserves a media row (status: "pending_upload") and signs a single-usePUT. This is where size and MIME are decided — before any bytes move.- You
PUTthe bytes toupload.urlwith exactly the headers inupload.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.expiresAttells you when). completeMediaverifies the object exists and admits it. Until you call it the media stayspending_uploadand 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
| Code | Status | Meaning |
|---|---|---|
invalid_media_size | 400 | sizeBytes outside 1..maximum for that kind |
invalid_media_checksum | 400 | Not a lowercase SHA-256 hex digest |
invalid_media_id | 400 | The id is not 24 lowercase hex characters |
media_not_found | 404 | No such media for your team |
media_too_large | 413 | The uploaded body exceeded the ceiling |
media_object_missing | 409 | completeMedia found nothing in storage |
media_referenced_by_future_posts | 409 | Still attached to an active post |
media_storage_unavailable | 503 | Object 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
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.
Queues
A queue is a weekly pattern of publishing times. Instead of picking a timestamp for every post, you describe the rhythm once — "Monday and Wednesday at 09:30, Friday at 16:00" — an