Docs

Download PDFs and files

Download an entry's PDFs and uploaded files, keep a local copy of everything a form collects, and get all PDFs as one ZIP.

This guide shows how to get the files out of Tepez. You will download the PDF an entry archived when it was submitted, render a fresh PDF, download the files people uploaded, keep a folder of every PDF and file for a form up to date, and download all of a form's PDFs as one ZIP file.

Files and PDFs explains the file values and PDFs this guide downloads.

The PDF of one entry

GET /entry/{entryId} returns the entry with its archivedRenders, one item per archived PDF. Download each item's file.url, and save it under its file.name:

curl -s https://api.tepez.co.il/v1.0/entry/<entryId> \
  -H "x-api-key: $TEPEZ_API_KEY" -o entry.json

# One line per archived PDF: its link and its file name
jq -r '.archivedRenders[].file | [.url, .name] | @tsv' entry.json |
  while IFS=$'\t\r' read -r url name; do
    curl -s -f -o "$name" "$url" && echo "saved $name"
  done

The entry, trimmed to its archived PDF:

{
  "id": "<entryId>",
  "status": "submitted",
  "archivedRenders": [
    {
      "id": "<renderId>",
      "action": "<submitActionId>",
      "type": "template",
      "template": { "type": "auto" },
      "file": {
        "name": "Job application - Dana Levi.pdf",
        "size": 212457,
        "type": "application/pdf",
        "s3Key": "<formId>/<entryId>/<renderId>/Job application - Dana Levi.pdf",
        "s3Bucket": "<bucket>",
        "url": "https://<bucket>.s3.eu-west-1.amazonaws.com/<formId>/<entryId>/<renderId>/Job%20application%20-%20Dana%20Levi.pdf?<presigned-query>"
      }
    }
  ]
}
saved Job application - Dana Levi.pdf
  • The link is archivedRenders[].file.url. The item itself has no url.
  • The link needs no API key. Don't send your key to it.
  • file.name is the name the form gives its PDF, often built from the entry's values. It already avoids the characters file systems reject, but two entries can have PDFs with the same name. Save each entry's files in a folder of its own, as the script below does.
  • The PDF is stored as it is, not compressed. Save the bytes you receive.

When a form archives several PDFs

Most forms archive one PDF per submission. A form with several PDF layouts can also archive one PDF per layout, after the PDF that combines them. This entry has three:

"archivedRenders": [
  {
    "id": "<renderId>",
    "action": "<submitActionId>",
    "type": "concatenation",
    "file": { "name": "Full file - Noa Bar.pdf", "size": 16624, "url": "…" }
  },
  {
    "id": "<renderId2>",
    "action": "<submitActionId>",
    "type": "template",
    "template": { "name": "application", "label": "Application", "type": "auto" },
    "file": { "name": "Application - Noa Bar.pdf", "size": 15103, "url": "…" }
  },
  {
    "id": "<renderId3>",
    "action": "<submitActionId>",
    "type": "template",
    "template": { "name": "references", "label": "References", "type": "auto" },
    "file": { "name": "References - Noa Bar.pdf", "size": 15103, "url": "…" }
  }
]

Tell them apart by type and template:

ItemWhat it holds
type: "concatenation"One PDF that combines every layout that applies to the entry.
type: "template"One layout, except that the first item of an action can hold the combined PDF under the first layout's name (see below). template.name identifies the layout and template.label is its display name. Each is missing when the layout doesn't define it.
  • The whole document: take the first item of the action. It is the form's main PDF, the one the form's emails attach.
  • One document of the set: the PDFs per layout follow the first item. Take the one whose template.name is that layout's name. Skip the first item: on some forms it carries the first layout's name while it holds the combined PDF.
  • The latest PDF: a form can also archive PDFs on save or on an edit, and each time adds items with that action's id in action. Items come in the order they were archived, so the newest are last. Take the items whose action equals the action of the last item. An entry's history explains actions.

A fresh PDF

POST /entry/{entryId}/render renders the entry now, from its current data and the form's current layout. Use it when the form archives no PDF, or when you need the PDF in today's layout:

