Genuka WA docs

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

ObjectLives forWhat that means for you
uploaded asset (media_id)30 daysa media_id in a database is a time bomb: it expires silently and the send fails with 131052
resolved download URL5 minutesresolve and consume in one go, never persist it, never queue it
inbound asset30 daysnot downloaded means lost, permanently
link: send~10 min cacheMeta 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:

  1. cached id younger than MEDIA.safeReuseDays → hand it back, no I/O beyond the cache read;
  2. otherwise → read the bytes from our storage, re-upload, refresh the cache row;
  3. invalidate() → drop the row so the next resolve re-uploads. This is what the send path's retry policy calls on 131052, 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

SituationClassWhy
unsupported mime, oversize, empty filevalidationour payload; no retry will ever fix it
bucket unreachable, empty Meta responsemediainvalidate and retry once
131052 / 131053 from Metamediamapped in errors.ts
expired token (190), permissionsconfigre-uploading changes nothing; stop and alert
429, 5xx, networkretryableback 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 url

Two things kill integrations here:

  1. the download URL needs Authorization: Bearer <token> — without it the lookaside host answers 401, which everybody first misreads as "the URL expired";
  2. 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_idheader_handle
minted byPOST /{PHONE_NUMBER_ID}/mediaPOST /{APP_ID}/uploads then POST /{UPLOAD_ID}
used forsending a message (image: { id })creating a template with a media header
scoped toone phone numberthe app, then bound to the template
lifetime30 daysthe template's
produced byMediaClient.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:

KindFormatsMax
imageJPEG, PNG5 MB
videoMP4, 3GPP (H.264 + AAC, one audio track)16 MB
audioAAC, AMR, MP3, M4A, OGG (opus only)16 MB
documentTXT, DOC(X), XLS(X), PPT(X), PDF100 MB
stickerWebP static / animated100 KB / 500 KB

Two details worth knowing:

  • image/webp is 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

OperationRoute
upload (multipart file, companyId, filename?)POST /api/v1/media
list (companyId?, kind?, limit?, cursor?)GET /api/v1/media
read oneGET /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.

On this page