Convert PowerPoint (.pptx) into a self-contained HTML5 package.
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
Three ways to supply the .pptx — provide exactly one of
file, uploadKey, or sourceUrl (else 400):
| Input | When to use |
|---|---|
A. multipart file | Small files (≤ 32 MB platform cap). |
B. uploadKey | Large files: PUT to a signed URL first, then reference it. |
C. sourceUrl | Your file is already hosted at an https URL — we fetch it. |
POST /v1/jobs API key required
multipart/form-data:
| Field | Required | Description |
|---|---|---|
file | yes | The .pptx. |
callbackUrl | no | http(s) URL POSTed when the job finishes. |
options | no | JSON (see options below). |
Idempotency-Key (header) | no | Repeat submissions return the same job. |
| Field | Description |
|---|---|
title | Deck title (string). |
signedUrlTtlHours | Package URL lifetime in hours (default 24, max 168). |
externalId | Your correlation id (string, ≤256). Echoed verbatim in the status response and the webhook. |
metadata | Opaque JSON object (≤2 KB). Echoed verbatim in status + webhook. |
videoCrf | Embedded-video compression — H.264 CRF, integer 0–51 (higher = smaller file, lower quality). Default 28. |
videoMaxHeight | Cap embedded-video height in px (144–4320); 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.
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
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.
GET /v1/jobs/:id
Poll until state is terminal. state:
queued → processing → uploading → succeeded,
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.
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.
…
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).
{
"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.
{
"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.
The error.code is one of:
| code | meaning |
|---|---|
DOWNLOAD_FAILED | sourceUrl couldn't be fetched (unreachable, timeout, too large, wrong type). |
INVALID_PPTX | The file wasn't a readable .pptx (corrupt zip / not OOXML). |
CONVERSION_FAILED | Conversion ran but failed on the deck's content. |
EXPIRED_UNPROCESSED | Job was cleaned up (24h) before ever reaching a terminal state — see below. |
INTERNAL | Unexpected server error. |
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.
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:
X-Ppt-Timestamp and the raw request body (bytes, before JSON parsing).HMAC_SHA256(secret, timestamp + "." + rawBody) as lowercase hex."sha256=" + thatHex to X-Ppt-Signature in constant time.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.
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.