{"openapi":"3.1.0","info":{"title":"media-encoding","version":"1.3.0","description":"A MAJR Agent Service: encodes user uploads into streaming media (video -> HLS, audio -> AAC/M4A, image -> WebP) as async jobs — enqueue, then a signed completion callback, with GET /v1/jobs/{jobId} as the poll backstop. Media bytes are never persisted beyond the job. Storage is caller-supplied per request, OR use the managed temporary storage path (POST /v1/uploads — presigned upload in, presigned outputs out) for zero-setup onboarding. Both callback and destination are optional (polling-first)."},"servers":[{"url":"https://encoding.majr.app"}],"paths":{"/healthz":{"get":{"operationId":"healthz","summary":"Liveness (unauthenticated)","responses":{"200":{"description":"Service is up.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","const":"ok"}},"required":["status"]}}}}}}},"/v1/uploads":{"post":{"operationId":"createUpload","summary":"Mint a presigned upload into managed temporary storage (Onramp v1)","description":"Optional managed storage so a caller with no S3 bucket of their own can still encode. Returns a presigned PUT plus the `source` object to hand to POST /v1/encode. PUT your file bytes to upload.url yourself — bytes never transit this API. The whole body is optional; pass {contentType} to have S3 enforce it on the PUT. Managed objects are temporary (~72h lifecycle). 503 when this deployment has no managed scratch configured.","security":[{"bearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadRequest"}}}},"responses":{"201":{"description":"Presigned managed-upload contract.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadContract"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Request failed validation.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"Either managed uploads are not configured in this deployment (a capability-off 503, no Retry-After), or auth is not judgeable yet (cold boot / plane outage — honor Retry-After, never a key-validity verdict).","headers":{"Retry-After":{"description":"Seconds until the auth cache retries a load (auth-retry 503 only).","schema":{"type":"integer"}}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/v1/encode":{"post":{"operationId":"enqueueEncode","summary":"Enqueue an encode job (idempotent on jobId, per product)","description":"Validates and enqueues; heavy work happens in a worker. Completion is delivered to callback.url as a signed POST (see the encodingComplete webhook). Re-sending an existing jobId does not enqueue twice: the response reports the persisted job's current status (202 while QUEUED/PROCESSING, 200 once terminal). Idempotency holds while the job record is retained (completed ~24h, failed ~7 days), after which the same jobId starts a fresh encode. A MANAGED jobId is additionally reserved for the life of its durable result record (~72h): re-using it while the result is still pollable returns 409 (managed job id in use). Each product has its own jobId space: another product's ids never collide with yours.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncodeRequest"}}}},"responses":{"200":{"description":"jobId already exists and has reached a terminal state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncodeStatus"}}}},"202":{"description":"Job accepted (or already queued/processing).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncodeStatus"}}}},"400":{"description":"Unsupported content type; caller does not match the credential; or the callback URL is not allowed — it must resolve to a public address (private, loopback, link-local, and reserved hosts are refused; code {\"code\":\"callback_url_not_allowed\"}).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Bucket not in the service allowlist.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Managed job id in use: this jobId is reserved by a live result record and cannot be reused for any new encode until it expires (~objectExpiresAt). The problem+json body carries a stable machine code {\"code\":\"managed_job_id_in_use\"} — do not parse the title. An identical retry of the same request is idempotent (not a 409). Pick a new jobId.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Request failed validation.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"Retryable auth (honor Retry-After; do not rotate keys on 503 — it is never a key verdict) OR a deliberate capability-unavailable: managed storage not ready (for a managed default / scratch destination) or callback delivery unconfigured (a callback was supplied but no signing secret is set).","headers":{"Retry-After":{"description":"Seconds until the auth cache retries a load (auth-retry 503 only).","schema":{"type":"integer"}}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/v1/jobs/{jobId}":{"get":{"operationId":"getJob","summary":"Job status + manifest (the poll backstop)","security":[{"bearerAuth":[]}],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,255}$"}}],"responses":{"200":{"description":"Current job state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobState"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"No such job.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"410":{"description":"The managed outputs for this job are no longer retrievable — deleted (retention window elapsed) OR incomplete (a required manifest artifact was partially deleted). A deliberate Gone, never presigned URLs to missing objects.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"500":{"description":"The encode succeeded but its managed output inventory exceeded the single-page listing cap — a deliberate failure rather than a silently partial inventory.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"Auth not judgeable right now: service auth unconfigured, or the key store has not loaded yet (cold boot / plane outage). Retryable — honor Retry-After; never a key-validity verdict, so do not rotate keys on 503.","headers":{"Retry-After":{"description":"Seconds until the auth cache will attempt another load (when retryable).","schema":{"type":"integer"}}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}}},"webhooks":{"encodingComplete":{"post":{"operationId":"encodingComplete","summary":"Completion callback (POSTed to callback.url)","description":"Signed with X-Majr-Signature: sha256=<hex HMAC-SHA256 over the raw request body> using the shared callback secret. Delivered at-least-once: up to 5 attempts with backoff 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 a worst case around 15 POSTs — handle idempotently on jobId.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallbackPayload"}}}},"responses":{"200":{"description":"Callback accepted. 400/422 stop redelivery; 5xx retries."}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Per-product fleet API key (Authorization: Bearer <api key>) — the same key works across MAJR Agent Services."}},"schemas":{"Problem":{"type":"object","description":"RFC 7807 problem details (application/problem+json).","properties":{"type":{"type":"string","default":"about:blank"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"}},"required":["title","status"]},"EncodeRequest":{"type":"object","additionalProperties":false,"required":["jobId","contentType","source"],"properties":{"jobId":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,255}$","description":"Caller-supplied idempotency key (also the queue job id). Safe as a path segment: no separators or traversal."},"caller":{"type":"string","minLength":1,"description":"Optional provenance / metering slug, e.g. \"majr\". Omit it and the service uses the product your key resolves to (key-only onboarding). If you DO send it under key auth it must equal that product (no length cap — existence in the identity store is the gate)."},"contentType":{"type":"string","minLength":1,"maxLength":255,"enum":["video/mp4","video/mpeg","video/quicktime","video/x-m4v","audio/mp4","audio/mpeg","audio/vnd.wave","audio/wave","audio/x-aiff","audio/x-m4a","audio/x-wav","audio/wav","audio/aac","audio/x-aac","image/jpeg","image/png","image/webp","image/heic","image/heif"],"description":"MIME type of the source object; selects the encoder. The enum is generated from the deployed encoder registry — anything else is rejected with 400."},"source":{"$ref":"#/components/schemas/ObjectRef"},"destination":{"$ref":"#/components/schemas/Destination","description":"Optional (Onramp v1): omit it to write outputs to managed temporary storage — GET /v1/jobs then returns presigned outputs (see JobState). Supply it to write renditions to your own bucket and retrieve them yourself."},"outputs":{"$ref":"#/components/schemas/Outputs"},"callback":{"type":"object","additionalProperties":false,"required":["url"],"description":"Optional (Onramp v1): omit for polling-first (poll GET /v1/jobs/{jobId}). When present, completion is POSTed here as a signed webhook.","properties":{"url":{"type":"string","format":"uri","pattern":"^https?://","description":"http(s) URL that receives the signed completion callback. Must resolve to a public address — private, loopback, link-local, and reserved hosts are refused (400 callback_url_not_allowed); the worker also pins the connection to the vetted address and does not follow redirects."}}},"privacy":{"type":"object","additionalProperties":false,"properties":{"retainSource":{"type":"boolean","default":true,"description":"false => delete the source object after a successful encode."}}}}},"ObjectRef":{"type":"object","additionalProperties":false,"required":["bucket","key"],"properties":{"bucket":{"type":"string","minLength":1},"key":{"type":"string","minLength":1}}},"Destination":{"type":"object","additionalProperties":false,"required":["bucket","prefix"],"properties":{"bucket":{"type":"string","minLength":1},"prefix":{"type":"string","minLength":1,"maxLength":512,"pattern":"^[^/]","not":{"pattern":"(^|/)\\.\\.(/|$)"},"description":"Object-key prefix under which all outputs are written, e.g. an upload id. Must not start with \"/\" or contain a \"..\" segment."}}},"Outputs":{"type":"object","additionalProperties":false,"description":"Optional per-media-type output settings; defaults match majr.","properties":{"video":{"type":"object","additionalProperties":false,"properties":{"format":{"type":"string","const":"hls","default":"hls"},"renditions":{"type":"array","minItems":1,"items":{"type":"string","enum":["240p","360p","480p","720p","1080p"]},"description":"Subset of rendition heights; omit for all."},"poster":{"type":"boolean","default":true}}},"audio":{"type":"object","additionalProperties":false,"properties":{"format":{"type":"string","const":"m4a","default":"m4a"},"bitrate":{"type":"string","pattern":"^\\d+(\\.\\d+)?[kKmM]?$","default":"192k"}}},"image":{"type":"object","additionalProperties":false,"properties":{"format":{"type":"string","const":"webp","default":"webp"},"maxWidth":{"type":"integer","minimum":1,"maximum":10000,"default":1080}}}}},"EncodeStatus":{"type":"object","required":["jobId","status"],"properties":{"jobId":{"type":"string"},"status":{"type":"string","enum":["QUEUED","PROCESSING","SUCCEEDED","FAILED"]}}},"JobState":{"type":"object","required":["jobId","status"],"description":"Job status + manifest. For a SUCCEEDED job whose output is in managed temporary storage, ALSO carries the structured, HLS-aware managed result: primaryPlayback (the labeled entry point), an optional poster, and a complete presigned outputs[] inventory. These managed fields are absent when you supplied your own destination.","properties":{"jobId":{"type":"string"},"status":{"type":"string","enum":["QUEUED","PROCESSING","SUCCEEDED","FAILED"]},"manifest":{"$ref":"#/components/schemas/Manifest"},"error":{"type":["string","null"]},"primaryPlayback":{"$ref":"#/components/schemas/PrimaryPlayback"},"poster":{"type":"object","required":["path"],"additionalProperties":false,"properties":{"path":{"type":"string"}},"description":"Video poster frame, if produced (managed jobs only)."},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/ManagedOutput"},"description":"Complete object inventory for a managed job — download ALL of these, preserving relative paths, then re-host. An HLS master URL is NOT directly streamable: its relative segment/variant references are unsigned."},"storage":{"type":"string","const":"managed-temporary"}}},"Manifest":{"description":"Shape depends on media type; paths are object keys under destination.prefix.","oneOf":[{"type":"object","title":"VideoManifest","required":["type","primary","renditions"],"properties":{"type":{"type":"string","const":"video"},"primary":{"type":"object","required":["path","durationSec"],"properties":{"path":{"type":"string","description":"HLS master playlist key."},"durationSec":{"type":"number"},"aspectRatio":{"type":"number"}}},"poster":{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"aspectRatio":{"type":"number"}}},"renditions":{"type":"array","items":{"type":"object","required":["height","bandwidth","path"],"properties":{"height":{"type":"integer"},"width":{"type":"integer"},"bandwidth":{"type":"integer"},"path":{"type":"string"}}}}}},{"type":"object","title":"AudioManifest","required":["type","primary"],"properties":{"type":{"type":"string","const":"audio"},"primary":{"type":"object","required":["path","durationSec"],"properties":{"path":{"type":"string"},"durationSec":{"type":"number"}}},"metadata":{"type":["object","null"]}}},{"type":"object","title":"ImageManifest","required":["type","primary"],"properties":{"type":{"type":"string","const":"image"},"primary":{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"aspectRatio":{"type":"number"}}}}}]},"CallbackPayload":{"type":"object","required":["jobId","caller","status","error","manifest"],"properties":{"jobId":{"type":"string"},"caller":{"type":"string"},"status":{"type":"string","enum":["SUCCEEDED","FAILED"]},"error":{"type":["string","null"]},"manifest":{"oneOf":[{"$ref":"#/components/schemas/Manifest"},{"type":"null"}]}}},"UploadRequest":{"type":"object","additionalProperties":false,"description":"POST /v1/uploads body — all fields optional; an empty body is accepted.","properties":{"contentType":{"type":"string","minLength":1,"maxLength":255,"description":"MIME of the file you will PUT. If set, it is signed into the presigned PUT (S3 enforces the Content-Type header on upload) and echoed in upload.headers."}}},"UploadContract":{"type":"object","required":["uploadId","source","upload","note"],"description":"POST /v1/uploads 201 response: where to PUT, and the source to encode.","properties":{"uploadId":{"type":"string"},"source":{"$ref":"#/components/schemas/ObjectRef","description":"Pass this as `source` to POST /v1/encode once the PUT succeeds."},"upload":{"type":"object","required":["url","method","headers","urlExpiresAt"],"properties":{"url":{"type":"string","format":"uri","description":"Presigned PUT target."},"method":{"type":"string","const":"PUT"},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Headers you MUST send on the PUT (e.g. Content-Type when signed)."},"urlExpiresAt":{"type":"string","format":"date-time","description":"When this presigned PUT URL stops working."}}},"note":{"type":"string"}}},"PrimaryPlayback":{"type":"object","required":["kind","path"],"description":"The single entry point to treat as \"the output\": the HLS master playlist (video), the audio file, or the image. `path` matches one entry in outputs[].","properties":{"kind":{"type":"string","enum":["hls","audio","image"]},"path":{"type":"string"}}},"ManagedOutput":{"type":"object","required":["path","bytes","url","urlExpiresAt","objectExpiresAt"],"description":"One managed object: a presigned GET plus the two distinct expiries.","properties":{"path":{"type":"string","description":"Full object key (as it appears in the manifest)."},"bytes":{"type":"integer"},"url":{"type":"string","format":"uri","description":"Presigned GET — an individual object URL, NOT a streamable HLS package."},"urlExpiresAt":{"type":"string","format":"date-time","description":"When THIS url stops working (~1h). Re-poll GET /v1/jobs/{jobId} for fresh URLs."},"objectExpiresAt":{"type":"string","format":"date-time","description":"When the object itself is deleted (~72h). Distinct from urlExpiresAt."}}}}}}