Convert PowerPoint (.pptx) into a self-contained HTML5 package.
Base URL: …
Create API keys in the SlideKiln dashboard
(API keys page — keys look like sfk_… and are shown once at creation).
Send the key as a bearer token on every call. It is required on
POST /v1/jobs and POST /v1/uploads. On
GET /v1/jobs/:id (status) it is optional today but validated when
present — a key that doesn't own the job gets 404 — and will become
required, so send it. GET /v1/jobs/:id/download is a capability link:
the unguessable job ID plus a short-lived signed URL, usable without a key so
download links stay shareable.
Authorization: Bearer sfk_YOUR_API_KEY
Treat job IDs as secrets. Anyone holding a job ID can read that job's status and download its package during the retention window, so keep IDs out of client-side logs, analytics, and shared URLs.
Conversions are metered per organization per calendar month: Free includes
2 (hard cap), Pro 35 and Scale 350
(overage billed per conversion beyond the included amount). When a hard-capped plan is
out of quota, POST /v1/jobs returns 402 with an explanatory
message — upgrade in the dashboard's billing page to continue.
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. |
hosted | Boolean. Host the deck live for embedding when it succeeds (see Hosting & embedding). Reserves a hosting slot; 402 if your plan's slots are all in use. |
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/revoked key · 402 monthly
conversion quota reached, or (with hosted:true) no hosting slots free — upgrade
or unhost to continue · 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>
Each organization has its own signing secret (whsk_…) — reveal or rotate it
on the dashboard's API keys page.
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. Hosted decks (below) are the exception — they stay live
until you unhost them.
Instead of downloading the zip and hosting it yourself, we can serve the converted deck
live at a stable public URL so you can embed it with an <iframe>. Set
options.hosted: true when you submit a job (the deck goes live automatically on
success), or host an existing conversion from the dashboard while its files still exist
(within 24h). Each plan includes a number of hosted presentations; unhosting one frees a
slot. A hosted job's status response carries an embedUrl:
{ "jobId": "…", "state": "succeeded", "hosted": true,
"embedUrl": "https://…/<jobId>/index.html" }
Drop it into any page:
<iframe src="https://…/<jobId>/index.html"
width="960" height="540"
style="border:0;aspect-ratio:16/9;max-width:100%"
allowfullscreen></iframe>
The unguessable jobId is the capability guard. Control the embedded deck from
the parent page with the window.SlideKiln API below.
The generated package is a self-contained presentation you can drive and observe from a
host page or a native webview (iOS WKWebView, Android WebView).
Three surfaces, in order of preference:
Declarative, no scripting. Load the package's index.html with:
| Form | Effect |
|---|---|
index.html#3 | Open on slide 3 (1-based). Setting location.hash later also navigates. |
?startSlideIndex=2 | Start on this 0-based position within the (possibly reordered) deck. |
?customSlideOrder=0,3,4 | Show only these slides (0-based original indices), in this order; the rest are dropped. |
Original index vs. playback position: customSlideOrder is
the one place you name slides by their original 0-based index. Everything else —
startSlideIndex, the #N hash, and the SlideKiln API/events
below (goTo, current, count) — counts playback
position in the resulting (possibly reordered, possibly shorter) deck. Example: with
?customSlideOrder=4,2, goTo(1) and #1 show original slide 4,
and count is 2.
window.SlideKilnA stable global on the package's window. Slide positions are 1-based
and count playback position (not original index — see the note above),
matching the #N hash.
| Member | Description |
|---|---|
version | Embedding-API semver (currently "1.0.0"). |
next() / prev() | Advance (steps animations first, then the slide) / go back one slide. |
goTo(n) | Jump to slide n (1-based); out-of-range and mid-transition calls are ignored. |
current / count | Current slide (1-based) / total reachable slides. |
on(type, cb) | Subscribe to "ready" or "slidechange"; returns an unsubscribe function. |
The player dispatches CustomEvents on window so a host can observe
playback without polling. Each event.detail is
{ index, count, isFirst, isLast } (index 1-based).
// slidekiln:ready fires once the deck is loaded; slidekiln:slidechange on every move
window.addEventListener('slidekiln:slidechange', function (e) {
console.log('now on', e.detail.index, 'of', e.detail.count, 'last?', e.detail.isLast);
});
window.SlideKiln.goTo(2); // or next() / prev()
postMessageWhen the package runs in an <iframe> it posts the same
ready/slidechange payloads to the parent, tagged
{ source: "slidekiln", type, index, count, isFirst, isLast }. To drive it, post a
message tagged source: "slidekiln-host" — any other traffic is ignored.
// host page embedding the deck in an iframe
const deck = document.getElementById('deck').contentWindow;
window.addEventListener('message', (e) => {
if (e.data?.source === 'slidekiln' && e.data.type === 'slidechange') updateProgress(e.data.index);
});
deck.postMessage({ source: 'slidekiln-host', type: 'goto', index: 3 }, '*'); // or type: 'next' | 'prev'
In a native webview, evaluate JavaScript against window.SlideKiln
directly and add a slidekiln:slidechange listener that bridges to the host
(e.g. webkit.messageHandlers on iOS). Already-converted packages keep the runtime
they shipped with; new options apply to packages converted after this API's release.