url=$(curl -s -X POST https://api.tepez.co.il/v1.0/entry/<entryId>/render \
  -H "x-api-key: $TEPEZ_API_KEY" | jq -j .url)
curl -s -f -o entry.pdf "$url"
{
  "url": "https://<bucket>.s3.eu-west-1.amazonaws.com/<uuid>/Full%20file%20-%20Dana%20Levi.pdf?<presigned-query>"
}

The link lasts about 2 days, so download the PDF right away rather than storing the link. A render adds nothing to the entry. Render a PDF on demand has the details.

Uploaded files

Files people upload sit in the entry's data, as file values at the field's data path. A file field can be anywhere: at the top, inside a section, or in every row of a repeating group. Rather than code each path, walk data and pick out every file value. A file value is an object with s3Key and url.

curl -s https://api.tepez.co.il/v1.0/entry/<entryId> \
  -H "x-api-key: $TEPEZ_API_KEY" -o entry.json

# One line per uploaded file, at any depth: its data path, link and file name
jq -r '.data | paths(objects and has("s3Key") and has("url")) as $p
  | getpath($p)
  | [($p | map(if type == "number" then "[\(.)]" else ".\(.)" end) | join("") | ltrimstr(".")),
     .url, .name] | @tsv' entry.json |
  while IFS=$'\t\r' read -r path url name; do
    curl -s -f -o "$name" "$url" && echo "$path $name"
  done

For an entry with a CV and two references, each with a recommendation letter, it prints:

cv[0] cv.png
references[0].letter[0] Letter from Yossi.png
references[1].letter[0] letter.png

The entry's data, trimmed to its file values:

{
  "fullName": "Dana Levi",
  "cv": [
    {
      "name": "cv.png",
      "size": 69,
      "type": "image/png",
      "s3Key": "form/<formId>/<uuid>/cv.png",
      "s3Bucket": "<bucket>",
      "url": "https://<bucket>.s3.eu-west-1.amazonaws.com/form/<formId>/<uuid>/cv.png?<presigned-query>"
    }
  ],
  "references": [
    {
      "name": "Yossi Cohen",
      "letter": [{ "name": "Letter from Yossi.png", "size": 69, "s3Key": "…", "url": "…" }]
    },
    {
      "name": "Rina Katz",
      "letter": [{ "name": "letter.png", "size": 69, "s3Key": "…", "url": "…" }]
    }
  ]
}
  • Keep the file's name. The link's path ends with the name too, but encoded.
  • name isn't always safe to save as is. Two fields can hold files of the same name, and a file value written through the API can carry any name, such as CV: Yael/2026.png. The script in the next section makes each name safe and unique.
  • Uploaded files are stored as they were uploaded, not compressed.

Everything for a form

This script keeps a folder with every archived PDF and uploaded file of a form's submitted entries, one subfolder per entry. Run it on a schedule. Each run:

  1. Lists the form's submitted entries whose changedAt is later than the last run, page by page with limit and the Link header, as in Page through the results.
  2. Saves each entry's archived PDFs and uploaded files into downloads/<entryId>/, with names every file system accepts.
  3. Skips a file that is already on disk with the right size, so running it again downloads only what's new.
  4. Saves the latest changedAt it saw in downloads/last-run.json, for the next run. When a download fails, it keeps the old time, so the next run tries again.

Each run starts a minute before the saved time, which catches entries that changed in the last minute of the previous run. If a run takes longer than that, start further back. Skipping files already on disk makes the overlap harmless. Poll for changes explains changedAt polling.

// Save the archived PDFs and uploaded files of a form's submitted entries,
// one folder per entry. Run it again to fetch only what changed since.
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';

const FORM_ID = '<formId>';
const OUT_DIR = 'downloads';
const STATE_FILE = path.join(OUT_DIR, 'last-run.json');

// The form's submitted entries that changed after `since`, page by page
async function* listEntries(since) {
  const params = new URLSearchParams({
    query: JSON.stringify({ form: FORM_ID, status: 2, changedAt: { $gt: since } }),
    fields: JSON.stringify(['id', 'changedAt', 'archivedRenders', 'data']),
    limit: '100',
  });
  let url = `https://api.tepez.co.il/v1.0/entry?${params}`;
  while (url) {
    const res = await fetch(url, { headers: { 'x-api-key': process.env.TEPEZ_API_KEY } });
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    yield* (await res.json()).data;
    // The Link header holds the next page, when there is one
    url = res.headers.get('link')?.match(/<([^>]+)>;\s*rel="?next"?/)?.[1];
  }
}

