Media uploads and library
Send images, audio, video, documents, and stickers without hosting public URLs. Tyxter stores media by lifecycle and gives providers signed fetch URLs at send time.
single_use for one-off sends and library for reusable assets. Library assets are retained until deleted.Choose a lifecycle
single_useis the default. The asset can be attached to one message, then remains fetchable for that owning message for 24 hours before cleanup.libraryassets can be reused across messages and are the only supported media lifecycle for WhatsApp template header media in broadcast batches.
Upload library media once, send many times
- Create an upload session with
POST /v1/media/uploads. - PUT the file bytes directly to the returned storage URL.
- Complete the upload with
POST /v1/media/uploads/{asset_id}/complete. - Send messages with
message.media.asset_id.
Mirror inbound WhatsApp media
For inbound images, audio, video, documents, and stickers, Tyxter resolves Meta's provider media handle inside a worker, downloads the private bytes with the project's worker-only credential, and stores them as a tenant-scoped Tyxter media asset. The message.received event is emitted after that download succeeds or after its bounded retries end in a stable failure descriptor.
That covers the formats WhatsApp actually hands over. Content it refuses to deliver — video notes (instant video), polls, and some view-once content — never reaches Tyxter, so there is nothing to mirror: no asset is created, no asset_id is issued, and no later retry can produce the bytes. Those messages still arrive as message.received, with content.type set to unsupported, no mediablock, and the provider's refusal reason in content.unsupported (WhatsApp reports a video note as code 131051). Ask the sender for a regular video or audio instead of waiting for an attachment. The shape is documented on the /webhooks page.
image.id or audio.id is a provider handle, not a Tyxter mda_* asset id. Passing it to GET /v1/media/{asset_id} correctly returns media_asset_not_found. A lookaside.fbsbx.com URL is private, short-lived, and requires Meta bearer authorization; never render it in a browser and never ask Tyxter for the provider token. Direct Graph API access is not the supported inbox path.Meta is not the only producer. POST /v1/sandbox/inbound-messages with type: "media" creates a real inbound media asset backed by a deterministic stand-in file per kind, so content.media on message.received, GET /v1/media/{asset_id}/download-url, GET /v1/media?source=inbound_provider, and audio transcription all behave the way they will in production — with no Meta credential and no real attachment. Rehearse the whole path with a sandbox key before you connect WhatsApp. Everything below applies to both origins: branch on source, kind, and status, never on which provider recorded the asset.
Read data.content.media.asset_id from the webhook or media.asset_id from GET /v1/messages/{id} and inbound message list rows. The descriptor is identical across those surfaces. When its status is consumed, its download field gives the relative { method: "GET", path } hop to follow with a messages:read API key. That hint is not a capability. Its response contains the five-minute, method-bound download_url that can be assigned to an image or audio element. Mint a new URL after expires_at; do not persist capability URLs.
For inbound WhatsApp audio, optional media.voice projects Meta's audio.voice signal. true means a voice note, false an ordinary audio file, and omission a legacy row or absent provider signal.
Inbound attachments are assets in the same environment as your uploads, so they appear in GET /v1/media too and their bytes count toward GET /v1/media/storage-usage. Separate the two with the optional source filter — source=customer for your uploaded library, source=inbound_provider for inbound-captured media. Omitting it returns both, which is the unchanged default. Because inbound media consumes the quota, a full environment is an inbound failure mode: the first store attempt settles the descriptor as status: "failed" with failure.code: "media_storage_quota_exceeded". The worker does not spend its remaining retries on capacity that cannot clear by itself, and the message still delivers. Watch percent_used, then reclaim room by deleting an existing asset with DELETE /v1/media/{asset_id} or ask for a higher quota before the sender resends the attachment.
Branch on status before reading anything else. failed is the only status that carries failure.code and failure.message, and it always carries them. expired (your retention window closed) and deleted (the contact was erased) mean the bytes are gone and no download URL can be minted; asset_id still identifies the attachment, so a replayed event never leaves you unable to match it to what you already stored.
const media = event.data.content.media;
if (media.status === "consumed") {
console.log(media.download.method, media.download.path); // safe relative route hint
const result = await tyxter.media.createDownloadUrl(media.asset_id);
image.src = result.download_url; // or: audio.src = result.download_url
} else if (media.status === "failed") {
if (media.failure.code === "media_storage_quota_exceeded") {
showQuotaRecoveryAction(media.asset_id);
} else {
showAttachmentError(media.failure.code, media.failure.message);
}
} else {
// "expired" or "deleted": the attachment is known but no longer downloadable.
showAttachmentUnavailable(media.asset_id, media.status);
}Opt in to inbound audio transcription
Transcription is separate from playback and is never automatic. For an inbound audio message whose media status is consumed, call POST /v1/messages/{message_id}/transcription with an optional language ISO-639-1 hint. When a completed transcript returns a non-null language, its value is provider/model-detected and may be a provider label or an ISO code. The request returns pending; poll the matching GET route, or subscribe to message.media_transcribed for success and message.media_transcription_failed for terminal failure. Production transcription is metered in audio seconds, sends the stored audio to the configured speech-to-text provider, and its text follows message-content retention and contact-erasure rules. A pending or succeeded receipt replays only with no language hint or the same hint; a failed receipt requires the explicit retry endpoint below. Retry requires a non-blank Idempotency-Key, reuses that key to replay its 202, and keeps the original receipt and audio asset. It never substitutes another attachment or purchases a second provider attempt through the create operation.
Bring your own OpenAI key for transcription
To use your own OpenAI transcription credential in a production environment, a dashboard owner or admin opens Connections → Speech-to-Textand connects OpenAI STT. This is a dashboard-only credential write: members can view the configured provider but cannot change it. Tyxter validates the submitted key against OpenAI's live transcription permission; a rejected key answers invalid_stt_api_key. The credential is encrypted and write-only, so no route or dashboard response returns it — the dashboard shows only a masked last-four-character key hint. Tyxter uses gpt-transcribe for both platform and BYOK transcription; the dashboard does not expose a model selector.
The current scheduled production rates are R$0.0043 per audio secondwith Tyxter's platform credential and R$0.0005 per audio second with OpenAI STT BYOK. Read Billingfor the effective-date and rate-card details. Disconnecting OpenAI STT withdraws the override: any transcription not already attributed to that credential, including queued work, falls back to Tyxter's platform credential and stt.audio.second meter. Sandbox always uses the deterministic transcription simulator and never asks for an OpenAI key.
Configuring a key selects the source for a future transcription; it does not start one. Request every transcript explicitly with POST /v1/messages/{message_id}/transcription.
Python broadcast script
This example uploads one image, completes it, then reuses the returnedasset_id across a list of recipients. The script sends individual messages so every recipient can have its own idempotency key.
import mimetypes
import pathlib
import requests
API_BASE = "https://api.tyxter.com"
API_KEY = "tx_live_..."
FROM_PHONE_NUMBER_ID = "pn_..."
RECIPIENTS = ["+5511999999001", "+5511999999002"]
FILE_PATH = pathlib.Path("promo.png")
headers = {
"authorization": f"Bearer {API_KEY}",
"content-type": "application/json",
}
data = FILE_PATH.read_bytes()
# Send the file's real type. "application/octet-stream" is not accepted for
# any kind, so fail loudly here rather than at the upload call.
mime_type = mimetypes.guess_type(FILE_PATH.name)[0]
if mime_type is None:
raise SystemExit(f"Could not determine a MIME type for {FILE_PATH.name}")
upload = requests.post(
f"{API_BASE}/v1/media/uploads",
headers={**headers, "idempotency-key": "media-upload-promo-v1"},
json={
"kind": "image",
"lifecycle": "library",
"filename": FILE_PATH.name,
"mime_type": mime_type,
"byte_length": len(data),
},
timeout=30,
)
upload.raise_for_status()
session = upload.json()
put = requests.put(
session["upload_url"],
headers=session["upload_headers"],
data=data,
timeout=120,
)
put.raise_for_status()
complete = requests.post(
f"{API_BASE}/v1/media/uploads/{session['id']}/complete",
headers={**headers, "idempotency-key": "media-complete-promo-v1"},
timeout=30,
)
complete.raise_for_status()
asset_id = complete.json()["id"]
for phone in RECIPIENTS:
response = requests.post(
f"{API_BASE}/v1/messages",
headers={**headers, "idempotency-key": f"promo-image-{phone}"},
json={
"channel": "whatsapp",
"sender": {"type": "whatsapp_phone_number", "id": FROM_PHONE_NUMBER_ID},
"recipient": {"type": "phone_e164", "id": phone},
"message": {
"type": "media",
"media": {
"kind": "image",
"asset_id": asset_id,
"caption": "Launch promo",
},
},
},
timeout=30,
)
response.raise_for_status()
print(phone, response.json()["id"])Inline convenience
For small single sends, message.media.inline can carry base64 bytes directly in the message request. Tyxter immediately stores those bytes as a single_use asset and persists only the generated asset_id. Inline media is limited to 2 MB and should not be used in bulk loops.
Accepted file types
Tyxter accepts exactly the MIME types WhatsApp accepts, and checks them when you create the upload session — so an unsupported file fails right away with a 400 invalid_media_mime_type that names the accepted set for that kind, instead of uploading cleanly and failing later at delivery. The same rule applies to message.media.inline.mime_type. It also reaches message.media.link on WhatsApp sends: Tyxter retrieves that URL before delivering and refuses a Content-Type outside the same accepted set — recorded as invalid_media_mime_type on the message rather than returned on the request. See the /delivery-errors page.
image:image/jpeg,image/png.sticker:image/webp.audio:audio/aac,audio/amr,audio/mpeg,audio/mp4,audio/ogg.video:video/mp4,video/3gpp.document:application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation.
kind: sticker, never as kind: image. And send the file's real type: if your MIME guesser returns nothing, do not fall back to application/octet-stream, which is not accepted for any kind, including document.WhatsApp also enforces rules a MIME type cannot express, and those are checked when the message is delivered rather than at upload: video must be H.264 with AAC audio and at most one audio stream, images must be 8-bit RGB or RGBA, and a static sticker must be 100 KB or smaller even though Tyxter accepts up to the 500 KB animated-sticker ceiling at upload.
For WhatsApp audio, set message.media.voice to true to request voice-note rendering. Omit it or use false for ordinary audio. Meta requires OGG/Opus mono audio for a voice note. When Tyxter knows the resolved bytes' MIME type, it requires the normalized base type audio/ogg before provider I/O; it does not inspect codecs or channels, and does not convert media.
Meta may render voice-note audio at or below 512 KB with a small play icon. That is a client rendering distinction, not a Tyxter file-size acceptance rule. Audio still has the 16 MB limit below.
Limits
- Default environment storage quota: 500 MB across active media.
- Default single-use retention before send: 7 days from upload creation.
- Consumed single-use retention: 24 hours for the owning message.
- Library retention: until deleted.
- Upload session expiry: 30 minutes.
- Inline media raw-size limit: 2 MB.
- Image: 5 MB.
- Audio: 16 MB.
- Video: 16 MB.
- Sticker: 500 KB (WhatsApp additionally caps a static sticker at 100 KB — see above).
- Document: 100 MB.