API documentation

One endpoint. One photo in, one report out.

The whole integration surface is a single multipart POST. This documents exactly what it accepts and returns today — not a roadmap.

Access is through a pilot, not a signup form

There are no self-serve API keys yet, and this page is not going to pretend otherwise. The endpoint below is the one behind the public demo: unauthenticated, rate-limited by IP, and pointed at our own origin. Production access — your own throughput, your own notification routing, authentication — is set up during a pilot, and I do that integration work myself.

Email me and tell me where your photos live. That conversation is usually shorter than reading this page.

The request

POST/api/analyze

multipart/form-data with exactly one file field named image. There is no JSON body parser on this service — the only endpoint that takes a body takes multipart, so an app-wide JSON parser would just be another way in.

curl -X POST https://teuscan.com/api/analyze \
  -F "image=@pallet-inbound-0412.jpg"

Limits

  • Formats: JPEG, PNG, WebP. HEIC is rejected with a clear message rather than failing downstream — the model cannot read it and browsers cannot preview it.
  • Size: 12MB by default. Phone photos are routinely 4-8MB, so this is deliberately above normal camera output.
  • Rate: 60 requests per IP per hour by default. The limiter is on this endpoint rather than the app because this is the call that costs money.
  • Timeout: 45 seconds on the model call. In practice a report comes back in a few seconds.

All four are environment settings rather than hard constants, and all four get revisited for a pilot — the defaults are sized for a public demo.

What happens to the image

  • EXIF orientation is baked in before analysis. Browsers apply the orientation tag when rendering and the model reads raw pixels, so without this every bounding box on a phone photo would be rotated relative to what you see.
  • The long edge is capped at 1536px. Boxes are returned as fractions, so downscaling costs no coordinate accuracy — and it is the main lever on latency.

The response

200 with an AnalyzeResponse. See the sample report for the same structure rendered as a human would read it.

{
  "report": {
    "verdict": "damage",
    "severity": "moderate",
    "summary": "Moderate damage: the lower left corner of the pallet is crushed
                across two cartons, with the outer wrap torn above it.",
    "findings": [
      {
        "label": "Crushed corner",
        "description": "The lower left corner of the load is compressed inward
                        across what appear to be two cartons.",
        "location": "lower left of the pallet",
        "severity": "moderate",
        "box": { "x": 0.08, "y": 0.62, "w": 0.29, "h": 0.30 }
      }
    ],
    "photoQuality": { "usable": true, "issues": [] }
  },
  "meta": {
    "processingMs": 4120,
    "model": "google/gemini-3.5-flash",
    "imageWidth": 1536,
    "imageHeight": 1152
  }
}

report.verdict

  • damage — damage found. severity is non-null.
  • no_damage — freight is visible and looks intact.
  • unusable — the photo cannot support a judgement. This is a real verdict and not an error: it is photo-quality control doing its job. In a tool people are going to trust, confidently describing damage that is not there is the one unrecoverable mistake.

severity is non-null exactly when the verdict is damage, and it is derived as the worst of the individual findings — so the headline can never read milder than a finding already in the list.

report.findings[]

  • label — short, e.g. "Crushed corner".
  • description — one sentence, written to be forwarded to a customer unedited.
  • location — in plain words, e.g. "lower left of the pallet".
  • severitylight, moderate or severe, graded per finding, so one photo can carry a light scuff and a severe crush.
  • box{ x, y, w, h } as fractions of the image, 0..1, origin top-left. Null when a finding could not be localised. Multiply by your rendered dimensions to draw it.

report.photoQuality

usable plus an issues array naming the capture problems found — motion blur, underexposure and so on. Empty when clean. This is returned on every request, including ones where the verdict is damage.

There is no confidence score

The model emits one and we deliberately do not return it. It is not calibrated — on repeated runs it came back as exactly 0.95 — and publishing an uncalibrated number is invented precision. The same rule governs the product numbers on the rest of the site.

Errors

Every failure returns the same shape. code is stable and safe to switch on; message is written for a person and may change.

{
  "error": {
    "code": "FILE_TOO_LARGE",
    "message": "That photo is over 12MB. Try a smaller one."
  }
}
CodeHTTPWhen
NO_FILE400No file in the request, an empty file, or more than one.
UNSUPPORTED_TYPE415Not a JPEG, PNG or WebP. HEIC is rejected here deliberately.
FILE_TOO_LARGE413Over the upload ceiling (12MB by default).
RATE_LIMITED429Past the per-IP allowance for the window.
UPSTREAM_ERROR502The vision provider failed. Details are logged, never returned.
UPSTREAM_TIMEOUT504The model call exceeded the timeout (45s by default).
INTERNAL500Anything else. Also never leaks upstream text.

Upstream provider errors are logged but never returned. Provider responses quote the request back, which here includes our prompt — so only a code and a message we wrote ourselves reaches a client.

Send me a batch instead

If you want to know whether this is worth integrating, the fastest path is not reading docs — email me 5-10 photos from your dock and I will run them and send back the reports.

How it works: photos arrive, damage is detected and graded, the customer is notified