// Every uploaded file in an entry's data, at any depth, with its data path
function findFiles(value, at = '') {
  if (Array.isArray(value)) return value.flatMap((item, i) => findFiles(item, `${at}[${i}]`));
  if (value === null || typeof value !== 'object') return [];
  if (typeof value.s3Key === 'string' && typeof value.url === 'string') {
    return [{ path: at, file: value }];
  }
  return Object.entries(value).flatMap(([key, v]) => findFiles(v, at ? `${at}.${key}` : key));
}

// A file name every file system accepts, unique within the entry's folder
function safeName(name, used) {
  let clean = name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').replace(/^[\s.]+|[\s.]+$/g, '') || 'file';
  if (/^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(clean)) clean = `_${clean}`; // reserved on Windows
  const ext = path.extname(clean);
  const base = clean.slice(0, clean.length - ext.length).slice(0, 100);
  clean = base + ext;
  for (let i = 2; used.has(clean.toLowerCase()); i++) clean = `${base} (${i})${ext}`;
  used.add(clean.toLowerCase());
  return clean;
}

// Download a file, unless a file of the same size is already on disk
async function download(url, size, target) {
  const existing = await stat(target).catch(() => null);
  if (existing?.size === size) return 'skipped';
  const res = await fetch(url);
  if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
  await writeFile(`${target}.part`, Buffer.from(await res.arrayBuffer()));
  await rename(`${target}.part`, target); // a cut download never looks complete
  return 'saved';
}

await mkdir(OUT_DIR, { recursive: true });
const state = JSON.parse(await readFile(STATE_FILE, 'utf8').catch(() => '{}'));
let since = state.since ?? '1970-01-01T00:00:00.000Z';
// Start a minute early, so a change made during the last run isn't missed
const from = new Date(Date.parse(since) - 60_000).toISOString();
let failed = 0;

for await (const entry of listEntries(from)) {
  const dir = path.join(OUT_DIR, entry.id);
  await mkdir(dir, { recursive: true });
  const used = new Set();
  const files = [
    ...(entry.archivedRenders ?? []).map((render) => render.file),
    ...findFiles(entry.data).map(({ file }) => file),
  ];
  for (const file of files) {
    const target = path.join(dir, safeName(file.name, used));
    try {
      console.log(await download(file.url, file.size, target), target);
    } catch (err) {
      failed++;
      console.error('failed', target, err.message);
    }
  }
  if (entry.changedAt > since) since = entry.changedAt;
}

// Move the cursor only when everything was saved, so failures are retried
if (failed) process.exitCode = 1;
else await writeFile(STATE_FILE, JSON.stringify({ since }));
console.log(failed ? `${failed} failed` : `done, next run from ${since}`);

The first run saves everything:

saved downloads/<entryId>/Job application - Dana Levi.pdf
saved downloads/<entryId>/cv.png
saved downloads/<entryId>/Letter from Yossi.png
saved downloads/<entryId>/letter.png
saved downloads/<entryId2>/Full file - Noa Bar.pdf
saved downloads/<entryId2>/Application - Noa Bar.pdf
saved downloads/<entryId2>/References - Noa Bar.pdf
done, next run from 2026-10-01T11:09:09.851Z

After one more submission, the next run saves only the new entry's files. It skips the files of an entry that changed within the minute before the saved time:

skipped downloads/<entryId2>/Full file - Noa Bar.pdf
skipped downloads/<entryId2>/Application - Noa Bar.pdf
skipped downloads/<entryId2>/References - Noa Bar.pdf
saved downloads/<entryId3>/Full file - Yael Shani.pdf
saved downloads/<entryId3>/Application - Yael Shani.pdf
saved downloads/<entryId3>/References - Yael Shani.pdf
saved downloads/<entryId3>/CV- Yael-2026.png
saved downloads/<entryId3>/letter.png
saved downloads/<entryId3>/letter (2).png
done, next run from 2026-10-01T11:12:35.941Z

