API v1 · engine mesh-fi/0.1

ClaimProof API

A REST API that answers one question per claim photo: was it captured by a camera as-is, or edited, AI-generated, or reused? Every answer carries evidence, a reliability-aware confidence and an explicit inconclusive path.

Base URL: https://claimproof.wardtechsystems.com · All endpoints are under /v1 · Machine-readable spec: /openapi.yaml

Quick start

  1. Get a tenant. Tenants are created by Ward Tech during onboarding (request a pilot). You receive one API key — shown once — and portal access.
  2. Check connectivity.
curl https://claimproof.wardtechsystems.com/v1/health
# {"ok":true,"engine":"mesh-fi/0.1","version":"…","time":"…"}
  1. Analyse your first photo — under 60 seconds from key to verdict.
curl -X POST https://claimproof.wardtechsystems.com/v1/analyze \
  -H "Authorization: Bearer cp_live_…" \
  -F "image=@roof.jpg"
{
  "id": "ana_7f3c…",
  "analysis": { "engine": "mesh-fi/0.1", "verdict": "edited_likely", "score": 0.78, "confidence": 0.71, "…": "…" },
  "annotatedUrl": "https://claimproof.wardtechsystems.com/v1/files/tenants/…/annotated.jpg?sig=…&exp=…",
  "storedUntil": "2027-02-08T00:00:00Z"
}

Then read verdict semantics before wiring any automation to the result.

Authentication

Every request except /v1/health, /v1/demo/analyze, /v1/leads and /v1/portal/session needs a bearer token:

Authorization: Bearer cp_live_0123456789abcdef0123456789abcdef
  • Keys look like cp_live_ + 32 hex characters. We store only a SHA-256 hash; the plaintext is shown once at creation.
  • Keys carry scopes: analyze, claims, read (default all three) and admin. A call outside the key's scopes returns 401 UNAUTHORIZED.
  • The portal exchanges a key for a 24-hour session token via POST /v1/portal/session; the API accepts either interchangeably in the Authorization header.
  • Rotate by asking support for a new key, moving traffic, then revoking the old one. Self-service rotation is on the roadmap.

Keep keys server-side. Never embed a cp_live_ key in a browser or mobile app — use your own backend as the proxy.

Rate limits & quotas

PlanRequests / min per keyIncluded images / monthOver the allowance
pilot30300 (30 days)402 QUOTA_EXCEEDED — hard stop
starter1202,500$0.18 / image, metered
carrier60020,000$0.12 / image, metered
enterprise2,000250,000+$0.06 / image, metered

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. A 429 RATE_LIMITED response also carries Retry-After (seconds). Back off and retry; do not spin.

An image is one photo analysed — via /v1/analyze or as part of a claim. Rejected files, re-reads and reports are not counted. See /v1/usage.

Single image analysis

POST /v1/analyze scope: analyze

Synchronous. Returns within ~25 s (typically 2–8 s). Accepts either multipart form data or JSON with URLs.

Request (multipart)

FieldTypeNotes
imagefileRequired. JPEG / PNG / WebP ≤ 25 MB. HEIC only when image transcoding is enabled for the tenant.
referencefileOptional. The known original. Switches on reference-aware mode: pixel-level diff with reliability 1.0.
contextJSON stringOptional ClaimContext: { lossDate, lossLocation:{lat,lng}, peril, claimNumber, policyNumber, expectedDevice }. Enables the context signal.

Request (JSON)

{ "imageUrl": "https://…/photo.jpg", "referenceUrl": "https://…/original.jpg", "context": { "lossDate": "2026-05-14", "peril": "hail" } }

Query

?store=false — analyse without persisting anything. The response then contains annotatedBase64 instead of annotatedUrl, and no id is retrievable later.

Response 200

{
  "id": "ana_…",
  "analysis": { /* AnalysisResult — see reference */ },
  "annotatedUrl": "…signed, 7-day…",
  "sideBySideUrl": "…only when reference was supplied…",
  "storedUntil": "ISO-8601 (retention)"
}

Examples

curl -X POST "https://claimproof.wardtechsystems.com/v1/analyze?store=false" \
  -H "Authorization: Bearer $CLAIMPROOF_KEY" \
  -F "image=@roof.jpg" \
  -F "reference=@roof_original.jpg" \
  -F 'context={"lossDate":"2026-05-14","peril":"hail","lossLocation":{"lat":32.93,"lng":-96.9}}'
GET /v1/analyses/:id scope: read

Returns a stored single analysis (same shape as the POST response) until storedUntil.

