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"
doneThe 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 nourl. - The link needs no API key. Don't send your key to it.
file.nameis 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:
| Item | What 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.nameis 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
idinaction. Items come in the order they were archived, so the newest are last. Take the items whoseactionequals theactionof 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"
doneFor 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.pngThe 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. nameisn'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 asCV: 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:
- Lists the form's submitted entries whose
changedAtis later than the last run, page by page withlimitand theLinkheader, as in Page through the results. - Saves each entry's archived PDFs and uploaded files into
downloads/<entryId>/, with names every file system accepts. - Skips a file that is already on disk with the right size, so running it again downloads only what's new.
- Saves the latest
changedAtit saw indownloads/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.851ZAfter 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.941ZThe 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.pdfCompare it with the script above:
| The ZIP export | The script | |
|---|---|---|
| The PDFs | Rendered 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 files | Not included. | Included. |
| File names | The 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. |
| Runs | One 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.
Link lifetimes and security
| Link | Where | Valid for |
|---|---|---|
| An uploaded file | url of a file value in data | years |
| An archived PDF | archivedRenders[].file.url | years |
| A rendered PDF | url from POST /entry/{entryId}/render | about 2 days |
| An export: CSV, Excel or a ZIP of PDFs | url from POST /entry/export | about 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, withstatus: "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
| Response | Cause and fix |
|---|---|
403 AccessDenied from a file or PDF link | The 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 entry | The 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 response | fields leaves them out. Name them: ["id","changedAt","archivedRenders","data"]. |
| The ZIP won't open | The 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 entries | The export includes only the entries query matches. Deleted entries are never included. |
| Writing a file fails with an invalid name, or a file overwrites another | An 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 run | It 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.
Question about this page? Email support@tepez.co.il