The new entry shows the name handling: the file named CV: Yael/2026.png is saved as CV- Yael-2026.png, and the second letter.png as letter (2).png.

  • An entry's PDFs come first, so they keep their names when an uploaded file has the same one.
  • The script saves every archived PDF of the entry. To keep only the main one, take the first item of the last action, as When a form archives several PDFs explains.
  • A file replaced after the submit, with a different size, is downloaded again over the old copy.

All PDFs as one ZIP

POST /entry/export with format=pdf renders every matching entry and returns a link to one ZIP file with a PDF per entry:

url=$(curl -s -X POST "https://api.tepez.co.il/v1.0/entry/export?format=pdf" \
  -H "x-api-key: $TEPEZ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"form": "<formId>", "status": 2}}' | jq -j .url)
curl -s -f -o entries.zip "$url"
unzip -l entries.zip
{
  "url": "https://<bucket>.s3.eu-west-1.amazonaws.com/<uuid>/Job%20application.zip?<presigned-query>"
}

The ZIP holds:

Full file - Dana Levi.pdf
Full file - Noa Bar.pdf
Full file - Yael Shani.pdf

Compare it with the script above:

The ZIP exportThe script
The PDFsRendered now, from each entry's current data and the form's current layout. Dana's PDF above is in today's layout, not the one archived when she submitted.The archived PDFs, exactly as they were at the time.
Uploaded filesNot included.Included.
File namesThe name each PDF would get, so the entry id isn't in it. Two PDFs with the same name become name.pdf and name (1).pdf.One folder per entry id.
RunsOne request for everything that matches, every time.Only what changed since the last run.

Prefer the ZIP for a one-off bulk download, such as handing a set of PDFs to someone. Prefer the script to keep a copy of the files in your system. Export entries to a file covers the export's options.

LinkWhereValid for
An uploaded fileurl of a file value in datayears
An archived PDFarchivedRenders[].file.urlyears
A rendered PDFurl from POST /entry/{entryId}/renderabout 2 days
An export: CSV, Excel or a ZIP of PDFsurl from POST /entry/exportabout 2 days
  • None of these links needs an API key. Anyone who has a link can download the file. Store links as carefully as the files themselves, and don't write them to logs or send them to people who shouldn't see the file.
  • A link stops working when its file is deleted. Deleting an entry deletes its archived PDFs, and its uploaded files unless the form is set to keep them. The links then answer 403. GET /entry/{entryId} still returns a deleted entry, with status: "deleted" and its links, so check the status before you download. See Delete an entry.
  • Keep your own copy of files you must keep. A link that lasts years still depends on the entry. Download the file, as the script does, rather than storing only the link.

Limits lists every link lifetime.

Troubleshooting

ResponseCause and fix
403 AccessDenied from a file or PDF linkThe link expired, was changed, or its file was deleted. A render or export link lasts about 2 days: render or export again. Use each url exactly as the API returned it, without decoding or cutting it. An archived PDF or uploaded file of a deleted entry is gone: read the entry and check its status.
An empty archivedRenders on a submitted entryThe form doesn't archive PDFs, the entry was submitted before the form started to, or it was edited to submitted rather than submitted (see Entries). Render a fresh PDF instead.
No archivedRenders or data in a list responsefields leaves them out. Name them: ["id","changedAt","archivedRenders","data"].
The ZIP won't openThe file isn't a ZIP: the export request failed and its JSON error was saved, or the link expired and its XML error was saved. Check the status of both requests before you write the file, as curl -f and the code above do.
The ZIP has no PDF for some entriesThe export includes only the entries query matches. Deleted entries are never included.
Writing a file fails with an invalid name, or a file overwrites anotherAn uploaded file's name can hold characters a file system rejects, and two files can share a name. Pass each name through a function like safeName in the script.
The script downloads the same files on every runIt can't save last-run.json, or a download fails each time. A failed download keeps the cursor so the next run retries. Fix the failure the output names.

Errors explains the error format and lists more messages.

Every response from the API carries an x-tepez-request-id header. Quote it when you contact us.

Email support with the request id

Question about this page? Email support@tepez.co.il

On this page