Skip to content

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

BeforeAfter
URLhttps://…execute-api…amazonaws.com/prod/<path> — one per utilityhttps://api.servicelabs.dev/v1/utilities/<slug> (sync) or …/<slug>/jobs (async)
AuthAuthorization: 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
BodyUtility-specific fields, several aliases each{ "input": { … }, "metadata": { … } } — one documented name per field
ResponseUtility-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 URLsS3 (finnoto-data), public-read or presignedCloudflare R2 — https://files.servicelabs.dev/<utility>/<date>/<job_id>/<file>
Time limit29 s (API Gateway) even though the functions could run 15 min90 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 / s3Urlinput.url
pdf_options / pdfOptionsinput.pdfOptions (same keys: format, width, height, orientation, landscape, scale, preferCSSPageSize, printBackground, margin)
pdf.urlresult.files[0].url
pdf.key, pdf.filename, pdf.sizeresult.files[0].key, .name, .size
sourcedropped — 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.url
js
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].url

Zip files

Before (POST /zip-files)After (POST /v1/utilities/zip-files)
Body as a bare array [{url, fileName}], or { files: [...] }input.files: [{ url, fileName }]
zipUrlresult.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.zipUrl
js
const job = await du('zip-files', { files }) // files: [{ url, fileName }]
const zipUrl = job.result.files[0].url

Unzip archive

Before (POST /unzip-files)After (POST /v1/utilities/unzip-files)
s3_url / s3Url / url / zip_urlinput.url
files[].https_urlresult.files[].url
files[].original_pathresult.files[].path
files[].s3_key, files[].sizeresult.files[].key, .size (now an R2 key)
file_countresult.fileCount
source, destinationdropped
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 / s3Urlinput.url
pages[].https_urlresult.files[].url
pages[].page_numberresult.files[].pageNumber
pages[].s3_key, pages[].sizeresult.files[].key, .size
page_countresult.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 pageNumber

XLS → XLSX

Before (POST /convert)After (POST /v1/utilities/xls-to-xlsx)
urlinput.url (http(s) only, as before)
xlsx_url (presigned, 1 hour)result.files[0].url (R2)
s3_keyresult.files[0].key
js
const { data } = await axios.post(
  `${XLS_URL}/convert`,
  { url },
  { headers: { Authorization: `Bearer ${jwt}` } },
)
const xlsxUrl = data.xlsx_url
js
const job = await du('xls-to-xlsx', { url })
const xlsxUrl = job.result.files[0].url

The 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: 200 success, 202 still running, 4xx/5xx failure — 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

  1. One API key per calling service — Console → API keys, scoped to the utilities it uses. Name it after the service.
  2. Switch the service — new base URL, Authorization: Bearer du_live_…, the { input } envelope, result.files[]. Deploy.
  3. 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.
  4. Confirm the old URL has gone quiet for that service (API Gateway's request count in CloudWatch).
  5. Repeat for the next service.
  6. 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.

ServiceLabs · a Finnoto company