Media Reference
The credential-free media runtime: upload, metadata, binary download, delete, encrypted decrypt, upload sessions, and validation.
experimental · reviewed 2026-05-19
The media runtime is credential-free: everything below exercises through GraphClient plus an injected Transport. No live Meta credentials are required by the test suite; live credentialed checks stay outside the repository gates.
import {
uploadMedia,
downloadMedia,
downloadMediaBytes,
deleteMedia,
decryptEncryptedMedia,
createUploadSession,
uploadFileToSession,
getUploadSession,
DEFAULT_MAX_MEDIA_UPLOAD_BYTES,
DEFAULT_MAX_MEDIA_DOWNLOAD_BYTES,
DEFAULT_MAX_UPLOAD_SESSION_BYTES,
MediaValidationError,
MediaCryptoError,
MediaIntegrityError
} from "@wats/graph";
import {
uploadAndSendImageFromPath,
uploadAndSendDocumentFromPath
} from "@wats/graph/node-media";@wats/graph/node-media is an explicit Node/Bun-only subpath. It reads local filesystem paths and therefore is never exported from the root @wats/graph browser-safe entrypoint.
Webhook media ID retention
Webhook media IDs are downloadable for 7 days (Meta reduced the window from 30 days to 7 as of 2025-10-09, per the WhatsApp changelog dated 2025-09-24). If you need media past that window, download the bytes promptly and persist them yourself — WATS exposes the media ID/URL and the downloadMedia / downloadMediaBytes helpers but does not auto-persist webhook media.
uploadMedia(client, params, body, options?)
const result = await uploadMedia(
client,
{ phoneNumberId: "555000111" },
{ file: new Uint8Array([1, 2, 3]), type: "image/jpeg", messagingProduct: "whatsapp" },
{ maxBytes: DEFAULT_MAX_MEDIA_UPLOAD_BYTES }
);
console.log(result.id);Builds one multipart/form-data request, sends POST /{phoneNumberId}/media through GraphClient.request, adds a generated safe boundary to the content-type header, and preserves the Graph error taxonomy for non-2xx responses.
@wats/graph/node-media path helpers
const sent = await uploadAndSendImageFromPath(phoneNumberClient, {
to: "15551230000",
path: "./photo.jpg",
caption: "sent from disk"
});The Node/Bun-only subpath reads a local file, infers a MIME type from the extension, calls the existing in-memory uploadAndSend* helper, then sends by returned media id. The helpers perform exactly two Graph requests: media upload, then message send. They reject empty/control/NUL paths, .. path segments, directories, unsupported extensions, over-large files, and missing files before transport. Error messages never echo the full filesystem path.
Supported path helpers: uploadAndSendImageFromPath, uploadAndSendVideoFromPath, uploadAndSendAudioFromPath, uploadAndSendDocumentFromPath, uploadAndSendStickerFromPath.
Supported file bodies for single-post upload:
| Body kind | Supported | Notes |
|---|---|---|
Blob | yes | Blob.size is checked before arrayBuffer() when available. |
ArrayBuffer | yes | byteLength is checked before sending. |
Uint8Array | yes | byteLength is checked before copying/sending. |
DataView / other ArrayBufferView | no | Rejected with MediaValidationError("invalid_file"). |
SharedArrayBuffer-backed Uint8Array | no | Rejected to avoid shared-memory/TOCTOU surprises. |
| string / plain object / null / undefined | no | Rejected before transport. |
downloadMedia(client, opts) and downloadMediaBytes(client, opts)
const metadata = await downloadMedia(client, { mediaId: "media123" });
const file = await downloadMediaBytes(client, {
url: metadata.url,
expectedSha256: metadata.sha256,
maxBytes: DEFAULT_MAX_MEDIA_DOWNLOAD_BYTES
});
console.log(file.bytes, file.contentType);downloadMedia resolves metadata via GET /{mediaId} and parses Graph snake_case into camelCase; its sha256 field is Meta's 64-character hex digest. downloadMediaBytes accepts only http: / https: URLs, fetches through the injected transport with the client's authorization header, enforces DEFAULT_MAX_MEDIA_DOWNLOAD_BYTES / MAX_MEDIA_DOWNLOAD_BYTES, and verifies expectedSha256 when provided. expectedSha256 accepts Meta's 64-character hex digest (so metadata.sha256 passes straight through) or a base64-encoded 32-byte digest.
deleteMedia(client, params)
const { success } = await deleteMedia(client, { mediaId: "media123" });Sends DELETE /{mediaId} and requires { success: boolean }.
decryptEncryptedMedia(bundle, encrypted)
const plaintext = await decryptEncryptedMedia(bundle, encryptedBytes);The encrypted media payload is ciphertext || 10-byte-HMAC-tag. WATS verifies sha256Enc, verifies the truncated HMAC-SHA256 tag over iv || ciphertext with constant-work byte comparison, AES-CBC decrypts, handles PKCS#7 padding, and verifies the plaintext sha256.
Resumable upload sessions
const session = await createUploadSession(client, {
appId: "1234567890",
fileName: "document.pdf",
fileLength: 1234,
fileType: "application/pdf"
});
const uploaded = await uploadFileToSession(client, {
uploadSessionId: session.id,
file: new Uint8Array([1, 2, 3]),
fileOffset: 0,
contentLength: 3
});
const status = await getUploadSession(client, { uploadSessionId: session.id });uploadFileToSession supports Uint8Array, ArrayBuffer, Blob, and ReadableStream<Uint8Array> bodies. Uint8Array inputs and stream chunks are normalized to plain copies using intrinsic typed-array metadata before transport, so subclass overrides of byteLength / length cannot under-report upload size. ReadableStream uploads require contentLength, and WATS wraps the stream to count actual bytes as transport reads them. If the streamed total exceeds maxBytes / DEFAULT_MAX_UPLOAD_SESSION_BYTES, the stream fails with MediaValidationError("upload_too_large") instead of allowing an unbounded upload.
Preconditions and validation
Validation failures reject before transport with MediaValidationError, MediaCryptoError, or MediaIntegrityError — not raw TypeError.
Path parameters:
phoneNumberId/appIdmust be non-empty digits-only strings.mediaIdmust be a non-empty string of letters, digits,_, or-.uploadSessionIdmust be a non-empty string of letters, digits,_,-, or:; the underlying Graph request percent-encodes:as a path-segment character.- Path params reject whitespace-only strings, non-strings, control characters,
/,\,?,#, URL markers like://, dot-segments, traversal markers, and encoded/double-encoded traversal markers.
Options and caps:
DEFAULT_MAX_MEDIA_UPLOAD_BYTES = 16 MiB;MAX_MEDIA_UPLOAD_BYTESis the same value.DEFAULT_MAX_MEDIA_DOWNLOAD_BYTES = 16 MiB;MAX_MEDIA_DOWNLOAD_BYTESis the same value.DEFAULT_MAX_UPLOAD_SESSION_BYTES = 64 MiB;MAX_UPLOAD_SESSION_BYTESis the same value.- Override caps must be positive safe integers no greater than the implementation maximum.
signal, when present, must beAbortSignal-like: boolean.abortedplus.addEventListener()and.removeEventListener()functions.
Error taxonomy
| Error | When |
|---|---|
MediaValidationError | Media precondition failure, unsupported body, cap violation, malformed successful media response. |
MediaCryptoError | Invalid encrypted bundle shape/base64/key length/ciphertext/padding or unsupported crypto. |
MediaIntegrityError | SHA-256, encrypted-hash, HMAC, or plaintext-hash verification failure. |
GraphApiError subclasses | Non-2xx Graph API responses, preserved from the Graph error taxonomy. |
GraphNetworkError | Transport/network failures. |
GraphSerializationError | Malformed successful Graph JSON, preserved from GraphClient.request. |
Notable codes: upload_too_large, download_too_large, invalid_url, invalid_file_offset, invalid_content_length, invalid_ciphertext, invalid_padding, encrypted_hash_mismatch, hmac_mismatch, plaintext_hash_mismatch.
Typed surface
export const DEFAULT_MAX_MEDIA_UPLOAD_BYTES: number;
export const MAX_MEDIA_UPLOAD_BYTES: number;
export const DEFAULT_MAX_MEDIA_DOWNLOAD_BYTES: number;
export const MAX_MEDIA_DOWNLOAD_BYTES: number;
export const DEFAULT_MAX_UPLOAD_SESSION_BYTES: number;
export const MAX_UPLOAD_SESSION_BYTES: number;
export function uploadMedia(...): Promise<MediaUploadResponse>;
export function downloadMedia(...): Promise<MediaDownloadResponse>;
export function downloadMediaBytes(...): Promise<MediaDownloadBytesResponse>;
export function deleteMedia(...): Promise<MediaDeleteResponse>;
export function decryptEncryptedMedia(...): Promise<Uint8Array>;
export function createUploadSession(...): Promise<CreateUploadSessionResponse>;
export function uploadFileToSession(...): Promise<UploadFileToSessionResponse>;
export function getUploadSession(...): Promise<GetUploadSessionResponse>;Not implemented yet: live credentialed Meta checks in CI — these require explicit user authorization and secrets.