Media
Upload, cache and resolve media so a 30-day-old media_id never breaks a send.
The media module of @genuka/whatsapp. One sentence governs the whole module:
Our bucket is the source of truth. Meta is a cache with a 30-day eviction policy nobody gets to tune.
Everything below follows from that.
import { MetaTransport } from "@genuka/whatsapp";
import { CachedMediaResolver, MediaClient } from "@genuka/whatsapp/media";
const resolver = new CachedMediaResolver({
store, // your storage, behind MediaStorePort
uploader: new MediaClient({ transport: new MetaTransport({ accessToken }) }),
});
// At send time, and only at send time:
const mediaId = await resolver.resolveMediaId(assetId, phoneNumberId);Meta's three clocks
| Object | Lives for | What that means for you |
|---|---|---|
uploaded asset (media_id) | 30 days | a media_id in a database is a time bomb: it expires silently and the send fails with 131052 |
| resolved download URL | 5 minutes | resolve and consume in one go, never persist it, never queue it |
| inbound asset | 30 days | not downloaded means lost, permanently |
link: send | ~10 min cache | Meta re-fetches your CDN on every send past that; a rename breaks a campaign mid-flight |
limits.MEDIA carries all four. safeReuseDays is 25, five days short of the cliff — the tail
of the window is where clock skew, retries and slow queues live, and it buys nothing.
The lifecycle
client upload ──▶ validate (format + size) ──▶ sha256 ──▶ dedupe ──▶ OUR BUCKET
│
at send time, per phone number
▼
POST /{PHONE_NUMBER_ID}/media ──▶ media_id (30 d)
│
▼
POST /{PHONE_NUMBER_ID}/messages { image: { id } }Validation happens at upload, not at send. A campaign built on Monday must not discover on Thursday, 40 000 recipients in, that its header is 7 MB.
MediaResolver
The contract the rest of the library depends on (media/types.ts):
resolveMediaId(assetId, phoneNumberId): Promise<string>
invalidate(assetId, phoneNumberId): Promise<void>CachedMediaResolver implements it:
- cached id younger than
MEDIA.safeReuseDays→ hand it back, no I/O beyond the cache read; - otherwise → read the bytes from our storage, re-upload, refresh the cache row;
invalidate()→ drop the row so the next resolve re-uploads. This is what the send path's retry policy calls on131052, before its single retry.
The composite key is not an optimisation
A media_id is scoped to the PHONE_NUMBER_ID that uploaded it. Reuse it from another number
and Meta answers 131052 — intermittently, only on accounts with more than one number, only in
production. The cache key is therefore (assetId, phoneNumberId), never assetId alone.
MediaStorePort
Storage stays injected, so the policy above is testable with zero I/O and the persistence layer stays out of a zero-dependency package:
interface MediaStorePort {
getCachedUpload(assetId, phoneNumberId): Promise<CachedUpload | null>;
putCachedUpload({ assetId, phoneNumberId, metaMediaId, uploadedAt }): Promise<void>;
deleteCachedUpload(assetId, phoneNumberId): Promise<void>;
readAsset(assetId): Promise<{ bytes, mimeType, filename? }>;
}Two deliberate asymmetries in the implementation:
- a cache read that throws degrades to a miss — a database hiccup must not fail a campaign when re-uploading is always available;
- a cache write that throws is swallowed — we already hold a working id; losing the row costs one extra upload next time, and failing the send costs a message.
Error classes
| Situation | Class | Why |
|---|---|---|
| unsupported mime, oversize, empty file | validation | our payload; no retry will ever fix it |
| bucket unreachable, empty Meta response | media | invalidate and retry once |
131052 / 131053 from Meta | media | mapped in errors.ts |
expired token (190), permissions | config | re-uploading changes nothing; stop and alert |
| 429, 5xx, network | retryable | back off |
A Meta-classified failure keeps its class when the resolver wraps it. Turning a config error
into a media one would send the retry policy uploading a file, twice, for a token problem.
Inbound media
const downloaded = await client.download(mediaId, phoneNumberId); // GET /{MEDIA_ID} then GET urlTwo things kill integrations here:
- the download URL needs
Authorization: Bearer <token>— without it the lookaside host answers 401, which everybody first misreads as "the URL expired"; - it really does expire, in five minutes.
download()resolves and consumes in one call for exactly that reason. There is no correct version that stores the URL and fetches it later.
Expose your URL to your clients. Never a Meta URL, never a Meta media_id — one is dead in
five minutes, the other in 30 days, and both are scoped to credentials the client does not have.
header_handle is not a media_id
The single most expensive confusion in this API. Both are opaque strings Meta returns for a file you uploaded, and they are interchangeable in neither direction:
media_id | header_handle | |
|---|---|---|
| minted by | POST /{PHONE_NUMBER_ID}/media | POST /{APP_ID}/uploads then POST /{UPLOAD_ID} |
| used for | sending a message (image: { id }) | creating a template with a media header |
| scoped to | one phone number | the app, then bound to the template |
| lifetime | 30 days | the template's |
| produced by | MediaClient.upload() | ResumableUploadClient.uploadHeaderHandle() |
Passing a header_handle to a send yields 131052; passing a media_id to template creation
yields a rejected template with an unrelated-sounding reason. Neither error names the confusion.
import { ResumableUploadClient } from "@genuka/whatsapp/media";
const handle = await new ResumableUploadClient({ appId, accessToken }).uploadHeaderHandle({
bytes,
mimeType: "image/png",
filename: "header.png",
});
// → components: [{ type: "HEADER", format: "IMAGE", example: { header_handle: [handle] } }]The resumable protocol predates the Cloud API and does not behave like it: Authorization: OAuth <token> (a Bearer is refused), an explicit file_offset header, and a raw binary body rather
than multipart. A failed upload resumes from the offset Meta reports rather than re-sending the
whole file — once. If nothing landed, the original error stands instead of being paid for twice.
Formats and sizes
Straight from limits.MEDIA / limits.MEDIA_MIME:
| Kind | Formats | Max |
|---|---|---|
| image | JPEG, PNG | 5 MB |
| video | MP4, 3GPP (H.264 + AAC, one audio track) | 16 MB |
| audio | AAC, AMR, MP3, M4A, OGG (opus only) | 16 MB |
| document | TXT, DOC(X), XLS(X), PPT(X), PDF | 100 MB |
| sticker | WebP static / animated | 100 KB / 500 KB |
Two details worth knowing:
image/webpis a sticker, not an image. Meta has no other slot for it, and it gets a sticker's budget whatever the caller meant.- static and animated stickers share one mime type and not one limit, so
assertMediaUpload()reads the RIFF/VP8X animation flag from the bytes instead of guessing. Guessing static rejects valid animated stickers; guessing animated defers the rejection to Meta, where the client cannot see it.
Client mime spellings are normalized before judgement (image/jpg, audio/mp3, audio/x-m4a,
IMAGE/JPEG; charset=binary…) via limits.MEDIA_MIME_ALIASES. Rejecting image/jpg over a
spelling is a support ticket, not a validation win.
Genuka WA API
| Operation | Route |
|---|---|
upload (multipart file, companyId, filename?) | POST /api/v1/media |
list (companyId?, kind?, limit?, cursor?) | GET /api/v1/media |
| read one | GET /api/v1/media/{id} |
| delete (blob, row, and every cache row) | DELETE /api/v1/media/{id} |
Responses carry id, url (ours), sha256, mimeType, kind, sizeBytes. There is no field
for a Meta identifier, deliberately. Re-uploading identical bytes returns the existing asset with
deduplicated: true and a 200 instead of a 201.