Zip files
Send a list of URLs and the names you want them to have inside the archive. Get back one .zip.
SyncPOST /v1/utilities/zip-filesWaits up to 90 s for the finished job.
AsyncPOST /v1/utilities/zip-files/jobsAnswers 202 with the job id at once.
What it’s for
- Download-all linksBundle a month of invoices, or a ticket’s attachments, into one link for whoever asked.
-
Hand-over packsGive an auditor or a partner one archive, with folders like
invoices/jan.pdf. - Gathering from many placesFiles from different hosts and buckets end up side by side in one download.
-
Your folder structureEach
fileNameis the entry’s path inside the zip, so you decide the layout.
How it runs
-
Check1–500 entries, unique relative names,
http(s)URLs only. Anything else is a400before a job exists. - DownloadFetches each URL one after another and holds it in memory. One failed download fails the job.
- PackCompresses everything into a single .zip at zlib level 9.
-
StoreOne
application/zipfile on the job.
Very large inputs, hundreds of MB in total, can run out of memory and fail with
502. Split big exports across several jobs, and use the async route for
more than a handful of files.
Input
Send these fields as input in the request body. Anything not listed is
refused with 400.
| Field | Type | Required or optional | Accepts |
|---|---|---|---|
files |
array |
Required | 1–500 entries. fileName values must be unique. |
files[].url
|
string |
Required |
A valid http:// or https:// URL. No
s3:// here. Where to download the entry from.
|
files[].fileName
|
string |
Required |
Path of the entry inside the zip, e.g. "invoices/jan.pdf". 1–255
characters, relative: no leading / and no
.. segment.
|
The fileName rules close the zip-slip hole: no entry can escape its
folder when the archive is extracted.
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[].name
|
A generated yyyymmddhhmmss_<10 hex>.zip name. |
files[].contentType
|
application/zip |
Example
curl https://api.servicelabs.dev/v1/utilities/zip-files \
-H "Authorization: Bearer $DU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": {
"files": [
{
"url": "https://example.com/a.pdf",
"fileName": "a.pdf"
},
{
"url": "https://example.com/b.pdf",
"fileName": "docs/b.pdf"
}
]
}
}'
# 1. Start the job. Answers 202 straight away.
curl https://api.servicelabs.dev/v1/utilities/zip-files/jobs \
-H "Authorization: Bearer $DU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: zip-files-0001" \
-d '{
"input": {
"files": [
{
"url": "https://example.com/a.pdf",
"fileName": "a.pdf"
},
{
"url": "https://example.com/b.pdf",
"fileName": "docs/b.pdf"
}
]
},
"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/zip-files`, {
method: 'POST',
headers,
body: JSON.stringify({ input: {
files: [
{ url: 'https://example.com/a.pdf', fileName: 'a.pdf' },
{ url: 'https://example.com/b.pdf', fileName: 'docs/b.pdf' },
],
} }),
})
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": "zip-files",
"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://…/20260910173009_8f3a1c2b4d.zip",
"name": "20260910173009_8f3a1c2b4d.zip",
"size": 2048733,
"contentType": "application/zip"
}
]
},
"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 zip-files",
"details": [
{
"path": "input.files.0.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 | 900 s (15 min) |
| Entries | 1–500 per job |
| Entry names | 1–255 characters, relative, unique |
| Sources |
http:// or https://, reachable from the internet
|
| Downloads | One after another, held in memory |
| Compression | zlib level 9 |
| 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 |
|---|---|---|---|
An empty list, more than 500 files, a duplicate or unsafe
fileName, a non-http(s) url
|
400 | invalid_request |
No job |
One of the downloads failed. The download error is in
error.message.
|
502 | upstream_error |
Failed job |
| Out of memory on large inputs | 502 | upstream_error |
Failed job |
| Ran past 900 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
- Zip files guideEvery option, with worked examples.
-
runZipFiles
POST /v1/utilities/zip-files -
createZipFilesJob
POST /v1/utilities/zip-files/jobs -
getJob
GET /v1/jobs/{id} - JobsStatuses, timings, retries and cancelling.
- CallbacksVerifying the signature on a finished job.
Also in service
Try Zip files on your own file.
The console’s playground runs zip-files against any URL you give it and shows the job it returns.