Image2PPT 开发者 API
把图片和 PDF 批量转换成可编辑的 PPTX。上传一批文件,我们在后台用 AI 拆解版面、还原成可编辑的文字与形状,合成一个 PPTX 给你下载。
本文面向对接方的开发同学,读完就能接入。
一分钟了解怎么用
- 登录后进入「开发者 / API」页面创建一个 API 密钥。
- 调
POST /api/v1/jobs上传文件(或者给出文件链接),拿到一个任务号。 - 每隔几秒调
GET /api/v1/jobs/{任务号}查进度,直到状态变成completed;也可以在提交时带上callbackUrl,任务结束时我们主动通知你(见「完成回调」)。 - 调
GET /api/v1/jobs/{任务号}/download下载成品 PPTX。
如果不再需要结果,可随时调 POST /api/v1/jobs/{任务号}/cancel 请求停止后续页面。要对账或找回之前的任务,调 GET /api/v1/jobs 按时间列出。
转换是异步的:提交后立刻返回任务号,真正的转换在后台跑。别在提交那一步干等结果。
1.3.0 新增的参数全部可选。
callbackUrl、pages、Idempotency-Key请求头和按链接提交都不传时,接口行为与 1.2.0 完全一致,已有的接入代码不用改。
认证
拿到密钥
登录 Image2PPT 后,从账号菜单进入「开发者 / API」页面,在「API Keys」处自助创建,得到一串形如下面的密钥:
i2p_live_xxxxxxxxxxxxxxxxxxxxxxxx
密钥只在创建时完整显示一次,请当场保存好。 之后页面只会显示前几位用于辨认。密钥泄露或需要轮换时,在同一页面吊销旧的、重建新的。
怎么带
每个请求都在 HTTP 头里带上密钥:
Authorization: Bearer i2p_live_xxxxxxxxxxxxxxxxxxxxxxxx
没带或带错,会返回 401(错误码 INVALID_API_KEY)。
基础地址
https://image2ppt.com
下文所有路径都拼在这个地址后面。
统一约定
请求和响应的 JSON 都用 UTF-8。
所有错误都是同一个信封格式,HTTP 状态码 + 一个
error对象:{ "error": { "code": "INVALID_FILE", "message": "不支持的文件格式:.bmp" } }你的代码应当按
code分支处理,message面向人看、可能会调整措辞,别拿它做逻辑判断。按链接提交出错时,
error里会多一个index,指出是第几条链接(从 0 开始):{ "error": { "code": "URL_FETCH_FAILED", "message": "无法下载这条链接", "index": 2 } }其他错误不带这个字段。
端点
1. 提交任务 POST /api/v1/jobs
提交一批文件,创建一个转换任务。请求体有两种写法,二选一,不能混用:
multipart/form-data:直接上传文件。application/json:给出文件链接,由我们去下载,见下方「按链接提交」。
multipart 字段
| 字段 | 必填 | 说明 |
|---|---|---|
files |
是 | 一个或多个文件。支持 png / jpeg / webp / gif / pdf,单文件不超过 35MB,同一请求的文件内容合计不超过 90MB。同一个字段名 files 重复出现来传多个文件。 |
locale |
否 | 成品语言环境,zh-CN(默认)或 en。 |
aspectRatio |
否 | 幻灯片比例,auto(默认,随原图)/ 16:9 / 4:3。 |
pages |
否 | 只转换其中几页,如 1-3,7,见下方「选页」。不传则全部转换。 |
callbackUrl |
否 | 任务结束时通知的 https 地址,见「完成回调」。 |
请求头
| 请求头 | 必填 | 说明 |
|---|---|---|
Idempotency-Key |
否 | 防重复提交的标识,网络超时后可以放心重试,见下方「防重复提交」。 |
一次提交有两条上限,都要满足:
| 上限 | 数值 | 说明 |
|---|---|---|
| 总页数 | ≤ 50 页 | 一张图片算 1 页,一个 PDF 按它的实际页数算。传了 pages 时按选中的页数算,PDF 本身可以超过 50 页。 |
| 总体积 | ≤ 90MB | 这一次请求里所有文件内容加起来。单个文件另有 35MB 的上限。 |
两条是独立的:40 张高清图片只有 40 页,却很容易超过 90MB。页数没满不代表能提交。
超过总体积会返回 413 PAYLOAD_TOO_LARGE。遇到它请减少单次提交的文件数量分批提交,重试同样的内容不会成功。
官方 SDK 分两种用法,行为不同:submit() / convert()(TypeScript 同名)只提交你给的这一批,会在上传前先本地核对体积和页数,超了直接报错,不会白传一遍,但不会替你拆批;要自动拆批请用 submit_all() / convert_all()(TypeScript 为 submitAll() / convertAll()),它们按体积和页数把文件切成若干次提交,每次一个任务。
成功响应 201 Created
{
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"status": "pending",
"slideCount": 12,
"creditsReserved": 12
}
slideCount:这次要转换的总页数(传了pages时是选中的页数)。creditsReserved:为这次任务锁定的积分(= 页数)。提交时锁定,完成时结算。
带 Idempotency-Key 重放同一个请求时,同样返回 201、结构相同,另带响应头 Idempotent-Replayed: true,见下方「防重复提交」。
curl 示例
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=zh-CN" \
-F "aspectRatio=16:9" \
-F "pages=1-3,7" \
-F "callbackUrl=https://example.com/hooks/image2ppt"
按链接提交
文件已经在对象存储或 CDN 上时,不用先下载再上传,直接把链接交给我们。请求体改成 JSON:
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": "zh-CN",
"aspectRatio": "auto",
"pages": "1-3,7",
"callbackUrl": "https://example.com/hooks/image2ppt"
}'
| 字段 | 必填 | 说明 |
|---|---|---|
urls |
是 | 文件链接,字符串数组,1–50 条,按顺序拼成一个 PPTX。 |
locale / aspectRatio / pages / callbackUrl |
否 | 含义同 multipart 字段。 |
规则:
- JSON 请求体不超过 64KB,超过返回
413 PAYLOAD_TOO_LARGE。字段值为null或空字符串时等同于没传。 - 每条链接必须是
https,长度不超过 2048,不能带用户名和密码,并且解析后必须落在公网地址上(内网、本机、云主机元数据等地址一律拒绝),规则与回调地址相同。 - 下载时最多跟随 4 次重定向,每一跳都重新检查,而且必须仍是
https。 - 文件类型按内容判断,不看对方返回的
Content-Type。下载得到的文件和直接上传的文件走同一套限制:单个超过 35MB 或合计超过 90MB 会立即中断,格式、PDF、页数超限分别返回INVALID_FILE/INVALID_PDF/PAYLOAD_TOO_LARGE/TOO_MANY_SLIDES。 - 时限:每条链接 60 秒(含读完内容),整个请求 120 秒;同一请求最多同时下载 4 条。
- 同一账户同一时间只能有 1 个按链接提交的请求在进行,否则返回
429 RATE_LIMITED(带Retry-After)。 - 开始下载前会先检查余额和进行中的任务数。每条链接无论下载成功与否,至少按 1 页计入每分钟 60 页的提交额度。
只要错误是某一条链接引起的——INVALID_URL、URL_FETCH_FAILED,以及下载下来的文件触发的 INVALID_FILE、PAYLOAD_TOO_LARGE——error.index 都会指出是第几条(从 0 开始);其余错误不带这个字段。
选页 pages
只想转换 PDF 里的几页时,用 pages 指定,不必先自己拆 PDF。
- 页码指这次提交按顺序拼起来之后的页码:一张图片占 1 页,一个 PDF 占它的页数。只提交一个 PDF 时,就是 PDF 自己的页码。
- 写法:逗号分隔的单页或
起-止区间,从 1 开始,允许空格,如"1-3, 7"。整串不超过 1000 个字符。 - 重复或重叠的部分会合并;成品按页码从小到大排列;
起大于止(如5-3)算写法错误。 slideCount和锁定的积分按选中的页数算。选中不超过 50 页即可提交,PDF 本身可以超过 50 页。- 成品和
pageResults[].pageNumber里的页码是成品中的第几页,也就是第几个被选中的页。例如pages=2,5时,原第 5 页在成品里是第 2 页。
| HTTP | code | 场景 |
|---|---|---|
| 400 | INVALID_PAGES |
写法错误。 |
| 400 | PAGES_OUT_OF_RANGE |
页码为 0,或超过总页数(message 会写明总页数)。 |
| 400 | TOO_MANY_SLIDES |
选中超过 50 页。 |
防重复提交 Idempotency-Key
网络超时的时候,你不知道上一次提交到底成功没有。带上 Idempotency-Key 请求头,就可以放心用同一个 key 重试:同一个请求只会建一个任务、只扣一次积分。
- key 是 1–255 个可打印 ASCII 字符(
0x21–0x7E,不含空格),否则返回400 INVALID_IDEMPOTENCY_KEY。建议每次新提交生成一个 UUID。 - 作用范围是账户:同一账户下的所有 API 密钥共用一套 key。
- 有效期:首次提交成功后 24 小时。
- 「同一个请求」的判据:按顺序的文件内容(只比字节,不比文件名)或
urls列表,加上locale、aspectRatio、pages、callbackUrl。没传的字段先按默认值补齐再比较。
| 情况 | 结果 |
|---|---|
| 同一个 key、同一个请求,原任务还在 | 201,响应结构与首次相同(status 是任务当前的状态),响应头 Idempotent-Replayed: true,不再扣积分。 |
| 同一个 key、请求内容不同 | 422 IDEMPOTENCY_KEY_MISMATCH。 |
| 同一个 key 的上一个请求还在处理 | 409 IDEMPOTENCY_KEY_IN_PROGRESS,带 Retry-After: 2,稍等再重试。 |
| 上一个请求失败了(任何 4xx / 5xx) | 失败的请求不占用 key,可以用同一个 key 重试。 |
| 原任务已经在网站上删除 | key 作废,按一次新提交处理。 |
可能的错误
| HTTP | code | 含义 |
|---|---|---|
| 401 | INVALID_API_KEY |
密钥无效或缺失。 |
| 400 | NO_FILES |
没有带任何文件,或 urls 为空。 |
| 400 | INVALID_FILE |
文件格式不支持,或单文件超过 35MB。 |
| 400 | INVALID_PDF |
PDF 无法读取或解析。 |
| 400 | TOO_MANY_SLIDES |
总页数(或选中的页数)超过 50。 |
| 400 | INVALID_PAGES |
pages 写法错误。 |
| 400 | PAGES_OUT_OF_RANGE |
pages 里的页码为 0 或超过总页数。 |
| 400 | INVALID_CALLBACK_URL |
callbackUrl 不合格:不是 https、太长、带用户名密码,或不是公网地址。 |
| 400 | INVALID_IDEMPOTENCY_KEY |
Idempotency-Key 长度不在 1–255 之间,或含 0x21–0x7E 以外的字符(空格、中文都不行)。 |
| 400 | INVALID_JSON |
JSON 请求体不是合法 JSON,或不是 JSON 对象。 |
| 400 | INVALID_PARAMETER |
JSON 字段类型不对,或超过 50 条链接。 |
| 400 | INVALID_URL |
链接格式不对、不是 https、带用户名密码,或指向内网(包括 DNS 解析后才落到内网的)。带 index。 |
| 400 | URL_FETCH_FAILED |
链接下载失败:无法解析、对方返回非 2xx、超时、重定向超过 4 次或降级到 http。可能是临时故障,可以重试。带 index。 |
| 400 | UPLOAD_ABORTED |
上传中途断开,请求体没收完。重试即可;反复出现多半是这次提交太大,先按上面的体积上限分批。 |
| 400 | MALFORMED_UPLOAD |
请求体不是合法的 multipart/form-data。这是客户端拼装问题,重试不会好——检查边界串和各分段的头。 |
| 402 | INSUFFICIENT_CREDITS |
可用积分不够覆盖这次提交。 |
| 409 | IDEMPOTENCY_KEY_IN_PROGRESS |
同一个 key 的上一个请求还在处理,按 Retry-After 等待后重试。 |
| 413 | PAYLOAD_TOO_LARGE |
同一请求的文件内容合计超过 90MB。 |
| 422 | IDEMPOTENCY_KEY_MISMATCH |
这个 key 已经用在另一个内容不同的请求上。 |
| 429 | RATE_LIMITED |
触发限流,见下方「限流」。 |
2. 查询任务状态 GET /api/v1/jobs/{jobId}
轮询这个端点看进度。
成功响应 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
}
字段
| 字段 | 说明 |
|---|---|
status |
pending(排队中)/ processing(转换中)/ completed(已完成)/ failed(已失败)。 |
progress |
进度百分比,0–100。 |
slideCount |
总页数。 |
creditsUsed |
结算后实际扣除的积分。 |
creditsRefunded |
部分成功时退回的失败页积分,见「计费与退款」。 |
cancellationRequested |
服务器是否已接受取消请求。为兼容旧客户端,任务状态仍使用原有四种取值。 |
createdAt / completedAt |
UTC 创建时间 / 完成时间,格式为 YYYY-MM-DD HH:MM:SS(未完成时 completedAt 为 null)。 |
downloadUrl |
仅当 completed 且成品仍在保留期内时给出,是下载端点的相对路径;其余状态不返回这个字段。 |
error |
仅当 failed 时给出,形如 {"code": "...", "message": "..."}。 |
pageResults |
仅当 completed 或 failed 时给出,逐页交账,见下。 |
callback |
仅当提交时带了 callbackUrl 才有,是完成回调的投递情况,见「完成回调」。 |
失败时的样子
{
"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": "转换失败,请稍后重试" },
"pageResults": [
{
"pageNumber": 1,
"status": "failed",
"error": { "code": "CONVERSION_FAILED", "message": "转换失败,请稍后重试", "retryable": true }
},
{
"pageNumber": 2,
"status": "failed",
"error": { "code": "PAGE_NOT_ATTEMPTED", "message": "任务已结束,这一页没有转换结果", "retryable": true }
}
]
}
逐页结果 pageResults
任务到终局(completed 或 failed)后,这个数组按页码顺序列出每一页的结果,长度等于 slideCount。在此之前不返回——任务还在跑的时候,「这页没转出来」和「这页还没轮到」区分不了。
(早期任务没有逐页记录,这个字段也不返回。判断时请检查字段是否存在,不要假定终局任务一定带它。)
creditsRefunded 只回答「有几页没转出来」,pageResults 回答「是哪几页」。
| 字段 | 说明 |
|---|---|
pageNumber |
页码,从 1 开始,与你提交的顺序一致(PDF 按拆出来的页序)。用了 pages 选页时,指成品中的第几页(第几个被选中的页)。 |
status |
converted:这一页转成了可编辑内容。failed:没转成。 |
error |
仅当 status 为 failed。含 code、message 和 retryable。 |
失败页有两种去向,靠 error.code 区分:
PAGE_NOT_ATTEMPTED— 任务先一步结束了(被取消、超时或被服务重启打断),这一页没有转换结果:可能还没轮到,也可能正转到一半被打断。成品里没有这一页,积分已退。- 其余错误码 — 这一页转过但没成,成品里保留的是这一页的原图(不可编辑),积分按「计费与退款」规则处理。
retryable 表示同样的图再交一次有没有可能成功。目前逐页结果里的每个失败页都是 true——下面那三个码要么是一次性的故障,要么是任务先一步结束、这页没留下结果,重交都有意义。仍然请判断这个字段而不要写死 true:以后新增的码可能带 false。
错误码
任务级 error.code(整单失败时给出)只有两个值,跟这个接口发布时一样:
| code | 含义 |
|---|---|
JOB_CANCELLED |
你自己取消或放弃了这个任务,且没有可交付页面。 |
CONVERSION_FAILED |
其余所有失败原因。 |
逐页 pageResults[].error.code 用的是更细的一套:
| code | 含义 | retryable |
|---|---|---|
CONVERSION_FAILED |
这页转过但没成,原因不细分。 | true |
CONVERSION_TIMEOUT |
这页超过时间预算被中断。 | true |
PAGE_NOT_ATTEMPTED |
任务先一步结束,这页没有转换结果(没轮到或被打断)。 | true |
为什么两层粒度不同:任务级那个字段从接口发布起就只有两个值,客户已经部署的代码是照着写的,扩大取值会让那些分支静默失效。细分放在 pageResults 里——那是新字段,没有历史包袱。
message 是给人看的一句话,会跟着你的 Accept-Language 走,不要拿它做分支判断——请判断 code。它不会夹带出错的诊断细节。
两层都可能新增错误码。请把认不出的 code 当作 CONVERSION_FAILED 处理。
可能的错误
| HTTP | code | 含义 |
|---|---|---|
| 404 | JOB_NOT_FOUND |
任务号不存在、不属于当前密钥所在账户,或已被账户所有者在网站上删除。 |
提示:任务号只在你自己的账户内可见,别人拿不到、也查不到你的任务。
3. 取消任务 POST /api/v1/jobs/{jobId}/cancel
请求服务器停止这个任务的后续工作。这个动作是收尾式取消,不是硬切:
- 已经开始转换的页面会继续完成,成功页面会保存在最终 PPTX 中并正常计费。
- 尚未开始的页面不会再运行,对应积分退回。取消到达的那一刻恰好正在派发的页可能仍会跑完,按已完成页计费。
- 请求可以安全重试,不会重复取消或重复结算。
任务仍在收尾 202 Accepted
{
"jobId": "3f2b8c1e-9a4d-4c7b-8e2f-1a6d5c0b9e37",
"cancellationRequested": true,
"finalizing": true
}
finalizing: true 表示仍有页面或 PPTX 装包正在收尾。继续轮询任务状态,直到变成 completed 或 failed。如果取消在本次请求内已经结算完,则返回 200 OK 且 finalizing: false。
取消后可能有两种终局:
- 至少保留了一页成功结果:任务为
completed,可下载部分 PPTX;未产出的页面通过creditsRefunded退款。 - 没有可交付页面:任务为
failed,error.code为JOB_CANCELLED,积分全额退回。
可能的错误
| HTTP | code | 含义 |
|---|---|---|
| 404 | JOB_NOT_FOUND |
任务号不存在、不是 API 任务,或不属于本账户。 |
| 409 | JOB_ALREADY_FINISHED |
任务已经结束,或已进入收尾、取消不再能改变结果。 |
| 500 | JOB_CANCEL_FAILED |
服务端暂时无法接受取消请求,可安全重试。 |
4. 下载成品 GET /api/v1/jobs/{jobId}/download
任务完成后,从这里下载 PPTX。
成功响应 200 OK,响应体就是 PPTX 二进制流(Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation)。
可能的错误
| HTTP | code | 含义 |
|---|---|---|
| 409 | NOT_READY |
任务还没完成,成品暂不可下载。等状态变成 completed 再来。 |
| 410 | OUTPUT_EXPIRED |
成品已过保留期被清理,无法下载(见下方「保留期」)。 |
| 416 | RANGE_NOT_SATISFIABLE |
Range 请求的起点超出文件大小,请丢弃旧的续传位置后重新下载。 |
| 404 | JOB_NOT_FOUND |
任务号不存在、不属于本账户,或已在网站上被删除。 |
保留期:成品 PPTX 在完成后保留 7 天,过期自动清理,之后下载会返回
410 OUTPUT_EXPIRED。请在保留期内取走。(历史记录仍在,只是成品文件不再保存。)
5. 查询账户 GET /api/v1/account
成功响应 200 OK
{
"email": "you@example.com",
"credits": 328
}
credits 是当前可用积分(不含已被进行中任务锁定的部分)。API 转换与网页端共用同一份积分。Pro 季卡 / 年卡的页数只能在网页和插件里用,不计入这里,API 只扣普通积分。能调用本接口的账号都已开通 API,积分不会因注册赠额到期而作废(只有从没付过费、也没开通 API 的账号,注册赠送的积分才会在 7 天后作废)。
6. 任务列表 GET /api/v1/jobs
按创建时间从新到旧列出本账户通过 API 提交、且没有在网站上删除的任务。适合月底对账,或者找回丢了任务号的任务。
查询参数(都可省略)
| 参数 | 说明 |
|---|---|
createdFrom |
起始日期,YYYY-MM-DD(UTC),包含当天。 |
createdTo |
截止日期,YYYY-MM-DD(UTC),包含当天。 |
limit |
每页条数,默认 20,范围 1–100。 |
cursor |
翻页用,填上一页返回的 nextCursor。它是不透明字符串,原样传回即可,不要自己拼。 |
成功响应 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"
}
data里每一项和GET /api/v1/jobs/{jobId}的返回结构相同,只是不含pageResults。要看逐页结果,再单独查那个任务。- 最后一页的
nextCursor是null。 - 对账:每项的
creditsUsed是实际扣除的积分,creditsRefunded是退还的积分,按需要自己加总。
curl "https://image2ppt.com/api/v1/jobs?createdFrom=2026-09-01&createdTo=2026-09-30&limit=100" \
-H "Authorization: Bearer i2p_live_xxxx"
这个端点目前不限流,但请照样处理 429:收到时按 Retry-After 等待再重试。
可能的错误
| HTTP | code | 含义 |
|---|---|---|
| 401 | INVALID_API_KEY |
密钥无效或缺失。 |
| 400 | INVALID_PARAMETER |
limit、cursor、createdFrom 或 createdTo 不合法,message 会写明是哪一个。 |
完成回调(Webhook)
提交时带上 callbackUrl,任务结束时我们会向这个地址发一个 POST,不用再轮询。回调遵循 Standard Webhooks 规范,任何支持这个规范的校验库都能直接用。
什么时候发
任务进入 completed 或 failed 时发送。取消的任务也会落到这两种状态之一,没有单独的「已取消」状态。如果任务被我们重跑后再次结束,会以新的 webhook-id 再发一次。
地址要求
提交时就会检查,不合格返回 400 INVALID_CALLBACK_URL:
- 必须是
https,长度不超过 2048,不能带用户名和密码;端口不限。 - 域名解析出来的所有地址都必须是公网地址。内网、本机回环、链路本地(包括
169.254.169.254这类云主机元数据地址)、组播、保留地址、文档和基准测试专用地址一律拒绝。IPv6 只接受2000::/3公网段,并排除其中的 NAT64(64:ff9b::/96、64:ff9b:1::/48)、6to4(2002::/16)、Teredo(2001::/32)和文档地址(2001:db8::/32);IPv4 映射或兼容的 IPv6 地址按其中的 IPv4 判断。 - 每次投递前都会重新解析并检查,连接只发往检查通过的 IP;不跟随重定向。
请求格式
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=
| 请求头 | 说明 |
|---|---|
webhook-id |
这次投递的编号,形如 msg_…。同一次投递的各次重试都相同,用它去重。 |
webhook-timestamp |
本次发出的时间,Unix 秒。 |
webhook-signature |
签名,形如 v1,<base64>。密钥轮换期间会有两个,用空格分隔。 |
请求体:
{
"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.completed或job.failed。以后可能新增类型,认不出的类型请直接忽略(照常返回 2xx)。data:与GET /api/v1/jobs/{jobId}的返回相同,但不含callback字段。- 请求体在任务结束时就定下来了,每次重试的字节完全相同。
data里message的语言跟随提交那次请求的Accept-Language。
校验签名
每个请求都要先校验签名,再相信里面的内容:
webhook-timestamp与当前时间相差超过 300 秒,拒收。- 取密钥
whsec_前缀之后的那段,按标准 base64 解码,得到 HMAC 密钥。 - 签名内容是
<webhook-id>.<webhook-timestamp>.<原始请求体>,用 HMAC-SHA256 计算,结果做标准 base64 编码。 - 把
webhook-signature按空格拆开,任意一个v1,后面的值与自己算出的相等(用恒定时间比较)即通过。
一定要用收到的原始字节计算,不要先解析 JSON 再重新序列化。只用 Python 标准库的写法:
import base64, hashlib, hmac, json, time
def verify_webhook(body: bytes, headers, secret: str) -> dict:
# headers 需按不区分大小写的方式取值(Flask、Django、FastAPI 的 headers 都是)
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("webhook-timestamp 超出 5 分钟")
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("签名不匹配")
密钥管理
每个账户一份回调密钥,形如 whsec_<base64>(前缀后是 32 个随机字节的标准 base64)。在「开发者 / API」页面查看、复制和轮换;第一次打开这个页面,或第一次提交带 callbackUrl 的任务时自动生成。
轮换后的 24 小时内,签名头会同时带新旧两个签名,你有一天时间把新密钥换上,期间不会漏收。
重试
- 你的服务在 10 秒内返回 2xx 算成功。重定向、超时、非 2xx、连接失败都算失败。你返回的响应体我们只读前几 KB。
- 最多投递 6 次:立即、1 分钟后、5 分钟后、30 分钟后、2 小时后、6 小时后。
- 待发的回调在我们的服务重启后不会丢。
- 因为会重试,同一条回调你可能收到不止一次,请按
webhook-id去重。处理慢的逻辑建议先返回 2xx,再异步处理。
查看投递情况
带了 callbackUrl 的任务,GET /api/v1/jobs/{jobId} 和任务列表的每一项会多一个 callback 字段:
"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"
}
| 字段 | 说明 |
|---|---|
url |
提交时给的回调地址。 |
status |
pending:任务还没结束,或在等下一次投递。delivered:已送达。failed:6 次都失败,不再重试。 |
attempts |
已经投递的次数。 |
lastAttemptAt |
最近一次投递的时间。 |
lastResponseStatus |
最近一次你返回的 HTTP 状态码;网络错误或超时为 null。 |
nextAttemptAt |
下一次投递的时间。 |
时间都是 UTC,格式 YYYY-MM-DD HH:MM:SS;没有值时为 null。回调最终失败时,任务本身不受影响,照常查询和下载即可。
限流
按账户限流(同一账户下所有密钥共享额度):
- 同时进行中的任务 ≤ 10 个(
pending+processing)。 - 提交速率 ≤ 60 页/分钟。按链接提交时,每条链接无论下载成败,至少按 1 页计。
- 按链接提交:同一时间只能有 1 个请求在进行。
超出时返回 429(RATE_LIMITED),并在 Retry-After 响应头给出建议等待的秒数。
查询任务状态不限流。任务列表目前也不限流,但以后可能会加,请统一处理 429。
正确的应对:读 Retry-After,等这么多秒再重试,别无脑立刻重试。官方 Python 客户端的 wait() 已经内建了这个退避;若你自己直接提交,参考下面的伪代码:
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")))
撞到限流后重试时,带上同一个 Idempotency-Key 更稳妥:被拒的请求不占用 key,而万一上一次其实已经成功,也不会建出第二个任务。
版本与升级提示
官方 SDK(0.2.0 起)每个请求都会自报家门:
User-Agent: image2ppt-python/0.2.0
User-Agent: image2ppt-node/0.2.0
这个头只用于统计和联系你,不参与鉴权、不参与限流、不影响任何请求的结果。
自己写客户端的话,不用管这个头——带不带都一样,我们把所有非官方 SDK 的调用统一记成「自建客户端」,不区分语言、不记版本,也不保存你报的那串字符(所以带一个自定义的名字并不会让你的程序在我们这边更容易被认出来,这一点我们不做假承诺)。
只有一个请求:不要冒用 image2ppt-python/... 或 image2ppt-node/...。这两个字符串是官方 SDK 的身份,冒用会让「官方 SDK 占比」这个数失真,而且我们会照着它给你发升级提示——提示里指的版本你根本没在用。
如果你用的官方 SDK 版本已经低于我们仍在维护的最低版本,响应里会多带三个标准头(RFC 8594 / RFC 9745),成功的响应上也带:
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—— 你这个版本我们准备停掉了。值是它被判定为过时的日期,按 RFC 9745 写成@加 Unix 时间戳。判断"有没有这个头"就够了,不必解析它。Sunset—— 计划停止支持的时间(有明确日期时才会出现)。Link—— 改了什么、怎么升级。
**这些头只是提醒。**状态码不变,请求照常处理,不会因此被拒绝。官方 SDK 收到后会在你的日志里打一条警告(可以关掉)。真要停用某个版本,我们会提前很久单独公告,不会只靠这个头。
业务语义
异步与时延预期
提交后任务在后台跑。单页典型耗时约 2 分钟,九成任务在 3 分钟内完成。页数多的任务更久。建议轮询间隔从 5 秒起、逐步退避到 15 秒左右,不要每秒猛查。
一个任务 = 一个 PPTX
一次提交的所有文件(多张图 / 多页 PDF)会合成同一个 deck,按上传顺序排页;用了 pages 时只包含选中的页。想要多个独立 PPTX,就分成多次提交。
计费与退款
- 按页计费,1 页 1 积分。
- API 只扣普通积分:Pro 季卡 / 年卡的页数只能用于网页和插件。积分不足而账号上有卡页时,
INSUFFICIENT_CREDITS的说明会提到这一点。 - 提交时按总页数锁定相应积分(响应里的
creditsReserved)。 - 完成时结算:实际扣除体现在
creditsUsed。 - 部分成功:如果个别页转换失败、其余成功,任务仍然是
completed,成品里包含成功的页,失败页的积分自动退回,体现在creditsRefunded(此时creditsRefunded > 0)。 - 整体失败:任务变成
failed,锁定的积分全额退回。 - 主动取消:已开始页面完成后按同一规则结算;未开始页面不再运行并退款。若没有可交付页面,状态为
failed且错误码为JOB_CANCELLED。
一句话:你只为成功产出的页付费。
官方 SDK
我们提供 Python 和 Node.js/TypeScript 两个官方客户端,都封装了提交、轮询、取消、下载、429 退避和错误映射。源码、示例和变更记录在 https://github.com/image2ppt/image2ppt-sdk;安装从包页面走:Python 见 https://pypi.org/project/image2ppt/,Node.js 见 https://www.npmjs.com/package/image2ppt。
SDK 只在服务端使用。别把 API 密钥放进浏览器或任何用户能看到的地方——谁都能读出来。
Python
pip install image2ppt
from image2ppt import Image2PPTClient, Image2PPTError, JobFailedError
client = Image2PPTClient(api_key="i2p_live_你的密钥")
try:
# 一步到位:提交 → 轮询等待 → 下载
job = client.convert(
["slide1.png", "slide2.png", "report.pdf"],
dest_path="out.pptx",
locale="zh-CN",
aspect_ratio="16:9",
)
print("完成,用掉积分:", job.credits_used, "退回:", job.credits_refunded)
except JobFailedError as e:
print("转换失败:", e.code, e.message)
except Image2PPTError as e:
print("请求出错:", e.status_code, e.code, e.message)
Node.js / TypeScript
零依赖,需要 Node 18+(用内置 fetch)。
npm install image2ppt
import { Image2PPTClient, Image2PPTError, JobFailedError } from "image2ppt";
const client = new Image2PPTClient({ apiKey: "i2p_live_你的密钥" });
try {
const job = await client.convert(
["slide1.png", "slide2.png", "report.pdf"],
"out.pptx",
{ locale: "zh-CN", aspectRatio: "16:9" },
);
console.log("完成,用掉积分:", job.creditsUsed, "退回:", job.creditsRefunded);
} catch (e) {
if (e instanceof JobFailedError) console.error("转换失败:", e.code, e.message);
else if (e instanceof Image2PPTError) console.error("请求出错:", e.statusCode, e.code, e.message);
else throw e;
}
分步控制(submit / wait / download)、账户查询(account)和各异常的完整说明见 GitHub 仓库的 README 与示例。
错误码总表
message 是给人读的,语言只跟着请求的 Accept-Language 走:说中文就给中文,不带这个头或者说别的语言一律给英文。浏览器 cookie、界面语言头这些都不影响它——从浏览器里调这个接口也一样,你声明什么就是什么。要在代码里分支请用 code,它不随语言变。
| HTTP | code | 出现场景 |
|---|---|---|
| 401 | INVALID_API_KEY |
密钥无效或缺失(所有端点)。 |
| 400 | NO_FILES |
没有带任何文件,或 urls 为空(提交)。 |
| 400 | INVALID_FILE |
文件格式不支持或单文件超 35MB(提交)。 |
| 400 | INVALID_PDF |
PDF 无法读取或解析(提交)。 |
| 400 | INVALID_ASPECT_RATIO |
画幅比例不认识,用 auto 或 16:9、4:3(提交)。 |
| 400 | TOO_MANY_SLIDES |
总页数或选中的页数超过 50(提交)。 |
| 400 | INVALID_PAGES |
pages 写法错误(提交)。 |
| 400 | PAGES_OUT_OF_RANGE |
pages 里的页码为 0 或超过总页数,message 写明总页数(提交)。 |
| 400 | INVALID_CALLBACK_URL |
callbackUrl 不是 https、超过 2048 字符、带用户名密码,或不是公网地址(提交)。 |
| 400 | INVALID_IDEMPOTENCY_KEY |
Idempotency-Key 不是 1–255 个可打印 ASCII 字符(提交)。 |
| 400 | INVALID_JSON |
请求体不是合法 JSON,或不是 JSON 对象(按链接提交)。 |
| 400 | INVALID_PARAMETER |
JSON 字段类型不对或超过 50 条链接(按链接提交);limit、cursor、createdFrom、createdTo 不合法(任务列表)。 |
| 400 | INVALID_URL |
链接格式不对、不是 https、带用户名密码,或指向内网,带 index(按链接提交)。 |
| 400 | URL_FETCH_FAILED |
链接下载失败:无法解析、对方非 2xx、超时、重定向超过 4 次或降级到 http,带 index。可能是临时故障,可重试(按链接提交)。 |
| 400 | PAGE_RATE_EXCEEDED |
单次提交页数就超过每分钟提交上限,永远排不进窗口(提交)。 |
| 400 | UPLOAD_ABORTED |
上传中途断开,请求体没收完(提交)。重试即可;反复出现多半是这次提交太大,见 PAYLOAD_TOO_LARGE。 |
| 400 | MALFORMED_UPLOAD |
请求体不是合法的 multipart/form-data(提交)。客户端拼装问题,重试不会好。 |
| 402 | INSUFFICIENT_CREDITS |
可用积分不足,或余额为 0(提交)。 |
| 403 | API_KEY_REQUIRED |
缺少有效的 API key(提交)。 |
| 403 | ACCOUNT_DELETED |
账号已删除(提交)。 |
| 413 | PAYLOAD_TOO_LARGE |
同一请求的文件内容合计超过 90MB(提交)。 |
| 429 | RATE_LIMITED |
触发限流,带 Retry-After 头(提交)。轮询状态不限流;任务列表目前不限流,但也请照样处理 429。 |
| 404 | JOB_NOT_FOUND |
任务号不存在、不属于本账户,或已在网站上被删除(查询、取消、下载)。 |
| 409 | IDEMPOTENCY_KEY_IN_PROGRESS |
同一个 Idempotency-Key 的上一个请求还在处理,带 Retry-After 头(提交)。 |
| 409 | JOB_ALREADY_FINISHED |
任务已经结束,或已进入收尾、取消不再能改变结果(取消)。 |
| 409 | NOT_READY |
任务未完成就来下载(下载)。 |
| 422 | IDEMPOTENCY_KEY_MISMATCH |
同一个 Idempotency-Key 已用在内容不同的请求上(提交)。 |
| — | JOB_CANCELLED |
取消已结算且没有可交付页面(任务状态里的 error.code)。 |
| 410 | OUTPUT_EXPIRED |
成品已过保留期被清理(下载)。 |
| 416 | RANGE_NOT_SATISFIABLE |
下载续传范围超出成品文件大小(下载)。 |
| 5xx | JOB_CANCEL_FAILED、STORAGE_FAILED 等 |
服务端处理出错,稍后重试;反复出现请联系我们。 |