Claims & multi-photo

A claim groups photos so they can be checked against each other and against your other claims. Photos are analysed asynchronously by a queue; the ClaimReport is built when the last photo finishes.

POST /v1/claims scope: claims
{
  "claimNumber": "HO-2026-48211",          // required
  "policyNumber": "P-99812",
  "peril": "hail",                          // hail|wind|water|fire|auto|theft|other
  "lossDate": "2026-05-14",
  "lossLocation": { "lat": 32.93, "lng": -96.9, "address": "…" },
  "insured": { "name": "…", "email": "…" },
  "expectedDevice": "iPhone 15 Pro",
  "webhookUrl": "https://claims.example.com/hooks/claimproof",   // per-claim override
  "metadata": { "adjusterId": "A-114" }     // free-form, echoed back
}
201 { "id": "clm_…", "status": "open",
      "uploadUrls": { "photos": "/v1/claims/clm_…/photos", "zip": "/v1/claims/clm_…/zip" } }
POST /v1/claims/:id/photos scope: claims

Multipart, 1–50 files per request in field photos[] (or a single photo). Up to 200 photos per claim. Each accepted photo is stored and queued immediately.

202 { "claimId": "clm_…", "status": "processing",
      "accepted": [ { "photoId": "pho_…", "filename": "IMG_0412.jpg", "bytes": 2817771 } ],
      "rejected": [ { "filename": "notes.pdf", "reason": "UNSUPPORTED_FORMAT" } ] }
GET /v1/claims/:id scope: read
{
  "id": "clm_…", "claimNumber": "HO-2026-48211",
  "status": "open" | "processing" | "complete" | "failed",
  "counts": { "total": 12, "done": 12, "failed": 0 },
  "photos": [ { "photoId": "pho_…", "filename": "…", "status": "done", "verdict": "authentic_likely",
                "score": 0.12, "confidence": 0.81, "thumbUrl": "…", "annotatedUrl": "…" } ],
  "report": null | { /* ClaimReport */ }
}
GET /v1/claims/:id/photos/:photoId scope: read

Full per-photo detail: { photoId, filename, status, analysis, originalUrl, annotatedUrl }.

GET /v1/claims/:id/report?format=json|html scope: read

json returns the ClaimReport; html returns a branded, printable page. Because browsers cannot attach headers to a new tab, the HTML variant also accepts the bearer credential as ?token= — on this endpoint only.

GET /v1/claims?status=&cursor=&limit= scope: read

Paged list, newest first. Follow nextCursor until it is absent.

DELETE /v1/claims/:id scope: claims

Deletes the claim, its photos, analyses and stored files. 204. Irreversible.

ZIP ingestion

POST /v1/claims/:id/zip scope: claims

Send the archive your claims system already produces. Either a raw application/zip body or multipart with field zip. Up to 50 MB; the central directory is budgeted before anything is inflated (≤ 60 MB of declared image bytes, ≤ 200 entries).

curl -X POST https://claimproof.wardtechsystems.com/v1/claims/clm_…/zip \
  -H "Authorization: Bearer $CLAIMPROOF_KEY" \
  -H "Content-Type: application/zip" \
  --data-binary @HO-2026-48211.zip
# 202 { "claimId": "clm_…", "workflowId": "…", "status": "unpacking" }

A durable workflow unpacks the archive, skips non-images, __MACOSX entries and files over 25 MB, creates photo rows and queues each one. Poll GET /v1/claims/:id — counts.total grows as entries are accepted — or wait for claim.completed.

Polling vs webhooks

Polling: GET /v1/claims/:id every 10 s while status is processing. Fine for the portal and for low volume.

Webhooks (Starter and above): set webhookUrl on the tenant or per claim. We POST JSON with these headers:

X-ClaimProof-Event: claim.completed
X-ClaimProof-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with your webhook_secret>
Content-Type: application/json
EventWhenBody
claim.photo.analyzedeach photo finishes{ claimId, photoId, verdict, score, confidence }
claim.completedlast photo done, report built{ claimId, claimNumber, report: ClaimReport }
claim.failedunrecoverable claim error{ claimId, error }
webhook.testPOST /v1/webhooks/test{ ok: true, tenant }

Deliveries retry 3× (after 1 min, 5 min, 30 min) on any non-2xx. Respond 200 quickly and process asynchronously. Always verify the signature over the raw body:

