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
- Get a tenant. Tenants are created by Ward Tech during onboarding (request a pilot). You receive one API key — shown once — and portal access.
- Check connectivity.
curl https://claimproof.wardtechsystems.com/v1/health
# {"ok":true,"engine":"mesh-fi/0.1","version":"…","time":"…"}- 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) andadmin. A call outside the key's scopes returns401 UNAUTHORIZED. - The portal exchanges a key for a 24-hour session token via
POST /v1/portal/session; the API accepts either interchangeably in theAuthorizationheader. - 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
| Plan | Requests / min per key | Included images / month | Over the allowance |
|---|---|---|---|
| pilot | 30 | 300 (30 days) | 402 QUOTA_EXCEEDED — hard stop |
| starter | 120 | 2,500 | $0.18 / image, metered |
| carrier | 600 | 20,000 | $0.12 / image, metered |
| enterprise | 2,000 | 250,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
Synchronous. Returns within ~25 s (typically 2–8 s). Accepts either multipart form data or JSON with URLs.
Request (multipart)
| Field | Type | Notes |
|---|---|---|
image | file | Required. JPEG / PNG / WebP ≤ 25 MB. HEIC only when image transcoding is enabled for the tenant. |
reference | file | Optional. The known original. Switches on reference-aware mode: pixel-level diff with reliability 1.0. |
context | JSON string | Optional 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}}'import { readFile } from 'node:fs/promises';
export async function analyze(path, context) {
const form = new FormData();
form.append('image', new Blob([await readFile(path)], { type: 'image/jpeg' }), 'photo.jpg');
if (context) form.append('context', JSON.stringify(context));
const res = await fetch('https://claimproof.wardtechsystems.com/v1/analyze', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.CLAIMPROOF_KEY}` },
body: form,
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
}
return res.json(); // { id, analysis, annotatedUrl, storedUntil }
}import json, os, requests
def analyze(path: str, context: dict | None = None) -> dict:
r = requests.post(
"https://claimproof.wardtechsystems.com/v1/analyze",
headers={"Authorization": f"Bearer {os.environ['CLAIMPROOF_KEY']}"},
files={"image": open(path, "rb")},
data={"context": json.dumps(context)} if context else None,
timeout=60,
)
if r.status_code >= 400:
err = r.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']} (request {err['requestId']})")
return r.json()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.
{
"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" } }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" } ] }{
"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 */ }
}Full per-photo detail: { photoId, filename, status, analysis, originalUrl, annotatedUrl }.
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.
Paged list, newest first. Follow nextCursor until it is absent.
Deletes the claim, its photos, analyses and stored files. 204. Irreversible.
ZIP ingestion
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| Event | When | Body |
|---|---|---|
claim.photo.analyzed | each photo finishes | { claimId, photoId, verdict, score, confidence } |
claim.completed | last photo done, report built | { claimId, claimNumber, report: ClaimReport } |
claim.failed | unrecoverable claim error | { claimId, error } |
webhook.test | POST /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);
});import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hooks/claimproof")
def claimproof_hook():
body = request.get_data() # raw bytes, not parsed JSON
expected = "sha256=" + hmac.new(
os.environ["CLAIMPROOF_WEBHOOK_SECRET"].encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(request.headers.get("X-ClaimProof-Signature", ""), expected):
abort(401)
event = request.headers.get("X-ClaimProof-Event")
payload = request.get_json(force=True)
if event == "claim.completed":
queue_for_adjuster(payload["claimId"], payload["report"])
return "", 200Sends a signed webhook.test event to the tenant's URL so you can verify your receiver before the first real claim.
Files & signed URLs
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
{ "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.
| Field | Type | Meaning |
|---|---|---|
engine | string | Engine version, e.g. mesh-fi/0.1. Pin your thresholds to it. |
image | object | width, height, format (jpeg|png|webp), bytes, sha256, phash (64-bit DCT perceptual hash, 16 hex), estimatedJpegQuality, megapixels. |
verdict | enum | authentic_likely · inconclusive · edited_likely · ai_generated_likely · reused_photo. See semantics. |
score | 0–1 | Manipulation suspicion. An ordinal, not a probability: 0.8 is more suspicious than 0.6, it is not "80 % likely edited". |
confidence | 0–1 | How 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. |
degradation | object | level: none|light|heavy, reasons[] from social_media_resize, no_exif, screenshot, heavy_jpeg, tiny_image, transcoded_heic. |
signals[] | array | One 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[] | array | Fused suspicious regions in full-resolution image coordinates, sorted by score, max 8: { id, x, y, w, h, score, signals[], note }. |
heatmap | object | { cols, rows, values[] } row-major fused suspicion grid for client overlay (the portal and demo draw it on a canvas). |
metadata | object | Normalised, 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}. |
context | object | null | Only when a ClaimContext was supplied: capturedBeforeLoss, daysFromLoss, distanceFromLossKm, deviceMatchesExpected (null when unknowable). |
summary | string | 2–4 plain-English sentences for an adjuster: the top findings and the degradation level. |
timingMs | object | { decode, total } milliseconds. |
warnings[] | array | Operational 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.
| Field | Meaning |
|---|---|
riskScore · riskLevel | 0–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. |
recommendation | fast_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 · verdictCounts | Total 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 · generatedAt | Plain-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.
| Verdict | Means | Does 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. |
| Inconclusive | Evidence 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 photo | Set 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.
| id | Measures | Typical finding codes | Limits |
|---|---|---|---|
| metadata | EXIF/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_MISMATCH | Metadata is strippable and forgeable; its absence lowers confidence instead of raising suspicion. C2PA signatures not verified in v0.1. |
| thumbnail | Embedded EXIF thumbnail vs the main image (pHash hamming + mean abs diff). | THUMBNAIL_MISMATCH | Needs a surviving thumbnail. Re-exports regenerate it. |
| quant | Quantisation tables → quality estimate, standard-library tables, chroma subsampling, double-JPEG periodicity. | STANDARD_IJG_TABLES QUALITY_ESTIMATE DOUBLE_JPEG_PERIODICITY | JPEG only. Phones re-encode on share; standard tables alone are low severity. |
| ela | Re-encode at q=90, per-cell |Δ| normalised by local texture, z-scored. Map + regions. | map | Weak after heavy recompression; edges/text are texture-normalised but still noisy. |
| noise | Residual vs median filter per cell, robust fit noise = a + b·texture, anomaly in MAD units; chroma/luma ratio. | map | Denoised, very dark or clipped regions give little signal. |
| grid | 8×8 block-grid phase strength per 64 px region vs the global grid. | map | JPEG below ~q97 with a detectable grid only; skipped for PNG/screenshots. |
| copymove | Quantised DCT block hashing (16×16, stride 4) → clustered shift vectors. | CLONE_DETECTED | Repetitive textures (shingles, brick) can match; requires ≥ 12 clustered matches. In-image clones only. |
| spectrum | Azimuthal FFT profile of the residual: periodic peaks, roll-off slope. | SPECTRAL_PERIODICITY | Low reliability by design; contributes to AI verdicts only with metadata or vision agreement. |
| vision | Vision 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). |
| context | ClaimContext vs metadata dates, GPS and device. | CAPTURED_BEFORE_LOSS FAR_FROM_LOSS_LOCATION DEVICE_MISMATCH TAKEN_LONG_AFTER_LOSS | Depends 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
inconclusivewithdegradation: 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 formats | JPEG, 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. |
| Resolution | Full-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. |
| Retention | 90 days (storage lifecycle rule; sooner via claim deletion). Demo uploads are never stored. |
| Signed URLs | 7-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_…" } }| HTTP | code | When |
|---|---|---|
| 400 | VALIDATION | Missing/invalid field, bad JSON, bad context. |
| 401 | UNAUTHORIZED | Missing, revoked or out-of-scope key; expired portal token. |
| 402 | QUOTA_EXCEEDED | Pilot allowance exhausted. |
| 404 | NOT_FOUND | Unknown claim / photo / analysis id (or not in your tenant). |
| 413 | PAYLOAD_TOO_LARGE | Image > 25 MB, ZIP > 50 MB, demo > 8 MB. |
| 415 | UNSUPPORTED_FORMAT | Not JPEG/PNG/WebP (or HEIC without transcoding). |
| 429 | RATE_LIMITED | Per-key or per-IP limit; honour Retry-After. |
| 500 | INTERNAL | Engine or storage failure; retry once, then contact support with the requestId. |
Changelog
v0.1 — 2026-11
- Initial release of the
/v1API and enginemesh-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.