preflight.art API

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, …
}
FieldRequiredWhat it does
referenceyesYour 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).
profileyesThe print profile every file on this link is checked against. See the table below.
depthnoquick (default): the deterministic intake screen, seconds per file. full: the complete prepress report, a few minutes per file.
customer_name, labelnoShown on the drop page and in your Hangar.
metadatanoA flat object of short values (2 KB), echoed on every webhook.
expires_in_daysnoLinks do not expire unless you ask.
max_uploadsnoUploads 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

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.

Link stateWhat the page saysUpload answer
activethe drop zone202
revoked (or the key was revoked)no longer active410 INTAKE_LINK_REVOKED
expiredhas expired410 INTAKE_LINK_EXPIRED
max uploads reachedhas taken all the uploads it allows410 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):

  1. POST https://staging.preflight.art/v1/intake/{token}/parts with file_name and size (a form or JSON) → 201 { "part_id": "pt_…", "chunk_bytes": 8388608, "received": 0 }. A type the page would only skip answers 422 UNSUPPORTED_TYPE before anything is sent.
  2. POST https://staging.preflight.art/v1/intake/{token}/parts/{part_id}?offset=N with the next piece as the raw body (up to 16 MB) → { "received", "complete" }. A piece must start at received; otherwise 409 OFFSET_MISMATCH says where to carry on. After a dropped connection, GET …/parts/{part_id} answers received: what arrived is kept.
  3. POST https://staging.preflight.art/v1/intake/{token}/upload with parts (the part ids, comma separated) instead of files, plus note and, on the general page, name, email and company → the same answer as an upload in one go. All the parts are taken, or none (409 PART_INCOMPLETE names 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": "…"
}
FieldMeaning
statuscomplete every file checked; partial some failed; failed none (sent as upload.failed with an error).
verdictThe worst verdict across the files: could-not-read, then fail / fix required, then conditional pass, then pass.
files[].urlsDownload links that need no API key. pdf is null if no report PDF was rendered; source is the original upload.
skippedEverything 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.

Result codes, for routing

resultLabelWhat it means
readyReadyChecked, nothing to change or note.
ready_with_notesReady with notesChecked, no critical: only warnings or notes to read before press.
needs_changesNeeds changesChecked, at least one critical to fix, on the quick check and the full report alike.
could_not_readCould not readThe file could not be opened or checked (damaged, password protected, not really a PDF).
receivedReceivedKept 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:

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

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

IdUse for
flexpackFlexible packaging, flexographic
digital-flexpackFlexible packaging, digital
gravure-flexpackFlexible packaging, rotogravure
offset-flexpackFlexible packaging, offset
flexo-labels, digital-labelsLabels
cartons-folding, cartons-folding-digitalFolding 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

  1. 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.)
  2. Zoho Creator, Find Record by reference, then Update Record with verdict, status and file_count.
  3. Looping by Zapier over files.
  4. Zoho WorkDrive, Upload File from files[].urls.pdf, and again from files[].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.