API Reference

Convert PowerPoint (.pptx) into a self-contained HTML5 package.

Base URL & authentication

Base URL:

Send your API key as a bearer token on POST /v1/jobs. The status and download endpoints are open (job IDs are unguessable UUIDs).

Authorization: Bearer YOUR_API_KEY

Submit a conversion

Three ways to supply the .pptx — provide exactly one of file, uploadKey, or sourceUrl (else 400):

InputWhen to use
A. multipart fileSmall files (≤ 32 MB platform cap).
B. uploadKeyLarge files: PUT to a signed URL first, then reference it.
C. sourceUrlYour file is already hosted at an https URL — we fetch it.

A. Multipart (small files, ≤ 32 MB)

POST /v1/jobs API key required

multipart/form-data:

FieldRequiredDescription
fileyesThe .pptx.
callbackUrlnohttp(s) URL POSTed when the job finishes.
optionsnoJSON (see options below).
Idempotency-Key (header)noRepeat submissions return the same job.

options object

FieldDescription
titleDeck title (string).
signedUrlTtlHoursPackage URL lifetime in hours (default 24, max 168).
externalIdYour correlation id (string, ≤256). Echoed verbatim in the status response and the webhook.
metadataOpaque JSON object (≤2 KB). Echoed verbatim in status + webhook.
videoCrfEmbedded-video compression — H.264 CRF, integer 051 (higher = smaller file, lower quality). Default 28.
videoMaxHeightCap embedded-video height in px (1444320); taller video is downscaled (never upscaled), width bounded to a 16:9 frame. Default 1080.

Embedded media is made browser-playable on conversion: every embedded video is re-encoded to compressed H.264/AAC MP4 (tuned by videoCrf / videoMaxHeight above), and non-web audio (e.g. WMA) is repackaged to AAC/M4A. Web-playable audio (mp3/m4a/aac/ogg/wav/flac) and images pass through unchanged.

B. Direct upload (any size, up to 500 MB)

Bypasses the ~32 MB request limit by uploading straight to storage. Three steps:

1. POST /v1/uploads API key required (no body) → 202:

{
  "uploadKey": "uploads/<uuid>/in.pptx",
  "uploadUrl": "<signed PUT url>",
  "method": "PUT",
  "requiredHeaders": { "x-goog-content-length-range": "0,524288000" },
  "maxBytes": 524288000,
  "expiresInSeconds": 3600
}

2. PUT the raw .pptx bytes to uploadUrl, echoing every header in requiredHeaders exactly.

3. POST /v1/jobs with Content-Type: application/json:

{ "uploadKey": "uploads/<uuid>/in.pptx",
  "callbackUrl": "https://…",   // optional
  "options": { "title": "…" } } // optional

C. Source URL (we fetch it)

POST /v1/jobs API key required with Content-Type: application/json:

{ "sourceUrl": "https://your-host/deck.pptx",
  "callbackUrl": "https://…",                // optional
  "options": { "externalId": "abc123" } }    // optional

The server downloads the file (following redirects), enforcing the 500 MB cap, a 60 s timeout, and a zip/pptx check. SSRF rules: sourceUrl must be https, and is rejected with 400 if it (or any redirect, or the resolved IP) points at a private/link-local/metadata range (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1, fc00::/7). A download that fails later (host unreachable, too large, wrong type) finalizes the job as failed with error code DOWNLOAD_FAILED.

All three paths return 202:

{ "jobId": "…", "statusUrl": "/v1/jobs/…" }

Errors: 401 bad/missing key · 400 bad uploadKey/callbackUrl · 404 nothing uploaded for that key · 422 not a pptx · 413 too large.

Check status

GET /v1/jobs/:id

Poll until state is terminal. state: queuedprocessinguploadingsucceeded, or failed, or expired (after 24h).

{
  "jobId": "…",
  "state": "succeeded",
  "externalId": "abc123",          // present if you sent options.externalId
  "package": { "url": "<signed url>", "expiresAt": "…", "sizeBytes": 859830 },
  "stats": {
    "slides": 2,
    "durationMs": 1500,
    "warnings": [ { "code": "FONT_SUBSTITUTED", "detail": "Calibri → Carlito" } ]
  }
}

On failure, state is "failed" and an error object is present (see error codes). externalId and metadata are echoed back whenever you supplied them.

Download the package

GET /v1/jobs/:id/download

