wats.sh
Reference

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 kindSupportedNotes
BlobyesBlob.size is checked before arrayBuffer() when available.
ArrayBufferyesbyteLength is checked before sending.
Uint8ArrayyesbyteLength is checked before copying/sending.
DataView / other ArrayBufferViewnoRejected with MediaValidationError("invalid_file").
SharedArrayBuffer-backed Uint8ArraynoRejected to avoid shared-memory/TOCTOU surprises.
string / plain object / null / undefinednoRejected 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 / appId must be non-empty digits-only strings.
  • mediaId must be a non-empty string of letters, digits, _, or -.
  • uploadSessionId must 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_BYTES is the same value.
  • DEFAULT_MAX_MEDIA_DOWNLOAD_BYTES = 16 MiB; MAX_MEDIA_DOWNLOAD_BYTES is the same value.
  • DEFAULT_MAX_UPLOAD_SESSION_BYTES = 64 MiB; MAX_UPLOAD_SESSION_BYTES is the same value.
  • Override caps must be positive safe integers no greater than the implementation maximum.
  • signal, when present, must be AbortSignal-like: boolean .aborted plus .addEventListener() and .removeEventListener() functions.

Error taxonomy

ErrorWhen
MediaValidationErrorMedia precondition failure, unsupported body, cap violation, malformed successful media response.
MediaCryptoErrorInvalid encrypted bundle shape/base64/key length/ciphertext/padding or unsupported crypto.
MediaIntegrityErrorSHA-256, encrypted-hash, HMAC, or plaintext-hash verification failure.
GraphApiError subclassesNon-2xx Graph API responses, preserved from the Graph error taxonomy.
GraphNetworkErrorTransport/network failures.
GraphSerializationErrorMalformed 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.

On this page