Image2PPT API
Batch-convert images and PDFs into editable PowerPoint (.pptx). You upload a
batch of files; Image2PPT reconstructs the layout with AI into editable text and
shapes, and hands you back one .pptx.
This doc is for developers integrating the API — read it top to bottom and you're ready to ship.
One-minute tour
- Sign in and open the Developer / API page to create an API key.
- Call
POST /api/v1/jobsto upload files (or hand us file URLs) and get back a job id. - Poll
GET /api/v1/jobs/{jobId}every few seconds untilstatusiscompleted— or pass acallbackUrlwhen you submit and we'll notify you when the job ends (see Completion webhooks). - Call
GET /api/v1/jobs/{jobId}/downloadto fetch the finished PPTX.
If you no longer need the result, call POST /api/v1/jobs/{jobId}/cancel to stop
pages that have not started yet. To reconcile usage or find an earlier job, call
GET /api/v1/jobs to list your jobs by date.
Conversion is asynchronous: submitting returns a job id immediately and the real work runs in the background. Don't block on the submit call waiting for the result.
Everything added in 1.3.0 is optional. Without
callbackUrl,pages, theIdempotency-Keyheader, or URL submission, the API behaves exactly as it did in 1.2.0 — existing integrations need no changes.
Authentication
Get a key
Sign in to Image2PPT, open the Developer / API page from the account menu, and create a key under API Keys. You'll get a string like:
i2p_live_xxxxxxxxxxxxxxxxxxxxxxxx
The key is shown in full only once, at creation — save it right then. Afterward the page shows only the first few characters for identification. If a key leaks or you need to rotate, revoke the old one and create a new one on the same page.
Send it
Pass the key in the HTTP header on every request:
Authorization: Bearer i2p_live_xxxxxxxxxxxxxxxxxxxxxxxx
A missing or wrong key returns 401 (code INVALID_API_KEY).
Base URL
https://image2ppt.com
All paths below are appended to this base URL.
Conventions
Request and response JSON is UTF-8.
Every error uses the same envelope — an HTTP status code plus an
errorobject:{ "error": { "code": "INVALID_FILE", "message": "Unsupported file format: .bmp" } }Branch your code on
code.messageis human-facing and its wording may change — don't build logic on it.When a URL submission fails because of one particular URL,
erroralso carriesindex— which URL it was, counting from 0:{ "error": { "code": "URL_FETCH_FAILED", "message": "Could not download this URL", "index": 2 } }No other error carries this field.
Endpoints
1. Submit a job — POST /api/v1/jobs
Submit a batch of files and create a conversion job. The request body comes in two forms — pick one, never mix them:
multipart/form-data: upload the files directly.application/json: give us file URLs and we download them — see Submit by URL.
Multipart fields
| Field | Required | Description |
|---|---|---|
files |
Yes | One or more files. png / jpeg / webp / gif / pdf, each ≤ 35MB and ≤ 90MB total file content per request. Repeat the files field name to send multiple files. |
locale |
No | Output locale: zh-CN (default) or en. |
aspectRatio |
No | Slide ratio: auto (default, follows the source) / 16:9 / 4:3. |
pages |
No | Convert only some pages, e.g. 1-3,7 — see Page selection. Omit to convert everything. |
callbackUrl |
No | An https URL we notify when the job ends — see Completion webhooks. |
Request headers
| Header | Required | Description |
|---|---|---|
Idempotency-Key |
No | Makes retries safe after a network timeout — see Idempotent submission. |
A submission has two independent limits, and it must satisfy both:
| Limit | Value | Notes |
|---|---|---|
| Total pages | ≤ 50 pages | An image is 1 page; a PDF counts as its actual page count. With pages, the selected pages are what count — the PDF itself may be longer than 50 pages. |
| Total size | ≤ 90MB | The combined file content of one request. A single file is separately capped at 35MB. |
They are independent: 40 high-resolution images are only 40 pages, yet easily exceed 90MB. Being under the page limit does not mean the request will be accepted.
Exceeding the size limit returns 413 PAYLOAD_TOO_LARGE. When you see it, send
fewer files per request — retrying the same payload will not succeed.
The official SDKs offer two modes, and they behave differently. submit() /
convert() send exactly the batch you hand them; they check size and page count
locally before uploading, so an over-limit batch fails immediately instead of
after a full upload — but they do not split for you. For automatic splitting
use submit_all() / convert_all() (submitAll() / convertAll() in
TypeScript), which divide the files into as many submissions as the size and page
limits require, one job per submission.
Success — 201 Created
{
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"status": "pending",
"slideCount": 12,
"creditsReserved": 12
}
slideCount: total pages to convert in this job (the selected pages, when you passpages).creditsReserved: credits held for this job (= page count). Held on submit, settled on completion.
Replaying the same request with the same Idempotency-Key also returns 201 with
the same body shape, plus the response header Idempotent-Replayed: true — see
Idempotent submission.
curl example
curl -X POST https://image2ppt.com/api/v1/jobs \
-H "Authorization: Bearer i2p_live_xxxx" \
-H "Idempotency-Key: 7d9e2c4a-order-20261001-001" \
-F "files=@slide1.png" \
-F "files=@slide2.png" \
-F "files=@report.pdf" \
-F "locale=en" \
-F "aspectRatio=16:9" \
-F "pages=1-3,7" \
-F "callbackUrl=https://example.com/hooks/image2ppt"
Submit by URL
If your files already sit in object storage or on a CDN, skip the download-and-reupload round trip and hand us the URLs. Send a JSON body instead:
curl -X POST https://image2ppt.com/api/v1/jobs \
-H "Authorization: Bearer i2p_live_xxxx" \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://cdn.example.com/deck.pdf", "https://cdn.example.com/cover.png"],
"locale": "en",
"aspectRatio": "auto",
"pages": "1-3,7",
"callbackUrl": "https://example.com/hooks/image2ppt"
}'
| Field | Required | Description |
|---|---|---|
urls |
Yes | File URLs as an array of strings, 1–50 entries, merged into one PPTX in order. |
locale / aspectRatio / pages / callbackUrl |
No | Same meaning as the multipart fields. |
Rules:
- The JSON body is at most 64KB; a larger one gets
413 PAYLOAD_TOO_LARGE. A field set tonullor an empty string is treated as absent. - Every URL must be
https, at most 2048 characters, carry no username or password, and resolve only to public addresses (private networks, loopback, cloud metadata addresses and the like are refused) — the same rules as the callback URL. - Downloads follow at most 4 redirects; every hop is checked again and must still be
https. - The file type is detected from the content, not from the server's
Content-Type. Downloaded files go through the same limits as uploaded ones: a single file over 35MB or a total over 90MB aborts the download immediately, and format, PDF, and page-count problems returnINVALID_FILE/INVALID_PDF/PAYLOAD_TOO_LARGE/TOO_MANY_SLIDESas usual. - Time limits: 60 seconds per URL (including reading the body), 120 seconds for the whole request; at most 4 URLs are downloaded at once per request.
- An account can have only 1 URL submission in progress at a time; a second one gets
429 RATE_LIMITEDwithRetry-After. - Your balance and in-flight job count are checked before anything is downloaded. Each URL counts as at least 1 page against the 60-pages-per-minute submission rate, whether or not it downloads successfully.
Whenever an error is caused by one particular URL — INVALID_URL, URL_FETCH_FAILED,
and INVALID_FILE / PAYLOAD_TOO_LARGE raised by a downloaded file — error.index
says which one (counting from 0). Other errors do not carry the field.
Page selection — pages
To convert just a few pages of a PDF, list them in pages instead of splitting the
PDF yourself.
- Page numbers refer to the submission concatenated in order: an image is 1 page, a PDF contributes its page count. With a single PDF they are simply the PDF's own page numbers.
- Syntax: comma-separated single pages or
start-endranges, 1-based, spaces allowed — e.g."1-3, 7". At most 1000 characters. - Duplicates and overlaps are merged; the deck is in ascending page order; a range
whose start is greater than its end (such as
5-3) is a syntax error. slideCountand the held credits count the selected pages. Up to 50 selected pages can be submitted even when the PDF itself is longer than 50 pages.- Page numbers in the deck and in
pageResults[].pageNumberare positions in the deck — the k-th selected page. Withpages=2,5, source page 5 is page 2 of the deck.
| HTTP | code | When |
|---|---|---|
| 400 | INVALID_PAGES |
Syntax error. |
| 400 | PAGES_OUT_OF_RANGE |
A page number is 0 or beyond the total page count (message states the total). |
| 400 | TOO_MANY_SLIDES |
More than 50 pages selected. |
Idempotent submission — Idempotency-Key
When a submit call times out, you can't tell whether it went through. Send an
Idempotency-Key header and you can safely retry with the same key: the same
request creates one job and is charged once.
- The key is 1–255 printable ASCII characters (
0x21–0x7E, no spaces); anything else returns400 INVALID_IDEMPOTENCY_KEY. A fresh UUID per new submission works well. - Scope is the account: every API key under one account shares the same keys.
- Lifetime: 24 hours after the first successful submission.
- "The same request" means: the file contents in order (bytes only — file names are
ignored) or the
urlslist, pluslocale,aspectRatio,pages, andcallbackUrl. Omitted fields are filled in with their defaults before comparing.
| Situation | Result |
|---|---|
| Same key, same request, original job still exists | 201 with the same body shape as the first time (status is the job's current status), response header Idempotent-Replayed: true, no further credits held. |
| Same key, different request | 422 IDEMPOTENCY_KEY_MISMATCH. |
| Same key, previous request still being processed | 409 IDEMPOTENCY_KEY_IN_PROGRESS with Retry-After: 2; wait and retry. |
| Previous request failed (any 4xx / 5xx) | A failed request doesn't claim the key; retry with the same key. |
| Original job was deleted on the website | The key is released; the request is treated as a new submission. |
Possible errors
| HTTP | code | Meaning |
|---|---|---|
| 401 | INVALID_API_KEY |
Key missing or invalid. |
| 400 | NO_FILES |
No files attached, or urls is empty. |
| 400 | INVALID_FILE |
Unsupported format, or a single file over 35MB. |
| 400 | INVALID_PDF |
The PDF can't be read or parsed. |
| 400 | TOO_MANY_SLIDES |
Total pages (or selected pages) over 50. |
| 400 | INVALID_PAGES |
pages has a syntax error. |
| 400 | PAGES_OUT_OF_RANGE |
A page number in pages is 0 or beyond the total page count. |
| 400 | INVALID_CALLBACK_URL |
callbackUrl isn't https, is too long, carries a username or password, or isn't a public address. |
| 400 | INVALID_IDEMPOTENCY_KEY |
Idempotency-Key isn't 1–255 characters, or contains anything outside 0x21–0x7E (spaces and non-ASCII included). |
| 400 | INVALID_JSON |
The JSON body isn't valid JSON, or isn't a JSON object. |
| 400 | INVALID_PARAMETER |
A JSON field has the wrong type, or there are more than 50 URLs. |
| 400 | INVALID_URL |
A URL is malformed, isn't https, carries a username or password, or points at a private network (including one only revealed by DNS resolution). Carries index. |
| 400 | URL_FETCH_FAILED |
A URL could not be downloaded: DNS failure, a non-2xx response, a timeout, more than 4 redirects, or a downgrade to http. Often transient — retrying is reasonable. Carries index. |
| 400 | UPLOAD_ABORTED |
The upload was cut off before the body finished arriving. Retrying is fine; if it keeps happening the submission is probably too big — split it by the size limit above. |
| 400 | MALFORMED_UPLOAD |
The body is not valid multipart/form-data. A client-side framing problem — retrying will not help; check the boundary and per-part headers. |
| 402 | INSUFFICIENT_CREDITS |
Not enough credits to cover this submission. |
| 409 | IDEMPOTENCY_KEY_IN_PROGRESS |
The previous request with this key is still being processed; wait for Retry-After and retry. |
| 413 | PAYLOAD_TOO_LARGE |
Total file content in one request exceeds 90MB. |
| 422 | IDEMPOTENCY_KEY_MISMATCH |
This key was already used for a request with different content. |
| 429 | RATE_LIMITED |
Rate limit hit — see Rate limits. |
2. Get job status — GET /api/v1/jobs/{jobId}
Poll this endpoint for progress.
Success — 200 OK
{
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"status": "processing",
"progress": 45,
"slideCount": 12,
"creditsUsed": 0,
"creditsRefunded": 0,
"cancellationRequested": false,
"createdAt": "2026-07-07 08:00:00",
"completedAt": null
}
Fields
| Field | Description |
|---|---|
status |
pending (queued) / processing / completed / failed. |
progress |
Percent complete, 0–100. |
slideCount |
Total pages. |
creditsUsed |
Credits actually charged after settlement. |
creditsRefunded |
Credits refunded for failed pages on partial success — see Billing & refunds. |
cancellationRequested |
Whether the service accepted a cancellation request. The four-state status enum is unchanged for backward compatibility. |
createdAt / completedAt |
UTC creation / completion time in YYYY-MM-DD HH:MM:SS format (completedAt is null until complete). |
downloadUrl |
Given only when completed and the output is still retained — a relative path to the download endpoint; omitted otherwise. |
error |
Given only when failed — {"code": "...", "message": "..."}. |
pageResults |
Given only when completed or failed — the per-page outcome, see below. |
callback |
Present only when the job was submitted with a callbackUrl — the webhook delivery state, see Completion webhooks. |
A failed job looks like
{
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"status": "failed",
"progress": 0,
"slideCount": 2,
"creditsUsed": 0,
"creditsRefunded": 2,
"createdAt": "2026-07-07 08:00:00",
"completedAt": "2026-07-07 08:01:00",
"error": { "code": "CONVERSION_FAILED", "message": "Conversion failed, please retry later" },
"pageResults": [
{
"pageNumber": 1,
"status": "failed",
"error": {
"code": "CONVERSION_FAILED",
"message": "Conversion failed, please retry later",
"retryable": true
}
},
{
"pageNumber": 2,
"status": "failed",
"error": {
"code": "PAGE_NOT_ATTEMPTED",
"message": "The job ended without a result for this page",
"retryable": true
}
}
]
}
Per-page results — pageResults
Once a job is terminal (completed or failed), this array reports every
page in page order; its length equals slideCount. It is omitted before that —
while a job is still running, "this page didn't convert" and "this page hasn't
had its turn yet" are not distinguishable.
(It is also omitted for early jobs that have no per-page record. Check whether the field is present rather than assuming every terminal job carries it.)
creditsRefunded only tells you how many pages did not convert.
pageResults tells you which ones.
| Field | Description |
|---|---|
pageNumber |
1-based page number, matching the order you submitted (PDFs follow their split page order). With pages, it is the position in the deck — the k-th selected page. |
status |
converted: the page became editable content. failed: it did not. |
error |
Present only when status is failed. Carries code, message and retryable. |
A failed page ends up one of two ways, told apart by error.code:
PAGE_NOT_ATTEMPTED— the job ended first (cancelled, timed out, or interrupted by a service restart), so the page has no result: it either never got its turn or was cut off mid-conversion. It is absent from the deck, and its credit was refunded.- Any other code — the page was attempted and failed. The deck keeps the original image for it (not editable), and its credit follows the Billing & refunds rules.
retryable says whether submitting the same image again could succeed. Every
failed page this endpoint can return today is true — the three codes below
are either a transient fault or a page left without a result because the job
ended first, so resubmitting is worth doing in all of them. Still branch on the field rather than hardcoding
true: a code added later may carry false.
Error codes
The job-level error.code (given when the whole job failed) has the same two
values it has had since this endpoint shipped:
| code | Meaning |
|---|---|
JOB_CANCELLED |
You cancelled or abandoned the job and nothing was deliverable. |
CONVERSION_FAILED |
Every other failure reason. |
Per-page pageResults[].error.code uses a finer set:
| code | Meaning | retryable |
|---|---|---|
CONVERSION_FAILED |
The page was attempted and did not succeed; the cause is not broken out. | true |
CONVERSION_TIMEOUT |
The page was cut off after exceeding its time budget. | true |
PAGE_NOT_ATTEMPTED |
The job ended first and the page has no result (it never got its turn, or was cut off). | true |
Why the two levels differ: the job-level field has carried only those two values
since this endpoint shipped, and deployed client code branches on them — widening
it would silently stop matching those branches. The finer reasons live in
pageResults, which is new field surface with no such history.
message is a human-readable sentence that follows your Accept-Language
header — do not branch on it, branch on code. It never carries
diagnostic detail.
Either level may gain new codes later. Treat a code you do not recognise as
CONVERSION_FAILED.
Possible errors
| HTTP | code | Meaning |
|---|---|---|
| 404 | JOB_NOT_FOUND |
Job id doesn't exist, isn't owned by this key's account, or was deleted by the account owner on the website. |
Note: job ids are visible only within your own account — nobody else can fetch or see your jobs.
3. Cancel a job — POST /api/v1/jobs/{jobId}/cancel
Ask the service to stop future work on this job. Cancellation is a graceful drain, not a hard kill:
- Pages already running finish; successful pages remain in the final PPTX and are billed.
- Pages that have not started are skipped and refunded. A page being dispatched at the moment cancellation arrives may still run to completion and be billed.
- Repeating the request is safe; it never cancels or settles twice.
Still winding down — 202 Accepted
{
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"cancellationRequested": true,
"finalizing": true
}
finalizing: true means running pages or PPTX assembly are still winding down.
Keep polling job status until it becomes completed or failed. If cancellation
settles within this request, the response is 200 OK with finalizing: false.
Cancellation can end in two ways:
- At least one successful page remains: the job is
completed, a partial PPTX is downloadable, and unproduced pages appear increditsRefunded. - Nothing is deliverable: the job is
failed,error.codeisJOB_CANCELLED, and all held credits are refunded.
Possible errors
| HTTP | code | Meaning |
|---|---|---|
| 404 | JOB_NOT_FOUND |
Job id does not exist, is not an API job, or belongs to another account. |
| 409 | JOB_ALREADY_FINISHED |
The job already finished, or is past the point where cancellation can change the outcome. |
| 500 | JOB_CANCEL_FAILED |
The service could not accept cancellation; retrying is safe. |
4. Download the result — GET /api/v1/jobs/{jobId}/download
Once the job is complete, download the PPTX here.
Success — 200 OK, with the PPTX binary as the response body
(Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation).
Possible errors
| HTTP | code | Meaning |
|---|---|---|
| 409 | NOT_READY |
Job isn't complete yet; the result isn't downloadable. Wait for completed. |
| 410 | OUTPUT_EXPIRED |
The result was cleaned up after its retention window — see Retention. |
| 416 | RANGE_NOT_SATISFIABLE |
The requested Range starts beyond the file size; discard the stale resume offset and retry. |
| 404 | JOB_NOT_FOUND |
Job id doesn't exist, isn't owned by this account, or was deleted on the website. |
Retention: the finished PPTX is kept for 7 days after completion, then auto-deleted; downloads afterward return
410 OUTPUT_EXPIRED. Fetch it within the window. (The job record stays; only the output file is removed.)
5. Get account — GET /api/v1/account
Success — 200 OK
{
"email": "you@example.com",
"credits": 328
}
credits is your currently available balance (excluding credits held by
in-flight jobs). API conversions and the web app share the same credit pool.
Pro pass pages (quarterly / annual) can only be used on the web and in the plugin;
they are not included here, and the API spends regular credits only.
Accounts that can call this endpoint have API access, so their credits never
expire with the free signup credits (those expire after 7 days only on accounts
with no payment and no API access).
6. List jobs — GET /api/v1/jobs
Lists the jobs this account submitted through the API and has not deleted on the website, newest first. Use it to reconcile usage at month end, or to find a job whose id you lost.
Query parameters (all optional)
| Parameter | Description |
|---|---|
createdFrom |
Start date, YYYY-MM-DD (UTC), inclusive. |
createdTo |
End date, YYYY-MM-DD (UTC), inclusive. |
limit |
Items per page, default 20, range 1–100. |
cursor |
For paging: pass the nextCursor from the previous page. It is an opaque string — send it back unchanged, never build one yourself. |
Success — 200 OK
{
"data": [
{
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"status": "completed",
"progress": 100,
"slideCount": 12,
"creditsUsed": 11,
"creditsRefunded": 1,
"cancellationRequested": false,
"createdAt": "2026-10-01 08:00:00",
"completedAt": "2026-10-01 08:03:10",
"downloadUrl": "/api/v1/jobs/3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37/download"
}
],
"nextCursor": "eyJjIjoiMjAyNi0xMC0wMSJ9"
}
- Each item in
datahas the same shape asGET /api/v1/jobs/{jobId}, withoutpageResults. Fetch the job itself for per-page results. - On the last page
nextCursorisnull. - Reconciliation: each item's
creditsUsedis what was actually charged andcreditsRefundedis what was returned; sum them as you need.
curl "https://image2ppt.com/api/v1/jobs?createdFrom=2026-09-01&createdTo=2026-09-30&limit=100" \
-H "Authorization: Bearer i2p_live_xxxx"
This endpoint is not rate limited today, but handle 429 anyway: if you get one,
wait for Retry-After and retry.
Possible errors
| HTTP | code | Meaning |
|---|---|---|
| 401 | INVALID_API_KEY |
Key missing or invalid. |
| 400 | INVALID_PARAMETER |
limit, cursor, createdFrom, or createdTo is invalid; message names which one. |
Completion webhooks
Pass a callbackUrl when you submit and we POST to it when the job ends — no
polling needed. Webhooks follow the Standard Webhooks
specification, so any library that verifies Standard Webhooks works as-is.
When it is sent
When the job reaches completed or failed. Cancelled jobs end in one of those two
states as well; there is no separate cancelled status. If we re-run a job and it
ends again, a new webhook is sent with a new webhook-id.
URL requirements
Checked at submission; a URL that fails returns 400 INVALID_CALLBACK_URL:
httpsonly, at most 2048 characters, no username or password; any port.- Every address the host name resolves to must be public. Private networks,
loopback, link-local (including cloud metadata addresses such as
169.254.169.254), multicast, reserved, documentation, and benchmarking ranges are refused. For IPv6 only the public2000::/3range is accepted, excluding NAT64 (64:ff9b::/96,64:ff9b:1::/48), 6to4 (2002::/16), Teredo (2001::/32), and documentation (2001:db8::/32); IPv4-mapped and IPv4-compatible addresses are judged by the IPv4 address inside. - The host is resolved and checked again before every delivery, and the connection goes only to the checked IP. Redirects are not followed.
Request format
POST <callbackUrl>
Content-Type: application/json
User-Agent: Image2PPT-Webhook/1.0
webhook-id: msg_2Lx8Qe7cV1nR4tYk
webhook-timestamp: 1790841600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
| Header | Description |
|---|---|
webhook-id |
Id of this delivery, shaped msg_…. It stays the same across retries of the same delivery — use it to deduplicate. |
webhook-timestamp |
When this attempt was sent, in Unix seconds. |
webhook-signature |
Signature, shaped v1,<base64>. During a secret rotation there are two, separated by a space. |
Body:
{
"type": "job.completed",
"data": {
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"status": "completed",
"progress": 100,
"slideCount": 12,
"creditsUsed": 12,
"creditsRefunded": 0,
"cancellationRequested": false,
"createdAt": "2026-10-01 08:00:00",
"completedAt": "2026-10-01 08:03:10",
"downloadUrl": "/api/v1/jobs/3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37/download",
"pageResults": [{ "pageNumber": 1, "status": "converted" }]
}
}
type:job.completedorjob.failed. More types may be added later; ignore types you don't recognise (and still answer 2xx).data: the same asGET /api/v1/jobs/{jobId}, without thecallbackfield.- The body is fixed when the job ends, so every retry carries exactly the same bytes.
- The language of
messagefields indatafollows theAccept-Languageof the submit request.
Verifying the signature
Verify every request before trusting its content:
- Reject it if
webhook-timestampis more than 300 seconds away from now. - Take the part of the secret after the
whsec_prefix and base64-decode it (standard alphabet) — that is the HMAC key. - The signed content is
<webhook-id>.<webhook-timestamp>.<raw body>; compute HMAC-SHA256 over it and base64-encode the result (standard alphabet). - Split
webhook-signatureon spaces; the request is valid if anyv1,value equals yours (compare in constant time).
Always compute over the raw bytes you received — never parse the JSON and re-serialise it. Using only the Python standard library:
import base64, hashlib, hmac, json, time
def verify_webhook(body: bytes, headers, secret: str) -> dict:
# headers must be looked up case-insensitively (Flask, Django and FastAPI headers are)
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("webhook-timestamp is more than 5 minutes off")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for item in headers["webhook-signature"].split(" "):
version, _, signature = item.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return json.loads(body)
raise ValueError("signature mismatch")
Managing the secret
Each account has one webhook secret, shaped whsec_<base64> (32 random bytes in
standard base64 after the prefix). View, copy, and rotate it on the Developer /
API page; it is created automatically the first time you open that page or first
submit a job with a callbackUrl.
For 24 hours after a rotation the signature header carries both the new and the old signature, which gives you a day to switch to the new secret without missing a webhook.
Retries
- A 2xx response within 10 seconds counts as success. Redirects, timeouts, non-2xx responses, and connection failures count as failures. Only the first few KB of your response body are read.
- At most 6 delivery attempts: immediately, then after 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours.
- Pending deliveries survive restarts of our service.
- Because of retries you may receive the same webhook more than once — deduplicate on
webhook-id. If your handling is slow, answer 2xx first and process asynchronously.
Delivery status
For jobs submitted with a callbackUrl, GET /api/v1/jobs/{jobId} and each item of
the job list carry an extra callback field:
"callback": {
"url": "https://example.com/hook",
"status": "pending",
"attempts": 1,
"lastAttemptAt": "2026-10-01 08:00:00",
"lastResponseStatus": 503,
"nextAttemptAt": "2026-10-01 08:01:00"
}
| Field | Description |
|---|---|
url |
The callback URL given at submission. |
status |
pending: the job hasn't ended yet, or the next attempt is scheduled. delivered: delivered. failed: all 6 attempts failed; no more retries. |
attempts |
Delivery attempts made so far. |
lastAttemptAt |
Time of the most recent attempt. |
lastResponseStatus |
HTTP status you returned on the most recent attempt; null for a network error or timeout. |
nextAttemptAt |
Time of the next attempt. |
Times are UTC in YYYY-MM-DD HH:MM:SS format, or null when there is no value. A
webhook that ultimately fails doesn't affect the job itself — you can still fetch
and download it as usual.
Rate limits
Limits are per account (all keys under one account share the quota):
- Concurrent in-flight jobs ≤ 10 (
pending+processing). - Submission rate ≤ 60 pages/minute. In a URL submission each URL counts as at least 1 page, whether or not it downloads.
- URL submissions: 1 in progress at a time.
Over the limit returns 429 (RATE_LIMITED) with a Retry-After response header
giving the suggested wait in seconds.
The right way to handle it: read Retry-After, wait that many seconds, then
retry — don't hammer immediately. The official Python client's wait() has this
backoff built in. If you submit directly yourself, mirror this pseudocode:
import time, requests
while True:
resp = requests.post(url, headers=headers, files=files)
if resp.status_code != 429:
break
time.sleep(int(resp.headers.get("Retry-After", "5")))
If you retry after a rate limit, reuse the same Idempotency-Key: a refused request
doesn't claim the key, and if an earlier attempt did go through you won't end up
with a second job.
Polling job status is not rate limited. The job list isn't rate limited today
either, but that may change — handle 429 the same way everywhere.
Versions & upgrade notices
The official SDKs (0.2.0 and later) identify themselves on every request:
User-Agent: image2ppt-python/0.2.0
User-Agent: image2ppt-node/0.2.0
The header is only used to understand which clients and versions are in use and to reach you if one of them has a problem — it plays no part in authentication or rate limiting and never changes the outcome of a request.
If you wrote your own client, you do not need to do anything about it. Every non-SDK caller is recorded simply as a custom client — no language, no version, and the string you send is not stored, so a custom name will not make your program easier for us to pick out. We would rather say that than promise visibility that does not exist.
One request only: please do not send image2ppt-python/... or
image2ppt-node/.... Those identify the official SDKs, so borrowing them distorts
the official-SDK share and makes us send you upgrade notices about a version you are
not running.
If the official SDK you are running is older than the lowest version we still support, responses carry three standard headers (RFC 8594 / RFC 9745) — on successful responses too, not only on errors:
Deprecation: @1793491200
Sunset: Sun, 01 Nov 2026 00:00:00 GMT
Link: <https://github.com/image2ppt/image2ppt-sdk/blob/main/CHANGELOG.md>; rel="deprecation"
Deprecation— this version is on its way out. The value is the date it was marked deprecated, written as@<Unix timestamp>per RFC 9745; checking whether the header is present is enough, you do not need to parse it.Sunset— when support is planned to end (present only once a date is set).Link— what changed and how to upgrade.
These headers are advisory only. The status code is unchanged, the request is processed as usual, and nothing is ever refused because of them. The official SDKs log a single warning when they see them (and let you switch it off). Actually retiring a version is a separate decision announced well in advance — never through this header alone.
Semantics
Async & latency expectations
Jobs run in the background after submission. A single page typically takes ~2 minutes; 90% of jobs finish within 3 minutes. Larger jobs take longer. Poll starting at 5s and back off toward ~15s — don't poll every second.
One job = one PPTX
All files in a single submission (multiple images / multi-page PDFs) are merged
into one deck, paginated in upload order (only the selected pages, when you pass
pages). For separate PPTX files, split into
separate submissions.
Billing & refunds
- Billed per page — 1 page = 1 credit.
- The API spends regular credits only: Pro pass pages (quarterly / annual) apply to
the web app and the plugin. When credits run short on an account that has pass
pages, the
INSUFFICIENT_CREDITSmessage says so. - On submit, credits for the total page count are held (
creditsReservedin the response). - On completion, credits are settled: the actual charge shows in
creditsUsed. - Partial success: if some pages fail but others succeed, the job is still
completed, the output includes the successful pages, and credits for the failed pages are refunded automatically (creditsRefunded > 0). - Total failure: the job becomes
failedand all held credits are refunded in full. - Cancellation: pages already running settle under the same rules; pages that
have not started are skipped and refunded. With no deliverable pages, status is
failedand the error code isJOB_CANCELLED.
In short: you only pay for pages that were successfully produced.
Official SDKs
We provide official Python and Node.js/TypeScript clients that wrap submission, polling, cancellation, download, 429 backoff, and error mapping. Source, examples, and the changelog live at https://github.com/image2ppt/image2ppt-sdk; install from the package pages: Python at https://pypi.org/project/image2ppt/, Node.js at https://www.npmjs.com/package/image2ppt.
Use the SDK server-side only. Never put an API key in a browser or anywhere a user can read it — anyone can extract it.
Python
pip install image2ppt
from image2ppt import Image2PPTClient, Image2PPTError, JobFailedError
client = Image2PPTClient(api_key="i2p_live_your_key")
try:
# One shot: submit → poll → download
job = client.convert(
["slide1.png", "slide2.png", "report.pdf"],
dest_path="out.pptx",
locale="en",
aspect_ratio="16:9",
)
print("done — credits used:", job.credits_used, "refunded:", job.credits_refunded)
except JobFailedError as e:
print("conversion failed:", e.code, e.message)
except Image2PPTError as e:
print("request error:", e.status_code, e.code, e.message)
Node.js / TypeScript
Zero dependencies, needs Node 18+ (uses the built-in fetch).
npm install image2ppt
import { Image2PPTClient, Image2PPTError, JobFailedError } from "image2ppt";
const client = new Image2PPTClient({ apiKey: "i2p_live_your_key" });
try {
const job = await client.convert(
["slide1.png", "slide2.png", "report.pdf"],
"out.pptx",
{ locale: "en", aspectRatio: "16:9" },
);
console.log("done — credits used:", job.creditsUsed, "refunded:", job.creditsRefunded);
} catch (e) {
if (e instanceof JobFailedError) console.error("conversion failed:", e.code, e.message);
else if (e instanceof Image2PPTError) console.error("request error:", e.statusCode, e.code, e.message);
else throw e;
}
Step-by-step control (submit / wait / download), account lookup (account),
and full details on each exception are in the GitHub repo's README and examples.
Error code reference
message is written for people to read, and its language follows the request's Accept-Language and nothing else: ask for Chinese and you get Chinese, send no such header or ask for anything else and you get English. Browser cookies and UI-language headers do not affect it, so calling this API from a browser behaves the same as calling it from a script. Branch on code in your own code — it never changes with the language.
| HTTP | code | When it happens |
|---|---|---|
| 401 | INVALID_API_KEY |
Key missing or invalid (all endpoints). |
| 400 | NO_FILES |
No files attached, or urls is empty (submit). |
| 400 | INVALID_FILE |
Unsupported format or a single file over 35MB (submit). |
| 400 | INVALID_PDF |
PDF can't be read or parsed (submit). |
| 400 | INVALID_ASPECT_RATIO |
Unrecognized aspect ratio; use auto, 16:9, or 4:3 (submit). |
| 400 | TOO_MANY_SLIDES |
Total pages, or selected pages, over 50 (submit). |
| 400 | INVALID_PAGES |
pages has a syntax error (submit). |
| 400 | PAGES_OUT_OF_RANGE |
A page number in pages is 0 or beyond the total page count; message states the total (submit). |
| 400 | INVALID_CALLBACK_URL |
callbackUrl isn't https, exceeds 2048 characters, carries a username or password, or isn't a public address (submit). |
| 400 | INVALID_IDEMPOTENCY_KEY |
Idempotency-Key isn't 1–255 printable ASCII characters (submit). |
| 400 | INVALID_JSON |
The body isn't valid JSON, or isn't a JSON object (submit by URL). |
| 400 | INVALID_PARAMETER |
A JSON field has the wrong type or there are more than 50 URLs (submit by URL); limit, cursor, createdFrom, or createdTo is invalid (list jobs). |
| 400 | INVALID_URL |
A URL is malformed, isn't https, carries a username or password, or points at a private network; carries index (submit by URL). |
| 400 | URL_FETCH_FAILED |
A URL could not be downloaded: DNS failure, non-2xx, timeout, more than 4 redirects, or a downgrade to http; carries index. Often transient — retrying is reasonable (submit by URL). |
| 400 | PAGE_RATE_EXCEEDED |
A single submission's page count exceeds the per-minute submission limit, so it can never fit the window (submit). |
| 402 | INSUFFICIENT_CREDITS |
Not enough available credits, or a zero balance (submit). |
| 403 | API_KEY_REQUIRED |
No valid API key present (submit). |
| 403 | ACCOUNT_DELETED |
Account has been deleted (submit). |
| 400 | UPLOAD_ABORTED |
The upload was cut off before the body finished arriving (submit). |
| 400 | MALFORMED_UPLOAD |
The body is not valid multipart/form-data (submit). |
| 413 | PAYLOAD_TOO_LARGE |
Total file content in one request exceeds 90MB (submit). |
| 429 | RATE_LIMITED |
Rate limit hit, with a Retry-After header (submit). Status polling is not rate limited; the job list isn't today either, but handle 429 there too. |
| 404 | JOB_NOT_FOUND |
Job id doesn't exist, isn't owned by this account, or was deleted on the website (status, cancel, download). |
| 409 | IDEMPOTENCY_KEY_IN_PROGRESS |
The previous request with the same Idempotency-Key is still being processed, with a Retry-After header (submit). |
| 409 | JOB_ALREADY_FINISHED |
The job already finished, or is past the point where cancellation can change the outcome (cancel). |
| 409 | NOT_READY |
Download requested before the job completed (download). |
| 422 | IDEMPOTENCY_KEY_MISMATCH |
The same Idempotency-Key was already used for a request with different content (submit). |
| — | JOB_CANCELLED |
Cancellation settled with no deliverable pages (error.code in job status). |
| 410 | OUTPUT_EXPIRED |
Result cleaned up after its retention window (download). |
| 416 | RANGE_NOT_SATISFIABLE |
Resume range starts beyond the result file size (download). |
| 5xx | JOB_CANCEL_FAILED, STORAGE_FAILED, etc. |
Server-side error; retry later. If it persists, contact us. |