Deckup API documentation
Deckup converts PowerPoint files into clean, structured SVG with a JSON sidecar describing what rendered and what didn't. One endpoint, two modes, predictable shape. If you've used Stripe or Resend, the rhythm here will feel familiar.
Quickstart
Five minutes from signup to first converted slide. Three things to know up front:
- API root:
https://api.deckup.io - Auth:
Authorization: Bearer sk_live_…(orsk_test_…for test mode) - One endpoint to learn:
POST /v1/convert
1. Get a key
Sign up, head to Settings → API keys, copy your test key. It'll look like sk_test_01HXR… — the amber prefix everywhere in the dashboard is intentional. Test keys never count against quota and never produce billable conversions.
2. Send your first deck
curl POST "https://api.deckup.io/v1/convert?mode=sync" \ -H "Authorization: Bearer $DECKUP_KEY" \ -F "file=@quarterly-review.pptx"
from deckup import Deckup client = Deckup(api_key=os.environ["DECKUP_KEY"]) with open("quarterly-review.pptx", "rb") as f: conv = client.conversions.create(file=f, mode="sync") print(conv.id, conv.status, len(conv.slides))
import { Deckup } from "deckup"; import fs from "node:fs"; const deckup = new Deckup({ apiKey: process.env.DECKUP_KEY }); const conv = await deckup.conversions.create({ file: fs.createReadStream("quarterly-review.pptx"), mode: "sync", }); console.log(conv.id, conv.status, conv.slides.length);
package main import ( "context" "os" deckup "github.com/deckup/deckup-go" ) func main() { client := deckup.NewClient(os.Getenv("DECKUP_KEY")) f, _ := os.Open("quarterly-review.pptx") defer f.Close() conv, _ := client.Conversions.Create(context.TODO(), &deckup.CreateParams{ File: f, Mode: deckup.ModeSync, }) fmt.Println(conv.ID, conv.Status, len(conv.Slides)) }
3. Handle the response
Sync responses come back in roughly 5–10 seconds for typical decks. The body is the full sidecar JSON with signed SVG URLs that expire in 5 minutes — fetch them, store them, mirror them to your own bucket if you need to keep them.
Authentication
All requests are authenticated with a bearer token in the Authorization header.
Authorization: Bearer sk_live_01HXR3… Content-Type: multipart/form-data Idempotency-Key: a8f3c1e0-… // optional, recommended
Keys are scoped to a single account. Multiple keys per account are supported — rotate freely; old keys keep working during a 24-hour overlap window when you regenerate.
sk_live_… key in client-side JavaScript or commit it to a repo. Use server-side credentials only. We surface any key that appears in a public commit and rotate it automatically — but don't make us.Test mode
Test-mode keys (sk_test_…) are free forever and produce identical output to live mode, except that:
- Conversions don't count against your monthly quota
- SVG URLs carry a
?livemode=falsemarker - Conversion IDs are prefixed
conv_test_…so they're visually distinct in your logs - Webhooks for test-mode conversions can be routed to a separate URL (set in account settings)
The whole point: integrate end-to-end without paying for it, then flip a single environment variable when you go live.
Create a conversion
The one endpoint that does the work. Accept a multipart upload, pick a mode, optionally provide a webhook URL.
Body parameters
| Name | Type | Description |
|---|---|---|
| file * | file | The PPTX file to convert. Up to 5 MB on Free, 250 MB on Scale. Anything bigger returns 413 Payload Too Large. |
| mode * | string | Either sync (block for the response, typical 5–10s) or async (return immediately, callback by webhook). |
| webhook_url | string | Required when mode=async. Must be HTTPS. We POST a signed payload here when the conversion completes. |
| webhook_secret | string | Per-call signing secret. Overrides the account-level secret. Recommended for multi-tenant routing. |
| metadata | object | Up to 20 key-value pairs (string keys, string values, <500 chars total). Returned verbatim on the conversion object — use for your own row IDs or correlation tokens. |
Returns
A Conversion object. Status will be succeeded or partial for sync mode; queued for async mode (the eventual result comes via webhook).
Example
curl POST "https://api.deckup.io/v1/convert?mode=async" \ -H "Authorization: Bearer $DECKUP_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -F "file=@huge-training-deck.pptx" \ -F "webhook_url=https://acme.com/hooks/deckup" \ -F 'metadata[customer_id]=cus_2L8K9'
conv = client.conversions.create(
file=open("huge-training-deck.pptx", "rb"),
mode="async",
webhook_url="https://acme.com/hooks/deckup",
metadata={"customer_id": "cus_2L8K9"},
)
assert conv.status == "queued"const conv = await deckup.conversions.create({ file: fs.createReadStream("huge-training-deck.pptx"), mode: "async", webhookUrl: "https://acme.com/hooks/deckup", metadata: { customer_id: "cus_2L8K9" }, });
Retrieve a conversion
Idempotent lookup for a conversion you've created. Useful while a webhook is in flight, or for any out-of-band reconciliation.
List conversions
Paginated list of conversions on your account. Cursor-based pagination via starting_after and ending_before query params.
Query parameters
| Name | Type | Description |
|---|---|---|
| limit | int | 1–100, default 25. |
| status | string | Filter by status: queued, running, succeeded, partial, failed. |
| livemode | boolean | Filter to live (true) or test (false) conversions. |
| created.gte | int | Unix timestamp. Returns only conversions created on or after this time. |
The sidecar JSON
Every conversion returns a sidecar describing what rendered, what didn't, and the metadata you'll need to display alongside. Schema is versioned via sidecar_version — pin against it if you're paranoid about breakage.
| Field | Type | Description |
|---|---|---|
| id | string | Conversion identifier. Prefixed conv_ (or conv_test_). |
| status | string | succeeded, partial, failed, queued, running. |
| slides[] | array | One object per slide. Has index, title, notes, dimensions, svg_url, and optionally skipped[]. |
| skipped[] | array | Aggregate of all skipped elements across the deck. Each entry has slide, kind, and reason. |
| metadata | object | Whatever metadata you passed at create time, plus our processing metadata under _deckup. |
| livemode | boolean | true for live keys, false for test keys. |
Webhook events
Async conversions complete via a signed webhook POST to the URL you provided. The body is the full conversion object.
| Event | When it fires |
|---|---|
| conversion.succeeded | All slides rendered without any unsupported elements. |
| conversion.partial | At least one slide rendered, but some elements were skipped. skipped[] tells you what. |
| conversion.failed | No slides could be rendered (corrupt file, unsupported PPTX version, etc.). |
Verifying webhook signatures
Every webhook delivery carries a Deckup-Signature header with a timestamp and an HMAC-SHA256 signature of the payload. Verify it before trusting the body.
import crypto from "node:crypto"; function verify(rawBody, header, secret) { const [t, sig] = header.split(",").map(p => p.split("=")[1]); const signed = `${t}.${rawBody}`; const expected = crypto .createHmac("sha256", secret) .update(signed) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(sig, "hex"), ); }
import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=") for p in header.split(",")) signed = f"{parts['t']}.{raw_body.decode()}" expected = hmac.new( secret.encode(), signed.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, parts["v1"])
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" ) func Verify(body []byte, header, secret string) bool { // Parse "t=…,v1=…" and HMAC-SHA256 over "t.body". // Use hmac.Equal — never bytes.Equal — for timing safety. }
Retry schedule
If your endpoint returns a non-2xx response or times out, Deckup retries on this schedule:
- Attempt 1 — immediately when the conversion completes
- Attempt 2 — 30 seconds later
- Attempt 3 — 5 minutes later
- Attempt 4 — 30 minutes later
- Attempt 5 — 2 hours later
- Attempt 6 — 12 hours later
- Attempt 7 — 24 hours later, then dead-letter
Dead-lettered deliveries are surfaced in the dashboard with one-click replay. Once your endpoint is back, fire the whole backlog from a single button.
Errors
Conventional HTTP status codes with a JSON body describing the failure. Error codes are stable strings you can match against — names will not change between v1 minor versions.
| Status | Error code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body, missing required parameter, or unparseable PPTX file. |
| 401 | authentication_failed | Missing, malformed, or revoked API key. |
| 402 | quota_exhausted | Monthly quota hit and overages are not enabled. Upgrade or enable overages. |
| 413 | file_too_large | File exceeds your tier's max size. Upgrade or split the deck. |
| 429 | rate_limited | Per-key request rate limit. Retry with exponential backoff. |
| 5xx | internal_error | Our fault. Retry. If it persists, the status page will say so. |
Rate limits
Per API key, regardless of tier: 60 requests per minute, 1,000 requests per hour. Burstable — short spikes are absorbed by the queue. Long-running async jobs do not count against these limits once accepted.
Need more? Email support@deckup.io with your use case. Per-key overrides are part of the dashboard for Pro and Scale customers.
SDKs & libraries
Official SDKs are auto-generated from our OpenAPI spec. The HTTP shape is the source of truth — every SDK exposes the same surface area, just shaped to the host language.
| Language | Package | Status |
|---|---|---|
| Python | pip install deckup | beta |
| Node / TypeScript | npm install deckup | beta |
| Go | go get github.com/deckup/deckup-go | preview |
| Ruby | gem install deckup | planned |
Changelog
Stable changes are dated, versioned, and announced via the dashboard and the public changelog page. We use semver for the SDKs and a date-stamped API version (2026-05-13-style) for the API itself; pin to a date if you want to lock against shape changes.