XLS to XLSX
Send a legacy .xls workbook. Get back an .xlsx with every sheet carried over, cell by cell.
SyncPOST /v1/utilities/xls-to-xlsxWaits up to 90 s for the finished job.
AsyncPOST /v1/utilities/xls-to-xlsx/jobsAnswers 202 with the job id at once.
What it’s for
- Exports from older systemsSoftware that still writes .xls can feed tools that only read .xlsx.
- Before parsingLibraries that handle .xlsx well get a file they understand.
- Old archivesBring a folder of old workbooks forward, one call per file.
- Quick, in lineTypical workbooks convert in well under a second, so the sync route fits.
How it runs
-
DownloadFetches the workbook from your
http(s)://URL. The download times out after 30 s. - ReadParses the binary BIFF format. A file that is already .xlsx, or a CSV renamed to .xls, fails.
- WriteCopies every sheet’s cell values into a new .xlsx. Formulas keep their last computed value.
- StoreOne .xlsx file on the job.
Values only. Formatting, charts, macros and merged-cell styling don’t come across.
Input
Send these fields as input in the request body. Anything not listed is
refused with 400.
| Field | Type | Required or optional | Accepts |
|---|---|---|---|
url |
string |
Required |
A valid http:// or https:// URL. No
s3:// for this utility. The legacy .xls workbook (binary BIFF) to
convert.
|
The request body
| Field | Required or optional | What it is |
|---|---|---|
input |
Required | The fields above. |
metadata |
Optional | Your own key–value pairs, returned on the job. At most 50 keys, 4 KB serialised. |
callbackUrl |
Optional | Async route only. The finished job is POSTed here, signed. Up to 2,048 characters. |
What comes back
| Field | Value |
|---|---|
files[].url
|
A link to the file. |
result.files
|
Exactly one file. |
files[].contentType
|
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
Example
curl https://api.servicelabs.dev/v1/utilities/xls-to-xlsx \
-H "Authorization: Bearer $DU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": {
"url": "https://example.com/report.xls"
}
}'
# 1. Start the job. Answers 202 straight away.
curl https://api.servicelabs.dev/v1/utilities/xls-to-xlsx/jobs \
-H "Authorization: Bearer $DU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: xls-to-xlsx-0001" \
-d '{
"input": {
"url": "https://example.com/report.xls"
},
"callbackUrl": "https://example.com/hooks/servicelabs"
}'
# 2. Collect it by id. Long-polls for up to 60 s.
curl "https://api.servicelabs.dev/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3?wait=60" \
-H "Authorization: Bearer $DU_API_KEY"
const API = 'https://api.servicelabs.dev/v1'
const headers = {
Authorization: `Bearer ${process.env.DU_API_KEY}`,
'Content-Type': 'application/json',
}
// Sync: holds the connection for up to 90 s
const res = await fetch(`${API}/utilities/xls-to-xlsx`, {
method: 'POST',
headers,
body: JSON.stringify({ input: { url: 'https://example.com/report.xls' } }),
})
if (res.status === 400) throw new Error((await res.json()).error.message)
let job = await res.json()
// Still running after 90 s? It carries on. Collect it by id.
while (job.status === 'queued' || job.status === 'running') {
job = await (await fetch(`${API}/jobs/${job.id}?wait=60`, { headers })).json()
}
if (job.status === 'succeeded') console.log(job.result.files.map((f) => f.url))
else console.error(job.error.code, job.error.message)
{
"id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3",
"object": "job",
"utility": "xls-to-xlsx",
"mode": "sync",
"status": "succeeded",
"createdAt": "2026-09-10T17:30:06.650Z",
"finishedAt": "2026-09-10T17:30:09.009Z",
"timings": { "queueMs": 0, "upstreamMs": 2140, "storageMs": 196, "totalMs": 2359 },
"result": {
"files": [
{
"url": "https://…/1789061409_report.xlsx",
"name": "1789061409_report.xlsx",
"size": 18342,
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
]
},
"error": null,
"links": {
"self": "/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3"
}
}
An example job. File links point at the stored output; download anything you need to keep.
{
"error": {
"code": "invalid_request",
"message": "input failed validation for xls-to-xlsx",
"details": [
{
"path": "input.url",
"message": "must be an http(s) URL"
}
]
}
}
Input is checked before anything runs. A 400 creates no job and costs nothing.
Limits
| Limit | Value |
|---|---|
| Runs up to | 300 s (5 min) |
| Input | Legacy .xls (binary BIFF) only |
| Kept | Cell values on every sheet; formulas as their last value |
| Not kept | Formatting, charts, macros, merged-cell styling |
| Download timeout | 30 s |
| Sync wait | 90 s, then 202 with the job id |
| Long-poll | Up to 60 s per GET /v1/jobs/{id}?wait= |
| Rate limit | Per key; 600 requests a minute unless set otherwise |
| Output files | Kept for a limited period; download what you need |
When it fails
A 400 means nothing ran. Everything else is a failed job with
status: "failed", an error code and the utility’s own message.
| Situation | Status | Code | Job |
|---|---|---|---|
url missing, or not http(s):// |
400 | invalid_request |
No job |
The source can’t be downloaded: Failed to download file |
422 | upstream_rejected |
Failed job |
Not a readable .xls: An error occurred during conversion: …
|
502 | upstream_error |
Failed job |
| Ran past 300 s | 504 | upstream_timeout |
Failed job |
Any call can also get 401 unauthorized, 403 forbidden (the
key isn’t allowed this utility) or 429 rate_limited. The full list is in
Conventions & errors.
Reference
- XLS to XLSX guideEvery option, with worked examples.
-
runXlsToXlsx
POST /v1/utilities/xls-to-xlsx -
createXlsToXlsxJob
POST /v1/utilities/xls-to-xlsx/jobs -
getJob
GET /v1/jobs/{id} - JobsStatuses, timings, retries and cancelling.
- CallbacksVerifying the signature on a finished job.
Also in service
Try XLS to XLSX on your own file.
The console’s playground runs xls-to-xlsx against any URL you give it and shows the job it returns.