Appearance
Are you an LLM? You can read better optimized documentation at /docs/guide/migrating.md for this page in Markdown format
Migrating from the Lambda URLs
Until now each utility had its own API Gateway URL, its own HS256 JWT and its own request and response shape. ServiceLabs replaces all of them with one base URL, one API key and one job shape. This page maps every old call to its new form.
The old URLs keep working until they're retired, so services can move one at a time.
What changes for every caller
| Before | After | |
|---|---|---|
| URL | https://…execute-api…amazonaws.com/prod/<path> — one per utility | https://api.servicelabs.dev/v1/utilities/<slug> (sync) or …/<slug>/jobs (async) |
| Auth | Authorization: Bearer <HS256 JWT> signed with a shared secret (a different one for some utilities) | Authorization: Bearer du_live_… — one API key per service, revocable, scoped |
| Body | Utility-specific fields, several aliases each | { "input": { … }, "metadata": { … } } — one documented name per field |
| Response | Utility-specific (pdf.url, zipUrl, files[].https_url, pages[], xlsx_url) | Always a job; files in result.files[] |
| Errors | { "success": false, "error": "…" } (or { "error", "message" }) | Before a job exists: { "error": { "code", "message", "details" } }. For a run: the job with status: "failed" and error: { code, message, upstreamStatus } — see error codes |
| File URLs | S3 (finnoto-data), public-read or presigned | Cloudflare R2 — https://files.servicelabs.dev/<utility>/<date>/<job_id>/<file> |
| Time limit | 29 s (API Gateway) even though the functions could run 15 min | 90 s sync, then 202 + job id; async jobs run the full 15 min |
Validation is stricter
Inputs are validated at the gateway before anything runs. Unknown fields are rejected (400), aliases are gone, and a few inputs the Lambdas used to accept blindly are now refused (see each utility below). Test every call path against the new API before switching.
Utility by utility
HTML → PDF
Before (POST /html-pdf) | After (POST /v1/utilities/html-pdf) |
|---|---|
html_url / htmlUrl / s3_url / s3Url | input.url |
pdf_options / pdfOptions | input.pdfOptions (same keys: format, width, height, orientation, landscape, scale, preferCSSPageSize, printBackground, margin) |
pdf.url | result.files[0].url |
pdf.key, pdf.filename, pdf.size | result.files[0].key, .name, .size |
source | dropped — your input is on the job (console) |
Newly refused: an unknown format (only Letter, Legal, Tabloid, Ledger, A0–A6), format together with width/height, width without height, unknown keys in pdfOptions or margin.
js
const { data } = await axios.post(
`${HTML_PDF_URL}/html-pdf`,
{ html_url: url, pdf_options: { format: 'A4' } },
{ headers: { Authorization: `Bearer ${jwt}` } },
)
const pdfUrl = data.pdf.urljs
const res = await fetch('https://api.servicelabs.dev/v1/utilities/html-pdf', {
method: 'POST',
headers: { Authorization: `Bearer ${DU_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ input: { url, pdfOptions: { format: 'A4' } } }),
})
const job = await res.json()
if (job.status !== 'succeeded') throw new Error(job.error?.message ?? `HTTP ${res.status}`)
const pdfUrl = job.result.files[0].urlZip files
Before (POST /zip-files) | After (POST /v1/utilities/zip-files) |
|---|---|
Body as a bare array [{url, fileName}], or { files: [...] } | input.files: [{ url, fileName }] |
zipUrl | result.files[0].url |
Newly refused: more than 500 files, duplicate fileNames, a fileName that starts with / or contains .. (those used to escape the archive's folder), non-http(s) URLs.
js
const { data } = await axios.post(`${ZIP_URL}/zip-files`, files, {
headers: { Authorization: `Bearer ${jwt}` },
})
const zipUrl = data.zipUrljs
const job = await du('zip-files', { files }) // files: [{ url, fileName }]
const zipUrl = job.result.files[0].urlUnzip archive
Before (POST /unzip-files) | After (POST /v1/utilities/unzip-files) |
|---|---|
s3_url / s3Url / url / zip_url | input.url |
files[].https_url | result.files[].url |
files[].original_path | result.files[].path |
files[].s3_key, files[].size | result.files[].key, .size (now an R2 key) |
file_count | result.fileCount |
source, destination | dropped |
js
const { data } = await axios.post(
`${UNZIP_URL}/unzip-files`,
{ s3_url: zipUrl },
{ headers: { Authorization: `Bearer ${jwt}` } },
)
for (const f of data.files) save(f.original_path, f.https_url)js
const job = await du('unzip-files', { url: zipUrl })
for (const f of job.result.files) save(f.path, f.url)Split PDF
Before (POST /split-pdf) | After (POST /v1/utilities/split-pdf) |
|---|---|
url / pdf_url / pdfUrl / s3_url / s3Url | input.url |
pages[].https_url | result.files[].url |
pages[].page_number | result.files[].pageNumber |
pages[].s3_key, pages[].size | result.files[].key, .size |
page_count | result.pageCount |
Limits are unchanged: ≤ 150 MB, ≤ 500 pages, no password-protected PDFs (those fail as upstream_rejected).
js
const { data } = await axios.post(
`${SPLIT_URL}/split-pdf`,
{ url },
{ headers: { Authorization: `Bearer ${jwt}` } },
)
const pages = data.pages.map((p) => p.https_url)js
const job = await du('split-pdf', { url })
const pages = job.result.files.map((f) => f.url) // ordered by pageNumberXLS → XLSX
Before (POST /convert) | After (POST /v1/utilities/xls-to-xlsx) |
|---|---|
url | input.url (http(s) only, as before) |
xlsx_url (presigned, 1 hour) | result.files[0].url (R2) |
s3_key | result.files[0].key |
js
const { data } = await axios.post(
`${XLS_URL}/convert`,
{ url },
{ headers: { Authorization: `Bearer ${jwt}` } },
)
const xlsxUrl = data.xlsx_urljs
const job = await du('xls-to-xlsx', { url })
const xlsxUrl = job.result.files[0].urlThe du() helper used above:
js
async function du(slug, input) {
const res = await fetch(`https://api.servicelabs.dev/v1/utilities/${slug}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DU_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ input }),
})
let job = await res.json()
if (res.status === 202) job = await waitForJob(job.id) // slower than 90 s — see the Quickstart
if (job.status !== 'succeeded') throw new Error(job.error?.message ?? `HTTP ${res.status}`)
return job
}waitForJob is in the Quickstart. For long or large inputs, switch to the async route and a callback instead.
Things to check in your code
- HTTP status handling. Old callers often checked only
success. Now:200success,202still running,4xx/5xxfailure — and failures still carry the job id. - Stored URLs. If you persisted S3 URLs from the old responses, they keep working as long as those objects exist; new results are R2 URLs with their own retention.
- Timeouts. Give sync calls a client timeout of at least 100 s, or go async.
- Hard-coded JWT minting. Remove it — and the shared secret from your service's config.
Cutover checklist
- One API key per calling service — Console → API keys, scoped to the utilities it uses. Name it after the service.
- Switch the service — new base URL,
Authorization: Bearer du_live_…, the{ input }envelope,result.files[]. Deploy. - Watch it land — Console → Usage: the new key should show traffic and a healthy success rate; failures are in Jobs with the utility's own error message.
- Confirm the old URL has gone quiet for that service (API Gateway's request count in CloudWatch).
- Repeat for the next service.
- Retire the old edges once every service is moved: the Lambdas' API Gateway stages are removed through each function's SAM template (remove the API event and the API resource, then
sam deploy) — not by hand, so CloudFormation stays in sync. The functions stay; only the gateway can invoke them.