302 redirect to a fresh signed URL for the .zip. 409 if not ready · 410 if expired · 404 if unknown.

Example

Callbacks

If you pass callbackUrl, the service sends one POST <your callbackUrl> when the job reaches a terminal state. The JSON body is the same shape as GET /v1/jobs/:id plus a top-level status. status carries the same enum as state and always equals it: one of succeeded · failed · expired (never success/error).

On success

{
  "jobId": "8f3c1a2e-…",
  "state": "succeeded",
  "status": "succeeded",
  "externalId": "abc123",
  "createdAt": "2026-06-15T10:00:00.000Z",
  "updatedAt": "2026-06-15T10:00:02.500Z",
  "package": {
    "url": "<signed download url>",
    "expiresAt": "2026-06-16T10:00:02.500Z",
    "sizeBytes": 859830
  },
  "stats": { "slides": 2, "durationMs": 1500, "warnings": [ … ] }
}

package.url is a directly-downloadable signed .zip link valid until expiresAt — you don't have to call the download endpoint.

On failure

{
  "jobId": "8f3c1a2e-…",
  "state": "failed",
  "status": "failed",
  "externalId": "abc123",
  "createdAt": "2026-06-15T10:00:00.000Z",
  "updatedAt": "2026-06-15T10:00:01.200Z",
  "error": { "code": "INVALID_PPTX", "message": "End of central directory not found" }
}

Fields are omitted when absent: no package/stats on failure, no error on success; externalId/metadata appear only when you sent them.

Error codes

The error.code is one of:

codemeaning
DOWNLOAD_FAILEDsourceUrl couldn't be fetched (unreachable, timeout, too large, wrong type).
INVALID_PPTXThe file wasn't a readable .pptx (corrupt zip / not OOXML).
CONVERSION_FAILEDConversion ran but failed on the deck's content.
EXPIRED_UNPROCESSEDJob was cleaned up (24h) before ever reaching a terminal state — see below.
INTERNALUnexpected server error.

Terminal states & the expired safety-net

You get a webhook for every job that has a callbackUrl: succeeded and failed fire as soon as the job finishes. A job that somehow never finishes is swept by retention cleanup after 24h and delivers a final status: "expired" webhook (with error.code: EXPIRED_UNPROCESSED) so your record never hangs in "pending". A job that already succeeded/failed does not get a second (expired) webhook.

Headers & verification

Content-Type: application/json
X-Ppt-Timestamp: <ISO-8601 UTC>
X-Ppt-Signature: sha256=<lowercase hex>

The signing secret is an account-level value provisioned with your API key (stored server-side as CALLBACK_HMAC_SECRET); ask your integration contact for it, and rotate by requesting a new one. Verify each delivery:

  1. Read X-Ppt-Timestamp and the raw request body (bytes, before JSON parsing).
  2. Compute HMAC_SHA256(secret, timestamp + "." + rawBody) as lowercase hex.
  3. Compare "sha256=" + thatHex to X-Ppt-Signature in constant time.
  4. Reject if X-Ppt-Timestamp is older than ~5 min (replay guard).

We re-stamp X-Ppt-Timestamp and re-sign on every retry, so a 5-minute receiver window never rejects a legitimate retry. Worked example (verify it yourself with the shell command):

secret    = s3cr3t
timestamp = 2026-06-15T10:00:02.500Z
rawBody   = {"jobId":"abc","status":"succeeded"}
base      = "<timestamp>.<rawBody>"

$ printf '%s' '2026-06-15T10:00:02.500Z.{"jobId":"abc","status":"succeeded"}' \
    | openssl dgst -sha256 -hmac s3cr3t
→ X-Ppt-Signature: sha256=a4d29141ed7d02dd374675265128cced74d1efb54e6fbeb191dce83872fbd780

Reply 2xx to acknowledge. Any non-2xx or a timeout (10 s per try) is retried up to 5 times with exponential backoff (≈1 s, 2 s, 4 s, 8 s). Callback delivery is tracked separately from job state — a job can be succeeded even if its callback never gets through, so treat GET /v1/jobs/:id as the source of truth.

Package contents & lifecycle

Each package contains index.html, assets/ (styles, player, fonts, media), thumbs/sl_N.png per slide, and notes.json (speaker notes).

The uploaded pptx and the output zip are deleted 24 hours after submission; the job then reports state: "expired" and download returns 410.