A quiz can ask shoppers to upload files, for example a photo of their skin, a prescription or a picture of a room (see Customer file upload in Lantern quizzes). In the Lantern admin those files appear under Reporting > Uploaded Files, where you can view and download them one at a time. The Files API gives your own software the same access, so a developer can pull the files into another system: a patient record, a CRM, a design tool, an order management tool.
With the API you can:
• count the files your shoppers uploaded
• list them page by page, newest first
• narrow the list to one quiz, one quiz session, one shopper's email address, part of a file name, or a range of upload dates
• download any file
Files from every quiz session are included, whether the shopper finished the quiz or left before the results. The quiz completion webhook, by comparison, only fires when a shopper reaches the results page, so it never carries files from unfinished sessions. If you need every upload, use this API.
This article is written for developers. If you only want to look at the files, Reporting > Uploaded Files is the place.
Before you start
Base URL: every request goes to https://api.productfinder.app. Only HTTPS is accepted.
Format: requests are plain GET requests with parameters in the URL. Answers are JSON. Dates and times are in UTC, written in ISO 8601 (for example 2026-10-23T01:15:28.613Z).
Your API key: in the Lantern admin open Integrations. The card at the top of the left column reads Your API key is: click Reveal, then Copy. It is the same key you would use to connect Zapier. Keep it secret: anyone who has it can read every file your shoppers uploaded and every quiz result sent to Zapier.
Your store domain: the myshopify.com address of your store, for example your-store.myshopify.com. You can see it in the address bar of your Shopify admin. It stays the same when you connect a custom domain.
Authentication
Send two headers with every request:
Header | Value |
X-STORE | Your store domain, for example your-store.myshopify.com |
X-API-KEY | The API key from the Integrations page |
A request with a missing or wrong header gets the answer 401 Unauthorized. The key only opens the files of the store it belongs to: a request that names another store's domain with your key is refused.
A first request to check your setup:
curl "https://api.productfinder.app/v1/files?limit=1" \
-H "X-STORE: your-store.myshopify.com" \
-H "X-API-KEY: your-api-key"
If the headers are right you get a JSON answer with your newest file in items and the number of files you have in total. If you have no files yet, items is empty and total is 0.
Endpoints
Request | What it returns |
GET /v1/files | A page of files, newest first, with filters and the total count |
GET /v1/files/{id} | One file's details |
GET /v1/files/{id}/download | A redirect to a temporary link to the file itself |
List files
GET https://api.productfinder.app/v1/files
Returns the newest files first, 25 per page unless you set limit. All parameters are optional and can be combined; a file has to match every parameter you send.
Parameter | Type | Meaning |
session_id | UUID | Only files uploaded during one quiz session. It is the same session id your quiz completion webhook sends and Reporting shows. |
quiz_id | text | Only files uploaded through one quiz. This is the quiz's URL slug, the last part of the quiz address, for example skin-quiz. It is also the quiz_id in the completion webhook. |
text | Only files from sessions where the shopper entered this email address. Capitalization does not matter. | |
file_name | text | Only files whose name contains this text, for example photo matches my-photo.jpg. Capitalization does not matter. |
uploaded_after | date | Only files uploaded at or after this moment. ISO 8601 (2026-10-01T00:00:00Z) or a Unix timestamp in milliseconds. |
uploaded_before | date | Only files uploaded before this moment. Same formats. |
limit | number | Files per page, 1 to 100. Default 25. |
offset | number | Files to skip before the page starts. Default 0. For the second page of 25, send 25. |
Example: the files uploaded through the quiz skin-quiz in October 2026, 50 per page.
curl "https://api.productfinder.app/v1/files?quiz_id=skin-quiz&uploaded_after=2026-10-01T00:00:00Z&uploaded_before=2026-11-01T00:00:00Z&limit=50" \
-H "X-STORE: your-store.myshopify.com" \
-H "X-API-KEY: your-api-key"
The answer:
{
"total": 2,
"limit": 50,
"offset": 0,
"items": [
{
"id": "6f2c1c1e-3d7a-4b8e-9f10-2a6c8d4e5b71",
"file_name": "left-cheek.jpg",
"content_type": "image/jpeg",
"size": 281380,
"uploaded_at": "2026-10-23T01:15:28.613Z",
"download_url": "https://api.productfinder.app/v1/files/6f2c1c1e-3d7a-4b8e-9f10-2a6c8d4e5b71/download",
"session_id": "4b3f9a1e-7f5e-4c1a-9c2d-2b8e6f1a0d3c",
"quiz_name": "Skin Quiz",
"quiz_id": "skin-quiz",
"email": "[email protected]",
"session_started_at": "2026-10-23T01:14:02.000Z",
"session_completed_at": "2026-10-23T01:16:25.431Z",
"test_session": false
},
{
"id": "0a9d7c33-5e21-4f6b-8c44-1d2e3f4a5b6c",
"file_name": "forehead.png",
"content_type": "image/png",
"size": 2506411,
"uploaded_at": "2026-10-21T09:02:11.004Z",
"download_url": "https://api.productfinder.app/v1/files/0a9d7c33-5e21-4f6b-8c44-1d2e3f4a5b6c/download",
"session_id": "a9ca397d-fefb-498a-bf94-2b9534d8453c",
"quiz_name": "Skin Quiz",
"quiz_id": "skin-quiz",
"email": null,
"session_started_at": "2026-10-21T09:00:40.112Z",
"session_completed_at": null,
"test_session": false
}
]
}
Fields in the answer
Field | Type | Meaning |
total | number | How many files match your filters across all pages. With no filters, how many files you have in all. |
limit, offset | number | The page size and start you asked for (or the defaults). |
items | list | The files on this page, newest first. Each item has the fields below. |
id | UUID | The file's id. Use it for the single-file and download requests. |
file_name | text | The name the file had on the shopper's device. |
content_type | text | The file's media type, for example image/jpeg or application/pdf. Can be null when the browser did not report one. |
size | number | Size in bytes. |
uploaded_at | date | When the upload finished. |
download_url | URL | The permanent download address for this file. See Download a file. |
session_id | UUID | The quiz session the file belongs to. |
quiz_name, quiz_id | text | The quiz's name and URL slug at the time of the request. |
text | The email address the shopper entered in the quiz, or null if they did not enter one. | |
session_started_at | date | When the shopper started the quiz. |
session_completed_at | date | When the shopper reached the results, or null if they left the quiz before the results page. |
test_session | true / false | True for sessions started from the quiz preview in the editor, false for real shoppers. |
Reading every page
Start with offset=0, then add the number of items you received to the offset and ask again, until the offset reaches total. Files are sorted newest first, so a file uploaded while you page through moves everything down by one. If you sync regularly, filter with uploaded_after set to the time of your previous sync instead of reading the whole list every time.
One file
GET https://api.productfinder.app/v1/files/{id}
Returns one file with the same fields as an entry in the list. Use it to refresh a file's details when you already know its id.
curl "https://api.productfinder.app/v1/files/6f2c1c1e-3d7a-4b8e-9f10-2a6c8d4e5b71" \
-H "X-STORE: your-store.myshopify.com" \
-H "X-API-KEY: your-api-key"
A file id that does not exist in your store gets the answer 404 Not Found, including ids that belong to another store.
Download a file
GET https://api.productfinder.app/v1/files/{id}/download
This is the download_url from the list. Send the same two headers. The answer is a 302 redirect whose Location header is a temporary link to the file itself, on Lantern's storage. That temporary link needs no headers and works for 15 minutes, then expires. Its Content-Disposition header carries the original file name.
There are two ways to use it:
1. Let your HTTP client follow the redirect. Most clients do this by default; with curl add -L. The file is downloaded in one step.
curl -L "https://api.productfinder.app/v1/files/6f2c1c1e-3d7a-4b8e-9f10-2a6c8d4e5b71/download" \
-H "X-STORE: your-store.myshopify.com" \
-H "X-API-KEY: your-api-key" \
-o left-cheek.jpg
2. Read the Location header yourself and fetch that link without any headers. This keeps your API key out of the request to the storage server, and it lets you hand the temporary link to a browser or another service that needs to read the file within the next 15 minutes.
curl -s -o /dev/null -w "%{redirect_url}" \
"https://api.productfinder.app/v1/files/6f2c1c1e-3d7a-4b8e-9f10-2a6c8d4e5b71/download" \
-H "X-STORE: your-store.myshopify.com" \
-H "X-API-KEY: your-api-key"
Store the download_url in your system, never the temporary link. The download_url does not expire and gives you a fresh temporary link every time you call it.
Errors
Errors come back as JSON with the HTTP status and a message that says what was wrong:
{ "statusCode": 400, "message": "session_id must be a UUID", "error": "Bad Request" }
Status | Meaning |
400 Bad Request | A parameter is not valid: a session_id that is not a UUID, a date that cannot be read, a limit of 0, a negative offset. The message names the parameter. |
401 Unauthorized | The X-STORE or X-API-KEY header is missing or wrong, or the key does not belong to that store. |
404 Not Found | No file with that id exists in your store. |
Code examples
Both examples list every file uploaded since a given moment and download each one into a folder, with the file's id in front of the name so two shoppers uploading photo.jpg do not overwrite each other.
JavaScript (Node.js 18 or newer)
const BASE = "https://api.productfinder.app";
const headers = {
"X-STORE": "your-store.myshopify.com",
"X-API-KEY": "your-api-key",
};
async function listFiles(since) {
const files = [];
let offset = 0;
while (true) {
const url = `${BASE}/v1/files?limit=100&offset=${offset}&uploaded_after=${encodeURIComponent(since)}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const page = await res.json();
files.push(...page.items);
offset += page.items.length;
if (offset >= page.total || page.items.length === 0) break;
}
return files;
}
async function download(file, folder) {
const res = await fetch(file.download_url, { headers }); // follows the redirect
if (!res.ok) throw new Error(`${res.status} downloading ${file.id}`);
const bytes = Buffer.from(await res.arrayBuffer());
await require("fs/promises").writeFile(`${folder}/${file.id}-${file.file_name}`, bytes);
}
(async () => {
const files = await listFiles("2026-10-01T00:00:00Z");
console.log(`${files.length} files`);
for (const file of files) await download(file, "./uploads");
})();
Python 3 (requests)
import requests
BASE = "https://api.productfinder.app"
HEADERS = {
"X-STORE": "your-store.myshopify.com",
"X-API-KEY": "your-api-key",
}
def list_files(since):
files, offset = [], 0
while True:
res = requests.get(
f"{BASE}/v1/files",
headers=HEADERS,
params={"limit": 100, "offset": offset, "uploaded_after": since},
)
res.raise_for_status()
page = res.json()
files.extend(page["items"])
offset += len(page["items"])
if offset >= page["total"] or not page["items"]:
return files
def download(file, folder):
res = requests.get(file["download_url"], headers=HEADERS) # follows the redirect
res.raise_for_status()
with open(f"{folder}/{file['id']}-{file['file_name']}", "wb") as out:
out.write(res.content)
files = list_files("2026-10-01T00:00:00Z")
print(len(files), "files")
for file in files:
download(file, "./uploads")
Good to know
• Which plans: the file upload block itself is available on the Grow and Enterprise plans. The API reads whatever files your store has, on any plan, including files uploaded before you downgraded.
• File types and size: the upload block accepts any file type up to 5 GB. The API returns them unchanged, exactly as uploaded.
• Deletion: all of a store's files are deleted 48 hours after Lantern is uninstalled, together with the rest of the store's quiz data, so download what you need while the app is installed. To delete one shopper's files earlier, write to us through the chat in the Lantern admin.
• Test sessions: files uploaded while previewing the quiz in the editor are listed with test_session true. Skip them in your sync if you only want real shoppers.
• Be reasonable with requests: a sync every few minutes with uploaded_after is fine. Avoid re-reading your entire file list in a tight loop.
• Need something the API does not do? Write to us through the chat in the Lantern admin and tell us what you are building.
