API Reference

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

Base URL & authentication

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.

Plans & quotas

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.

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.
hostedBoolean. 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.

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/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.

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>

Each organization has its own signing secret (whsk_…) — reveal or rotate it on the dashboard's API keys page. 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. Hosted decks (below) are the exception — they stay live until you unhost them.

Hosting & embedding

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.

Controlling the player

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:

1. URL — deep-linking & start options

Declarative, no scripting. Load the package's index.html with:

FormEffect
index.html#3Open on slide 3 (1-based). Setting location.hash later also navigates.
?startSlideIndex=2Start on this 0-based position within the (possibly reordered) deck.
?customSlideOrder=0,3,4Show 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.

2. JavaScript — window.SlideKiln

A 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.

MemberDescription
versionEmbedding-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 / countCurrent slide (1-based) / total reachable slides.
on(type, cb)Subscribe to "ready" or "slidechange"; returns an unsubscribe function.

3. Events

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()

4. Cross-frame & native webviews — postMessage

When 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.