import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
app.post('/hooks/claimproof', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.get('X-ClaimProof-Signature') || '';
  const expected = 'sha256=' + createHmac('sha256', process.env.CLAIMPROOF_WEBHOOK_SECRET)
    .update(req.body).digest('hex');
  const ok = sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const event = req.get('X-ClaimProof-Event');
  const payload = JSON.parse(req.body.toString('utf8'));
  res.status(200).end();                 // ack first
  if (event === 'claim.completed') queueForAdjuster(payload.claimId, payload.report);
});
POST /v1/webhooks/test scope: claims

Sends a signed webhook.test event to the tenant's URL so you can verify your receiver before the first real claim.

Files & signed URLs

GET /v1/files/*?sig=&exp= signed

Every *Url field in the API (annotatedUrl, thumbUrl, originalUrl, sideBySideUrl) is a signed URL on this endpoint: HMAC-signed, valid for 7 days, no bearer token needed. They can be embedded in your claim system or emailed to an adjuster. Do not try to construct them; re-fetch the parent resource to get a fresh one.

Originals and renders are deleted after 90 days by a storage lifecycle rule, or immediately when the claim is deleted through the API; URLs stop resolving at that point even if the 7-day signature is still valid. Other retention periods are set per tenant by Ward Tech on Enterprise contracts.

Usage

GET /v1/usage?month=YYYY-MM scope: read
{ "month": "2026-11", "plan": "carrier",
  "images": { "used": 8120, "included": 20000 }, "claims": 611,
  "overageImages": 0, "estimatedOverageUsd": 0 }

AnalysisResult reference

Returned verbatim as analysis by /v1/analyze, /v1/analyses/:id, /v1/claims/:id/photos/:photoId and /v1/demo/analyze.

FieldTypeMeaning
enginestringEngine version, e.g. mesh-fi/0.1. Pin your thresholds to it.
imageobjectwidth, height, format (jpeg|png|webp), bytes, sha256, phash (64-bit DCT perceptual hash, 16 hex), estimatedJpegQuality, megapixels.
verdictenumauthentic_likely · inconclusive · edited_likely · ai_generated_likely · reused_photo. See semantics.
score0–1Manipulation suspicion. An ordinal, not a probability: 0.8 is more suspicious than 0.6, it is not "80 % likely edited".
confidence0–1How much the evidence should be trusted for this image — the reliability-weighted mean of the signals that ran. Low confidence means degraded input, not an authentic one.
degradationobjectlevel: none|light|heavy, reasons[] from social_media_resize, no_exif, screenshot, heavy_jpeg, tiny_image, transcoded_heic.
signals[]arrayOne entry per signal that ran, stable order. Each: id, label, score, reliability, status (clean|suspicious|inconclusive|skipped), findings[], map. map is a coarse {cols, rows, cell, values[]} grid or null.
signals[].findings[]array{ code, severity (high|medium|low|info), message, data }. Codes are stable identifiers (e.g. EDITOR_SOFTWARE, THUMBNAIL_MISMATCH, CAPTURED_BEFORE_LOSS) — key automation on codes, not messages.
regions[]arrayFused suspicious regions in full-resolution image coordinates, sorted by score, max 8: { id, x, y, w, h, score, signals[], note }.
heatmapobject{ cols, rows, values[] } row-major fused suspicion grid for client overlay (the portal and demo draw it on a canvas).
metadataobjectNormalised, always present, nulls when absent: make, model, software, lens, dateTimeOriginal, modifyDate, gps{lat,lng}, hasExif, hasXmp, hasIcc, hasC2pa, c2pa{present, verified:false, actions[], generator}, editorHistory[], aiMarkers[], exifThumbnail{present, matchesImage, hammingDistance}, orientation, colorSpace, dimensionsDeclared{w,h}.
contextobject | nullOnly when a ClaimContext was supplied: capturedBeforeLoss, daysFromLoss, distanceFromLossKm, deviceMatchesExpected (null when unknowable).
summarystring2–4 plain-English sentences for an adjuster: the top findings and the degradation level.
timingMsobject{ decode, total } milliseconds.
warnings[]arrayOperational notes, e.g. "Image was downscaled before pixel analysis; metadata and hash come from the original file".

ClaimReport reference

Built once when the last photo of a claim finishes. Available as report on GET /v1/claims/:id, via /report, and inside the claim.completed webhook.

FieldMeaning
riskScore · riskLevel0–1 and low | elevated | high. high if any photo is ai_generated_likely / reused_photo / captured before the loss, or ≥ 2 are edited_likely; elevated if one edited_likely, or ≥ 30 % inconclusive under heavy degradation, or a far-GPS finding; else low.
recommendationfast_track (low) · desk_review (elevated) · field_inspection (high) · siu_referral (high with reuse, AI or before-loss evidence). A routing suggestion, not a decision.
photoCount · verdictCountsTotal and a count per verdict.
crossPhoto.duplicatesWithinClaim[]{ photoIds[], hamming } — near-identical photos inside the claim.
crossPhoto.reusedFromOtherClaims[]{ photoId, matchedClaimId, matchedClaimNumber, hamming } — pHash hamming ≤ 6 against the tenant-wide index (Carrier+). Sets the photo's verdict to reused_photo.
crossPhoto.devices[]{ make, model, count }; a null make/model row counts photos without EXIF.
crossPhoto.captureWindow{ first, last, spanHours, photosBeforeLoss, photosWithoutDate }.
crossPhoto.gps{ withGps, clusterRadiusKm, maxDistanceFromLossKm }.
topFindings[]≤ 10 { photoId, code, severity, message } ordered by severity.
summary · engine · generatedAtPlain-English paragraph; engine version; timestamp.

Verdict semantics

A verdict is a reading of evidence with a stated confidence. It is never a determination of fraud, and no verdict should trigger a denial without human review.

VerdictMeansDoes not mean
Authentic (likely)score ≤ 0.25, confidence ≥ 0.5, no high-severity finding. The signals that could run found nothing inconsistent.That the photo is unaltered. A high-skill composite re-photographed with a camera will pass single-image forensics.
InconclusiveEvidence is mixed, or the image is too degraded for the signals to be trusted (confidence < 0.5). The honest default.Suspicious. Treat it as "no forensic opinion" — ask for the original file, not the WhatsApp copy.
Edited (likely)score ≥ 0.65 and confidence ≥ 0.5, or a reference diff changed > 0.2 % of pixels. Localised evidence usually appears in regions.Fraud. Cropping, brightness and sharpening are edits too; read the findings and look at the regions.
AI-generated (likely)A high-severity AI_MARKER in metadata, or vision likelyGenerated ≥ 0.75 together with spectrum.score ≥ 0.5. Never from one weak signal alone.Certainty. Generators without markers and with camera-like post-processing may read as inconclusive instead.
Reused photoSet only at claim level: the photo's pHash is within hamming 6 of a photo in another claim of yours. matchedClaimNumber tells you which.That this claim is the fraudulent one — it could be the earlier one, or a legitimate re-open. Check both.

Reference-aware mode

When a reference is supplied, the engine aligns the two images (downscaling the larger if sizes differ), diffs luma after a 3×3 median, and reports changed areas as regions with signals:["reference"] and a reference signal at reliability 1.0. This is the only mode that can state "these pixels changed" rather than "these pixels look inconsistent".

Signals explained

Ten signals, each with a score and a reliability. Reliability is lowered by degradation before fusion: no EXIF → metadata/thumbnail ×0.4; JPEG quality < 75 or < 0.5 MP → ela/noise/grid ×0.5; PNG/screenshot → quant/grid skipped; vision capped at 0.5. The fused score is a noisy-OR over score × reliability, capped so a single low-reliability signal can never exceed 0.45.

idMeasuresTypical finding codesLimits
metadataEXIF/XMP/IPTC/ICC, raw JPEG/PNG/WebP marker scan, C2PA/JUMBF presence and declared actions.EDITOR_SOFTWARE AI_MARKER XMP_HISTORY_EDITS DATES_MISMATCH NO_EXIF SOCIAL_DIMENSIONS GENERATOR_DIMENSIONS DECLARED_DIMS_MISMATCHMetadata is strippable and forgeable; its absence lowers confidence instead of raising suspicion. C2PA signatures not verified in v0.1.
thumbnailEmbedded EXIF thumbnail vs the main image (pHash hamming + mean abs diff).THUMBNAIL_MISMATCHNeeds a surviving thumbnail. Re-exports regenerate it.
quantQuantisation tables → quality estimate, standard-library tables, chroma subsampling, double-JPEG periodicity.STANDARD_IJG_TABLES QUALITY_ESTIMATE DOUBLE_JPEG_PERIODICITYJPEG only. Phones re-encode on share; standard tables alone are low severity.
elaRe-encode at q=90, per-cell |Δ| normalised by local texture, z-scored. Map + regions.mapWeak after heavy recompression; edges/text are texture-normalised but still noisy.
noiseResidual vs median filter per cell, robust fit noise = a + b·texture, anomaly in MAD units; chroma/luma ratio.mapDenoised, very dark or clipped regions give little signal.
grid8×8 block-grid phase strength per 64 px region vs the global grid.mapJPEG below ~q97 with a detectable grid only; skipped for PNG/screenshots.
copymoveQuantised DCT block hashing (16×16, stride 4) → clustered shift vectors.CLONE_DETECTEDRepetitive textures (shingles, brick) can match; requires ≥ 12 clustered matches. In-image clones only.
spectrumAzimuthal FFT profile of the residual: periodic peaks, roll-off slope.SPECTRAL_PERIODICITYLow reliability by design; contributes to AI verdicts only with metadata or vision agreement.
visionVision model with a strict JSON schema: lighting, shadows, perspective/scale, halos, generative tells, screenshot / photo-of-screen, likelyGenerated, likelyComposited.reasons[]Reliability ≤ 0.5 always. Skipped when not configured (public demo).
contextClaimContext vs metadata dates, GPS and device.CAPTURED_BEFORE_LOSS FAR_FROM_LOSS_LOCATION DEVICE_MISMATCH TAKEN_LONG_AFTER_LOSSDepends on honest EXIF. Missing date/GPS → inconclusive, not suspicious.

What it cannot do

  • Provide certainty. Scores are ordinal evidence; verdicts are for adjuster review.
  • See through degradation. Screenshots, messaging-app copies and heavy recompression remove the camera-level evidence. Expect inconclusive with degradation: heavy; ask for the original file.
  • Catch a perfect composite. Noise-, compression- and lighting-matched edits that are then re-photographed defeat single-image analysis. Cross-claim reuse and context checks are the backstop.
  • Verify C2PA signatures (v0.1 reports presence and declared actions only).
  • Trust the vision model on its own. It is capped at 0.5 reliability and never carries a verdict alone.
  • Judge the damage itself. ClaimProof tells you whether the photo is trustworthy, not whether the roof needs replacing.

Limits & formats

Image formatsJPEG, PNG, WebP. HEIC/HEIF transcoded to JPEG when image transcoding is enabled for the tenant, otherwise 415. Transcoding is reported as degradation transcoded_heic.
Image size≤ 25 MB per file (/v1/analyze, claim photos); ≤ 8 MB on the public demo.
ResolutionFull-resolution pixel analysis up to 6 MP. Larger images (most 12 MP phone photos) are downscaled through Cloudflare Images to about 5 MP before the pixel signals run; metadata, the EXIF thumbnail and the content hash still come from the original file, degradation.reasons gains resized_for_analysis and the pixel signals carry reduced reliability.
ZIP≤ 50 MB; entries that are not images, exceed 25 MB or sit under __MACOSX/ are skipped and listed as rejected.
Photos per claim≤ 200. Per request: ≤ 50 files in photos[].
Synchronous timeout/v1/analyze answers within 25 s or returns 500 INTERNAL with a requestId.
Retention90 days (storage lifecycle rule; sooner via claim deletion). Demo uploads are never stored.
Signed URLs7-day expiry.

Errors

All errors share one envelope. Log requestId — it is also in the X-Request-Id header on every response, success or failure.

{ "error": { "code": "RATE_LIMITED", "message": "Too many requests", "requestId": "req_…" } }
HTTPcodeWhen
400VALIDATIONMissing/invalid field, bad JSON, bad context.
401UNAUTHORIZEDMissing, revoked or out-of-scope key; expired portal token.
402QUOTA_EXCEEDEDPilot allowance exhausted.
404NOT_FOUNDUnknown claim / photo / analysis id (or not in your tenant).
413PAYLOAD_TOO_LARGEImage > 25 MB, ZIP > 50 MB, demo > 8 MB.
415UNSUPPORTED_FORMATNot JPEG/PNG/WebP (or HEIC without transcoding).
429RATE_LIMITEDPer-key or per-IP limit; honour Retry-After.
500INTERNALEngine or storage failure; retry once, then contact support with the requestId.

Changelog

v0.1 — 2026-11

  • Initial release of the /v1 API and engine mesh-fi/0.1: ten signals, reliability-weighted fusion, reference-aware mode.
  • Claims with multi-photo upload, ZIP ingestion workflow, cross-photo checks and ClaimReport.
  • Signed webhooks with 3 retries; signed 7-day file URLs; public in-memory demo endpoint.
  • Known limitations: C2PA manifests read but not cryptographically verified; HEIC requires the transcoding binding; self-service key rotation not yet in the portal.