Intake links
One link per quote. Your customer opens it, drops their artwork, and sees a verdict in seconds. You get one webhook per upload with your own quote reference, a verdict per file, and download links for the report and the original. No login and no form fields for your customer.
How it fits together
Your quote system POST /v1/intake-links {reference, profile} → a link
Your customer opens the link, drops 1 file, many, or a .zip
preflight.art checks each file, stamps it with your reference
Your webhook (Zapier) upload.completed → write the verdict to the quote,
pull the PDF + original into your archive
1. Make a link for a quote
POST /v1/intake-links with your API key. Only reference and profile are required.
curl -X POST https://staging.preflight.art/v1/intake-links \
-H "x-api-key: $PREFLIGHT_KEY" -H "Content-Type: application/json" \
-d '{"reference":"Q-1234","profile":"gravure-flexpack","customer_name":"Sunrise Snacks",
"metadata":{"quote_id":"Q-1234","rep":"Alex"}}'
→ 201 {
"id": "il_7f3a2b91c0de", "url": "https://staging.preflight.art/drop/ik_…", "reference": "Q-1234",
"profile_id": "gravure-flexpack", "depth": "quick", "state": "active",
"expires_at": null, "max_uploads": null, "profile_held": false, …
}
| Field | Required | What it does |
|---|---|---|
reference | yes | Your id for the work, up to 128 characters. Comes back on every job, report and webhook. The same reference can have several links (a revised file weeks later). |
profile | yes | The print profile every file on this link is checked against. See the table below. |
depth | no | quick (default): the deterministic intake screen, seconds per file. full: the complete prepress report, a few minutes per file. |
customer_name, label | no | Shown on the drop page and in your Hangar. |
metadata | no | A flat object of short values (2 KB), echoed on every webhook. |
expires_in_days | no | Links do not expire unless you ask. |
max_uploads | no | Uploads are unlimited unless you set a cap. |
The url is returned once. We store only a hash of it, like an API key. Save it in the quote record. Lost it? Make a new link and revoke the old one with DELETE /v1/intake-links/{id}.
From Zoho Creator (Deluge)
payload = Map();
payload.put("reference", input.Quote_ID);
payload.put("profile", "gravure-flexpack");
payload.put("customer_name", input.Customer_Name);
response = invokeurl
[
url :"https://staging.preflight.art/v1/intake-links"
type :POST
parameters :payload.toString()
headers :{"x-api-key":"pf_your_key","Content-Type":"application/json"}
];
input.Artwork_Link = response.get("url");
One link per quote request (the same URL every time)
Send quote_request_id and the call becomes an upsert: the first call makes the link, every later call with the same id returns the same link and URL ("created": false) and updates the fields you sent. Your CRM can ask for the link whenever it needs it; nothing has to be saved. The URL is never rotated by the API; if a link leaks, revoke it and ask us to reissue it.
curl -X POST https://staging.preflight.art/api/v1/upload-links \
-H "Authorization: Bearer $PREFLIGHT_KEY" -H "Content-Type: application/json" \
-d '{"quote_request_id":"QR-88121","qre":"QRE-10001","project_name":"Sour Gummies 5x8",
"company":"Sunrise Snacks","rep_name":"Alex Rivera","rep_email":"alex@example.com",
"profile":"flexpack"}'
→ 201 on the first call, 200 after: {
"upload_url": "https://staging.preflight.art/drop/ik_…", "url": "https://staging.preflight.art/drop/ik_…", "created": true,
"id": "il_…", "quote_request_id": "QR-88121", "qre": "QRE-10001", "checked": true, …
}
The same call works at POST /v1/intake-links with x-api-key (errors there are 400 with a code). On /api/v1/upload-links the key may also be sent as Authorization: Bearer, and a bad field is 422 {"error", "code", "field"}. The fields: qre (the header on the drop page), project_name, company, rep_name, rep_email, plus every field in the table above. profile may be empty or left out: the files are then received and stored but not checked, and each comes back with the verdict NOT_CHECKED. GET /v1/intake-links/{id} and ?quote_request_id= return the URL of these links too.
The general upload page (no quote yet)
For a customer who has no quote link, for example an upload button on your website: POST https://staging.preflight.art/v1/intake-links/general returns the key's one general page, the same URL on every call (201 the first time, then 200). The page asks for the customer's name, email and company, and optionally a quote or PO number, before it uploads. Those come back on submission.ready as submitter_name, submitter_email, submitter_company and qre_hint, with link_kind: "general", so you can match the upload to a customer or open a new quote. The same file twice is counted per email address on this page, not across everyone who uses it.
curl -X POST https://staging.preflight.art/v1/intake-links/general -H "x-api-key: $PREFLIGHT_KEY" -H "Content-Type: application/json" -d '{"profile":"flexpack"}'
→ 201, then 200: { "id": "il_…", "kind": "general", "url": "https://staging.preflight.art/drop/ik_…", "created": true, … }
Send profile (empty means received, not checked), depth, label or job_description to change it. DELETE /v1/intake-links/{id} turns it off; the next call to /general gives a new URL ("reissued": true), and the old one stays off.
Other link calls
GET /v1/intake-links?reference=Q-1234every link for a quote, newest first (limit,cursorfor paging).GET /v1/intake-links/{id}one link. Returns the URL only for links made withquote_request_idand the general page.GET /v1/intake-links/{id}/uploadsthe uploads made on it, each with the customer'snoteand, on the general page,submitter.DELETE /v1/intake-links/{id}revoke. The page then tells the customer the link is no longer active.
2. What your customer sees
Your brand (logo, color, name) when your key has one, the quote reference, their company name, and the print profile in plain words. They drop up to 25 files at a time, 500 MB each and 2 GB in all: .pdf, .ai, .eps, or a .zip. Each file gets a card that says Ready, Ready with notes, Needs changes, Could not read, or Received, with the top fixes in plain language and a link to the full PDF report.
- Reference files (
.jpg,.png,.tif,.psd,.docx,.xlsx, on their own or inside a zip) are kept with the submission and never checked. Each is a file withkind: "attachment"and the result Received, with a link to the original. - The same file twice. A file this link already has (the same bytes) is "already received": listed in
skippedwith the reasonduplicate, not stored, checked or sent in a webhook again. If every file was already here, the upload answers200withstatus: "duplicate"and nothing else happens. A file whose check failed can always be sent again. - A note. Every page has an optional box for a note with the files (up to 2,000 characters). It comes back as
noteonupload.completedandsubmission.ready. - Email me the results (when your brand has it switched on; ask us). The page offers a box; a customer who ticks it gets one email when every file is done: each file's result in the same words as the page, the top fixes, a link to each report that does not expire, and "Upload a revised file" when something needs changing. It comes in your brand, and replies go to your team's address. A quote page asks where to send it; the general page uses the email the customer typed.
GET /v1/intake-links/{id}/uploadsshowsresults_email(to,status: sent, failed, or off). - Large files on a weak connection. Anything over 8 MB goes up in 8 MB pieces. If the connection drops, the page waits, asks how much arrived, and carries on from there by itself for as long as it is open; a dropped connection costs one piece, not the file. Your integration sees one upload, the same as always.
- Limits per upload: 25 artwork files after zips are opened, 25 reference files, 2 GB in all. A zip that unpacks to more than 5 GB, or to more than 100 times its own size, is refused.
| Link state | What the page says | Upload answer |
|---|---|---|
| active | the drop zone | 202 |
| revoked (or the key was revoked) | no longer active | 410 INTAKE_LINK_REVOKED |
| expired | has expired | 410 INTAKE_LINK_EXPIRED |
| max uploads reached | has taken all the uploads it allows | 410 UPLOAD_LIMIT |
Sending large files yourself, in pieces
The drop page does this for you. If you build your own uploader on a link, the same calls are open to it (the token in the path is the credential):
POST https://staging.preflight.art/v1/intake/{token}/partswithfile_nameandsize(a form or JSON) →201 { "part_id": "pt_…", "chunk_bytes": 8388608, "received": 0 }. A type the page would only skip answers422 UNSUPPORTED_TYPEbefore anything is sent.POST https://staging.preflight.art/v1/intake/{token}/parts/{part_id}?offset=Nwith the next piece as the raw body (up to 16 MB) →{ "received", "complete" }. A piece must start atreceived; otherwise409 OFFSET_MISMATCHsays where to carry on. After a dropped connection,GET …/parts/{part_id}answersreceived: what arrived is kept.POST https://staging.preflight.art/v1/intake/{token}/uploadwithparts(the part ids, comma separated) instead of files, plusnoteand, on the general page,name,emailandcompany→ the same answer as an upload in one go. All the parts are taken, or none (409 PART_INCOMPLETEnames one not finished).
A part with no piece for a day is removed.
3. The webhook
Set your key's webhook URL (your Zapier Catch Hook) and, if you want exactly one call per upload, subscribe it to upload.completed only. Every file still sends its own preflight.completed to keys subscribed to all events.
upload.completed fires once per upload, after every file in it is done. upload.failed fires when nothing in the upload could be checked (a password-protected zip, a zip with no artwork, every file failed).
{
"event": "upload.completed",
"upload_id": "iu_4c1e…", "intake_link_id": "il_7f3a…", "api_key_id": "…",
"reference": "Q-1234", "metadata": {"quote_id": "Q-1234", "rep": "Alex"},
"customer_name": "Sunrise Snacks", "profile_id": "gravure-flexpack", "depth": "quick",
"source_kind": "zip", "source_file_name": "sunrise_artwork.zip",
"file_count": 3, "status": "complete",
"verdict": "FIX_REQUIRED",
"files": [
{ "job_id": "…", "file_name": "front.pdf", "status": "complete", "depth": "quick",
"verdict": "PASS", "quick_verdict": "PASS",
"counts": {"critical": 0, "warning": 1, "info": 3},
"urls": { "report_json": "https://staging.preflight.art/v1/dl/…", "pdf": "https://staging.preflight.art/v1/dl/…", "source": "https://staging.preflight.art/v1/dl/…" },
"urls_expire_at": "2026-10-03T18:00:00.000Z" }
],
"skipped": [ { "entry": "sunrise_artwork.zip/old/inner.zip", "reason": "nested_zip",
"message": "Nested zip files are not processed. Unzip and upload the artwork directly." } ],
"uploaded_at": "…", "completed_at": "…"
}
| Field | Meaning |
|---|---|
status | complete every file checked; partial some failed; failed none (sent as upload.failed with an error). |
verdict | The worst verdict across the files: could-not-read, then fail / fix required, then conditional pass, then pass. |
files[].urls | Download links that need no API key. pdf is null if no report PDF was rendered; source is the original upload. |
skipped | Everything in the upload that was not checked, with the reason and a sentence you can pass to the customer. |
Every preflight.completed and preflight.failed now also carries reference, metadata, file_name, profile_id, depth, intake (link and upload ids) and the same urls. Every key they had before is unchanged, in the same order.
submission.ready: flat fields for a CRM
The same moment as upload.completed, in a shape a CRM can write straight back to a quote. It is sent only to a key that names it in its webhook events (Edit the key on /account/api-keys); a key with the default events never receives it.
- Flat: every top-level value is a string, counts too (
"file_count": "2"), and a value we do not have is"", so every field is always there to map. - The quote:
quote_request_id,qre,project_name,company,rep_email,rep_namefrom the link, and its metadata asmetadata_<key>. - The customer:
note(what they wrote with the files, any page) and, from the general page,submitter_name,submitter_email,submitter_companyandqre_hint.link_kindisquoteorgeneral. - The result:
result_labelis Ready, Ready with notes, Needs changes, Could not read or Received (not checked), the same words the customer sees on the drop page;resultis the same as a code (the list is below). The submission's result is the worst of its files. - The findings: up to 8 lines, criticals first, each
SEVERITY: titleand at most 150 characters, joined with a line break;findings_totalsays how many there were. - The full file list every time, in
files, each file flat with string values:file_id,file_name,kind(artworkorattachment),result(the code) andresult_label,verdict,critical_count,warning_count,info_count,findingsandfindings_total,report_linkandfile_link(never expire),report_pdf_urlandoriginal_file_url(signed, 7 days),replaces_file_id,version,recheckedanderror. A file that was not checked has empty counts. - Retries: after 1, 5 and 30 minutes, then every hour for 24 hours. Every retry has the same body and
event_id(the same asX-Preflight-Event-Id). Every send and the answer it got are in the Hangar, and Resend sends it again any time. - Resend:
POST https://staging.preflight.art/v1/submissions/{upload_id}/resendsends it again with the file list as it stands now and a newevent_id(send_reason: "resend"). Only one send of a submission is ever in flight.GET https://staging.preflight.art/v1/submissions/{upload_id}shows what it would carry and how its last send went. - A corrected file:
POST https://staging.preflight.art/v1/submissions/{upload_id}/fileswith the file as multipartfileand, when it replaces one,replaces_file_id. It is checked like the others, and when it is done submission.ready goes out again withsend_reason: "corrected"and the files as they stand: the replaced file is left out and named inreplaced_file_ids, and the new one carriesreplaces_file_idand itsversion("2"after one correction,"3"after two). upload.completed is not sent again. - A new print profile on the quote: call the link again (
POST https://staging.preflight.art/api/v1/upload-linkswith the samequote_request_id) with the newprofile. The URL stays the same, later uploads are checked under the new profile, and every finished submission already on the link is checked again under it: submission.ready goes out again for each withsend_reason: "rechecked", each file markedrechecked: "true", naming the file it replaces inreplaces_file_id(the old one is inreplaced_file_ids). Theversiondoes not change: it is the same artwork. The answer to the link call lists what is being checked again inrecheck. To check one submission again on demand:POST https://staging.preflight.art/v1/submissions/{upload_id}/recheck. A call with the same profile, or withoutprofile, checks nothing again. - Map your Zap before a real upload:
POST https://staging.preflight.art/v1/webhooks/testwith{"event": "submission.ready"}sends a full sample, marked"test": "true".
Result codes, for routing
result | Label | What it means |
|---|---|---|
ready | Ready | Checked, nothing to change or note. |
ready_with_notes | Ready with notes | Checked, no critical: only warnings or notes to read before press. |
needs_changes | Needs changes | Checked, at least one critical to fix, on the quick check and the full report alike. |
could_not_read | Could not read | The file could not be opened or checked (damaged, password protected, not really a PDF). |
received | Received | Kept and not checked: a reference file (kind: "attachment"), or any file on a link with no profile. |
Every upload on a link gets one. A link with no profile sends it with result: "received" (each file verdict: "NOT_CHECKED", empty counts). An upload where nothing could be checked sends it with result: "could_not_read"; when nothing in it could even be opened as artwork (a password-protected zip, a file that is not really a PDF) it also says upload_status: "failed" with an error_code. The one upload that sends nothing is one made only of files that link already has: the page tells the customer we already have them.
Route on result. critical_count counts every critical, including total ink coverage over the limit, which is reported but never holds a file on its own, so a file can read ready_with_notes with critical_count: "1" on the quick check. The other codes you will see:
verdict(per file):PASS,CONDITIONAL_PASSorFAIL, as the PDF report states it;NOT_CHECKEDfor a file that was not checked; empty when the check itself failed. A file that could not be read also showsFAIL:resulttells the two apart. On the full reportFAILis kept for a file that cannot be used at all, so a file with a critical to fix readsCONDITIONAL_PASSthere andneeds_changesinresult.upload_status:complete,partial(some files could not be checked) orfailed(none could), witherror_codeENCRYPTED_ZIP,ZIP_TOO_LARGE,NO_ARTWORK,ALL_FILES_FAILEDorUPLOAD_FAILEDanderror_messagein words.send_reason:completed(the first send),resend,corrected,recheckedortest.link_kind:quoteorgeneral.
Full reports on a link (depth: "full"): the complete report, about two minutes a file. The files of one upload are checked one after another, so submission.ready for a 15-file upload arrives about half an hour after the upload; the customer's page fills in card by card meanwhile. The quick check takes seconds a file.
Verify the signature
Each delivery carries X-Preflight-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 with your webhook secret over t + "." + raw body. Verify against the raw bytes, not re-serialized JSON. Deduplicate on X-Preflight-Event-Id: it is the same on every retry of one event. A delivery that does not get a 2xx is tried up to three times in all: right away, a minute later, then five minutes after that (submission.ready is tried for a day, as above).
const crypto = require('crypto');
function verify(header, rawBody, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const want = crypto.createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1 || ''));
}
For your team: the Submissions tab
Everyone in your organization can see every upload on your links in the Hangar (/hangar?tab=submissions): each file's result and top fixes, links to the reports and originals that do not expire, the note and who sent it, and the callback log (every event sent to your CRM, how many tries, and what it answered). From there they can send a submission to your CRM again, or add a corrected file.
4. Download links and how long files last
The links in a webhook last 24 hours. Mint fresh ones any time, for up to 7 days:
curl "https://staging.preflight.art/v1/preflight/$JOB_ID/links?ttl_hours=168" -H "x-api-key: $PREFLIGHT_KEY"
→ { "report_json": "…", "pdf": "…", "source": "…", "expires_at": "…", "ttl_hours": 168 }
Links that never expire. For a quote in your CRM, a proof in Ziflow or an email to the factory, each file in submission.ready also carries file_link and report_link (https://staging.preflight.art/v1/f/… and https://staging.preflight.art/v1/r/…). They do not expire: each one opens a fresh 15-minute download, named after the customer's own file. GET https://staging.preflight.art/v1/files/{file_id}/links gives the same two links any time, and DELETE on it turns them off (the next GET gives new ones). They also stop when the key is revoked, and the file link stops when the original passes its retention period.
Ziflow. GET https://staging.preflight.art/api/v1/files/{file_id}/ziflow-source with Authorization: Bearer pf_… (or /v1/files/{file_id}/ziflow-source with x-api-key) answers { file_id, file_name, url, expires_at }: a 24-hour link to the file itself. For a packaged Illustrator zip that is the main artwork inside it (the .ai, byte for byte), never the zip. A reference file answers 422 NOT_ARTWORK; a file past its retention period 410 FILE_EXPIRED.
A link stops working the moment its API key is revoked. Originals and report PDFs are kept for your key's retention period (the default, or what is agreed for your account). Keep your own archive of record: pull pdf and source into it from the webhook. Reports and status stay readable through the API after the 24-hour job window.
5. Zip files
- One artwork (an Illustrator File > Package folder, zipped, or a zip of one PDF): one job. The package report is read for missing fonts and link resolution. When the package folder also holds a PDF export of the same art, the
.aiis checked and the PDF is listed inskippedaspackage_extra("This package folder was checked once, from its main artwork file."). The file that is checked, and thatziflow-sourcereturns, is the main artwork:.aifirst, then.pdf, then.eps. - Two or more artworks: one job per artwork, up to 25 per zip. A package folder inside the zip is one job from its main artwork.
- Password protected: refused,
ENCRYPTED_ZIP, "This zip is password protected. Remove the password and upload again." - Nested zips are listed in
skipped(nested_zip). On the drop page, images, .psd, .docx and .xlsx inside a zip are kept as reference files; throughPOST /v1/preflightimages and .psd are listed asunsupported_type. .indd isunsupported_typeeverywhere: export as PDF. Files in a package'sLinks/andFonts/folders are assets, not artwork, and are not listed. - No artwork at all:
NO_ARTWORKon the drop page,INVALID_PACKAGE_ZIPonPOST /v1/preflight, saying what the zip did contain.
POST /v1/preflight handles a multi-artwork zip the same way: the 202 adds upload_id, job_ids, files and skipped, and keeps job_id (the first file) so a single-file integration keeps working.
6. Profiles
| Id | Use for |
|---|---|
flexpack | Flexible packaging, flexographic |
digital-flexpack | Flexible packaging, digital |
gravure-flexpack | Flexible packaging, rotogravure |
offset-flexpack | Flexible packaging, offset |
flexo-labels, digital-labels | Labels |
cartons-folding, cartons-folding-digital | Folding cartons |
The quick check runs the same deterministic intake screen for every flexible-packaging method today: color space, image resolution, fonts, linked images, bleed and the dieline. Press-specific checks are added to each profile as they are validated. profile_held: true on a link marks a profile whose press-specific checks are still being added.
7. A Zapier recipe
- Webhooks by Zapier, Catch Hook. Put its URL on your API key and subscribe the key to
upload.completed. (Use Catch Raw Hook if you verify the signature in a Code step.) - Zoho Creator, Find Record by
reference, then Update Record withverdict,statusandfile_count. - Looping by Zapier over
files. - Zoho WorkDrive, Upload File from
files[].urls.pdf, and again fromfiles[].urls.source, into the quote's folder.
Test the whole chain before a customer uses it: make a link, drop a file on it yourself, and watch one upload.completed arrive.