# media-encoding: a MAJR Agent Service > User uploads -> streaming media, as an async job service: video -> HLS (multi-rendition > + poster frame), audio -> AAC/M4A, image -> WebP. Encoder only: asset records, user > notifications, and long-term delivery (CDN, signed URLs) stay with the caller. Bring your > own S3-compatible storage per request, OR use managed temporary storage (presigned upload > in, presigned outputs out — no bucket, no setup). Privacy: media BYTES touch scratch disk > only for the duration of a job and are never retained; job METADATA (the enqueue request — > bucket/key names, destination prefix, callback URL) is held in the service's job queue for > idempotency and status, then evicted (completed jobs ~24 hours, failed jobs ~7 days; > a managed job's result stays pollable until its outputs expire — see below). Logs carry counts > and identifiers, never media content. `privacy.retainSource: > false` deletes the source object after a successful encode. Auth: every `/v1/*` call needs `Authorization: Bearer ` — your product's per-product fleet key (accounts -> products -> keys, minted by the MAJR Control Plane; one key works across MAJR Agent Services, and the same key answers `GET /v1/usage` on release-notes with your usage across the fleet). Beta keys: email jamie@majr.app. Errors are RFC 7807 `application/problem+json`. A `401` means the key is wrong. A `403` with `code: insufficient_service_scope` means the key is VALID but not scoped for `media-encoding` — do not rotate it and do not retry here: use a key whose scopes include `media-encoding`. The body's `granted_scopes` lists where that key does work, `required_scope` names this service, and `WWW-Authenticate: Bearer error="insufficient_scope", scope="media-encoding"` carries the same signal. A `503` with `Retry-After` means the key store is not readable yet (cold boot / outage) — retry; it is never a verdict on your key, so do not rotate keys on a 503. ## The fast path: managed storage + polling (no bucket, no callback server) 1. `POST /v1/uploads` (body optional; `{"contentType":"image/jpeg"}` optional) -> `201 {uploadId, source:{bucket,key}, upload:{url, method:"PUT", headers, urlExpiresAt}}`. 2. PUT your bytes straight to the presigned URL — they never transit this API: `curl -T ./photo.jpg -H "Content-Type: image/jpeg" ""`. 3. `POST /v1/encode` with only `{jobId, contentType, source}` — omit `destination` (outputs go to managed storage) and `callback` (poll instead). 4. Poll `GET /v1/jobs/{jobId}` until `status:"SUCCEEDED"`. A managed success also returns: `primaryPlayback: {kind, path}` (the entry point — HLS master playlist, audio, or image), `poster: {path}` (video, when present), `outputs: [{path, bytes, url, urlExpiresAt, objectExpiresAt}]` (the COMPLETE object inventory, each with a fresh presigned GET), and `storage: "managed-temporary"`. 5. Download EVERY `outputs[]` url and copy into your own storage/CDN, preserving the relative layout. Then it is yours. Two expiries, deliberately distinct: `urlExpiresAt` (~1h) is when a presigned url stops working — just re-poll `GET /v1/jobs/{jobId}` for fresh urls; `objectExpiresAt` (~72h) is when the managed object is deleted. The result stays pollable from a durable record for as long as its objects live — even after the queue entry is cleaned up — so a re-poll always works until `objectExpiresAt`; after that, polling returns `410 Gone`. Copy your outputs out before `objectExpiresAt`. HLS is not a directly streamable signed package: an `.m3u8` references its segments and variant playlists by RELATIVE path, and those references are not signed — so you cannot hand the master url to a player. Download the whole prefix and re-host it; the relative references then resolve under your own origin. ## Beta caller rules This service is in BETA and managed output storage is temporary, NOT archival. Four rules keep your integration correct: 1. Generate a fresh UUID for every new encode — a `jobId` is single-use. 2. Retry the IDENTICAL request only for transport uncertainty (a lost/timed-out response): an identical retry is idempotent and returns the existing job. Do not retry by reshaping it. 3. Never reshape or reuse a `jobId` — reusing one for ANY different encode while its result is still live returns `409 {"code":"managed_job_id_in_use"}`. 4. Copy every output into your own storage BEFORE `objectExpiresAt` — managed storage is temporary, not archival; after expiry, polling returns `410 Gone`. ## MCP (Model Context Protocol) Agents can drive the SAME journey over MCP instead of raw HTTP — the tools call the identical operations, so tenancy, idempotency, expiry, and error codes are byte-for-byte the same as `/v1`. - Endpoint: `https://encoding.majr.app/mcp` — Streamable HTTP, stateless, JSON responses. Auth is the SAME `Authorization: Bearer ` header as HTTP (a `503` + `Retry-After` is likewise never a key verdict — do not rotate). - Three tools: `create_upload` (mint a managed upload, then PUT the bytes to the returned URL — bytes NEVER transit MCP), `encode_media` (enqueue; omit `destination` for managed storage and `callback` to poll), `get_encoding_job` (poll to terminal; returns `manifest`, `primaryPlayback`, `poster`, and the complete `outputs` with `urlExpiresAt`/`objectExpiresAt`). - Connect (Claude CLI): claude mcp add --transport http media-encoding https://encoding.majr.app/mcp --header "Authorization: Bearer $API_KEY" ## Contract (async: enqueue -> poll, or supply a callback) - `POST /v1/uploads` -> `201` managed upload contract (above). `503` when this deployment has no managed storage configured — bring your own bucket instead. - `POST /v1/encode` -> `202 {jobId, status}` while the job is QUEUED or PROCESSING — first enqueue and idempotent re-enqueues alike — and `200` once the job has reached a terminal state. Idempotent on the caller-supplied `jobId` while the record is retained (completed ~24 hours, failed ~7 days); re-using it after eviction starts a fresh encode. A `jobId` is reserved for the whole life of its result record: re-using it for ANY new encode under your product while the result is still pollable returns `409` with a stable machine code `{"code":"managed_job_id_in_use", ...}` — pick a new `jobId` or wait for `objectExpiresAt`. An identical retry of the SAME request is idempotent (it returns the existing job's status, not a 409). Each product has its own `jobId` space — ids never collide across products. Body: `{jobId, contentType, source: {bucket, key}, caller?, destination?: {bucket, prefix}, outputs?, callback?: {url}, privacy?: {retainSource}}`. `caller` defaults to the product your key resolves to (send it and it must match — a mismatch is a 400); `destination` omitted -> managed storage; `callback` omitted -> polling-first. `contentType` selects the encoder; supported types and per-type output options are enumerated in /openapi.json. - `GET /v1/jobs/{jobId}` -> `{jobId, status: "QUEUED"|"PROCESSING"|"SUCCEEDED"| "FAILED", manifest?, error?}`, plus the managed fields above for a SUCCEEDED managed job. The pull backstop: poll jobs past your SLA so a single dropped callback can't strand an asset. - Optional callback: supply `callback.url` and completion arrives there as a POST of `{jobId, caller, status: "SUCCEEDED"|"FAILED", error, manifest}`, signed with `X-Majr-Signature: sha256=` (shared callback secret). Delivery retries with backoff — up to 5 attempts per encode attempt, and a delivery-failed encode may be re-driven on a later job attempt (jobs run up to 3 times), so plan for at-least-once with a worst case around 15 POSTs and handle callbacks idempotently on `jobId`. Signature verification is documented for production integrations — contact us. - `GET /healthz` — liveness, unauthenticated. - Machine-readable spec (incl. the callback documented as a webhook): `GET /openapi.json`. Limits (deployment defaults): source object <= 2 GiB, media duration <= 1 hour, images <= 100 megapixels. Oversized or hostile media is rejected outright, never truncated. ## Fleet This is one of the MAJR Agent Services. Machine-readable service catalog: https://release-notes.fly.dev/catalog.json ## Bring-your-own-bucket quickstart (production shape) curl -X POST https://encoding.majr.app/v1/encode \ -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "jobId": "quickstart-0001", "contentType": "image/jpeg", "source": {"bucket": "my-uploads", "key": "photo.jpg"}, "destination": {"bucket": "my-media", "prefix": "photo"}, "callback": {"url": "https://example.com/hooks/encoding"} }'