Appearance
Read image metadata with the API
The inspect routes read a file's metadata and return it grouped by container: EXIF, GPS, vendor MakerNotes, XMP, IPTC, ICC profile and more. They run no uniquify job, store nothing and spend no balance. Use them to see what a source file carries before you process it, or to check what a finished variant ended up with.
This is the same read the Studio's Inspector shows.
| Route | What it reads |
|---|---|
POST /inspect | A file you upload in the request. |
GET /inspect/result/{jobId}/{index} | A finished variant of one of your jobs, read on the server. |
POST /inspect/forensics | A deeper check of metadata, container structure and C2PA provenance for an upload. |
All paths below are relative to the API base, https://api-ghost.fluidvip.com/api:
bash
API="https://api-ghost.fluidvip.com/api"Authentication and the inspect:read scope
Send your credential as a bearer token, like every other route (see Authentication):
Authorization: Bearer fg_sk_live_…The inspect routes also check a scope. An API key must carry inspect:read. A secret key has it by default, so a standard key can call these routes. A key without it gets 403 insufficient_scope, with what is missing in details:
json
{
"error": "insufficient_scope",
"message": "The API key is missing required scopes.",
"details": { "required": ["inspect:read"], "missing": ["inspect:read"] }
}Which keys can inspect
Secret keys carry inspect:read by default, along with variants:write and jobs:read. That covers keys created in the dashboard and sub-keys created through provisioning, and existing full keys gained the scope automatically. A key created with a narrowed scopes list keeps exactly the scopes it asked for, so it can inspect only if the list includes inspect:read. Management keys cannot inspect: they never carry data-plane scopes. Signed-in dashboard sessions are not scope-limited, which is how the Studio Inspector works.
For reading a finished variant, a team member also needs the owner's jobs permission (the same permission that lets them run jobs). Without it the route returns 403 forbidden.
Inspect an uploaded file
POST /inspect takes multipart/form-data with one file in the image field.
bash
curl -s -X POST "$API/inspect" \
-H "Authorization: Bearer $FLUIDGHOST_KEY" \
-F "image=@photo.jpg"Accepted files are the same as for jobs: JPEG, PNG, WebP, TIFF, HEIC, AVIF, MP4 and MOV. The API checks the file's leading bytes, not its name or extension, so a renamed file is judged by what it really is. The upload limit is 100 MB.
Inspect a finished variant
To compare a result with its source, read a variant's metadata on the server. index is the 0-based variant number from the job's variants array.
bash
curl -s "$API/inspect/result/8f3c1a90-…/0" \
-H "Authorization: Bearer $FLUIDGHOST_KEY"The API fetches the produced file from storage itself, so your client never has to download it or fetch it cross-origin. Two things to know:
- The variant must be finished. A variant that is still processing, failed, or does not exist returns
404 not_found. - Inspect before you download. One-time results are deleted after a successful download and purged after their expiry anyway (see One-time results). Once the file is gone there is nothing left to read.
Like every job route, this one is owner-scoped: a job that is not yours returns 404 not_found. The API never says whether someone else's job exists.
The response
Both routes return the same shape, the InspectResult type:
ts
interface InspectResult {
/** group label -> { tag: value } */
groups: Record<string, Record<string, string | number | boolean>>;
/** total tags across all groups */
tagCount: number;
/** container sniffed from the file's leading bytes */
format?: string;
/** pixel dimensions, when the metadata states them */
dimensions?: { width: number; height: number };
}An example for a phone JPEG, shortened. Exact groups and tags depend on the file:
json
{
"groups": {
"File": { "FileType": "JPEG", "MIMEType": "image/jpeg", "ImageWidth": 4032, "ImageHeight": 3024 },
"IFD0": { "Make": "Apple", "Model": "iPhone 15 Pro", "Software": "17.5.1" },
"ExifIFD": { "ExposureTime": "1/120", "ISO": 400, "ExifImageWidth": 4032, "ExifImageHeight": 3024 },
"GPS": { "GPSLatitudeRef": "North", "GPSLongitudeRef": "West" },
"Apple": { "RunTimeFlags": "Valid" },
"ICC_Profile": { "ProfileDescription": "Display P3" },
"Composite": { "Megapixels": 12.2 }
},
"tagCount": 84,
"format": "jpeg",
"dimensions": { "width": 4032, "height": 3024 }
}Group labels
Metadata is read with ExifTool, so group labels are ExifTool's family 1 groups: IFD0, ExifIFD, GPS, IFD1, XMP-…, IPTC, ICC_Profile, a MakerNotes group named after the camera maker (for example Apple, Canon, Sony), Composite, and so on. Vendor MakerNotes are decoded.
If ExifTool cannot run, the API falls back to a lighter reader and uses these labels instead: JFIF, EXIF (IFD0), EXIF, GPS, Interop, MakerNotes (Apple), Thumbnail, IPTC, XMP, XMP (Regions), XMP (IPTC Ext), ICC Profile. Match on tag names rather than hard-coding one set of group labels.
Local file-system details (temporary file name, path, size, dates and permissions) are removed. They describe the server's copy, not your file.
Values
- Values are strings, numbers or booleans. Nested structures are left out.
- Strings longer than 256 characters are cut and end in
…. - Lists of up to 12 simple values are joined with
,. Longer lists become a summary such as‹40 values›. - Binary tags (thumbnails, embedded previews) appear as ExifTool's summary text, for example
(Binary data 9640 bytes, use -b option to extract). The bytes are not returned.
format and dimensions
format is one of jpeg, png, webp, tiff, heic, heif, avif or unknown. MP4 and MOV share the ISO base media container with HEIF, so a video can come back as heif. For the real type, read File.FileType or File.MIMEType.
dimensions is filled from the EXIF pixel size, or else from the image size ExifTool reports. It is omitted when neither is present.
Deep check: POST /inspect/forensics
Same request as POST /inspect (one image file), same scope and rate limit. It runs a forensic engine over the file's metadata, container structure and C2PA provenance and returns findings instead of raw tags:
ts
interface ForensicReport {
status: 'ok' | 'unavailable' | 'error';
reason?: string; // when status is not 'ok'
verdict?: string;
format?: string;
dimensions?: [number, number];
c2pa?: { status: string; trust?: string };
deviceClaim?: { make?: string | null; model?: string | null };
findings: Array<{
severity: 'high' | 'medium' | 'low' | 'informational';
id: string;
category: string;
confidence?: string;
evidence: Record<string, unknown>;
limitations?: string;
recommendation?: string;
}>;
tool?: { name: string; version?: string };
scopeNote: string; // always present
}This route always answers 200, even when the engine is not available on the server: check status before reading verdict or findings. scopeNote states what the check covers. It reads metadata, container and provenance only, not pixel-level signals, so treat a clean result as "the metadata layer is consistent" and nothing more.
Errors
Errors use the API's standard body, { "error", "message", "details"? } (see Errors).
| Status | error | When |
|---|---|---|
400 | bad_request | No file, an empty file, a file in a field other than image, or an unsupported type. |
401 | unauthorized | Missing, malformed, unknown or revoked key. |
403 | insufficient_scope | The key lacks inspect:read (a narrowed secret key, or a management key). |
403 | forbidden | Variant read by a team member without the jobs permission. |
404 | not_found | Variant read: no such job or variant, not finished, or not yours. |
413 | payload_too_large | The upload is over 100 MB. |
429 | rate_limited | Over the inspect limit. Wait for Retry-After seconds. |
Rate limits
POST /inspect, POST /inspect/forensics, POST /inspect/compare and, for API keys only, GET /inspect/result/{jobId}/{index} share one per-owner bucket, separate from job creation (browser sessions are not limited on that GET). The default is 60 requests per 60-second window (INSPECT_RATE_LIMIT_MAX). Every response from these routes carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, and a 429 adds Retry-After. Limits can be tuned per deployment, so build on the headers rather than the number. See Rate limits.
Examples
The official SDKs (fluidghost for Node and Python, version 2.1.0 or newer) wrap every inspect route: inspect, inspectForensics, inspectCompare and inspectResult in TypeScript, inspect, inspect_forensics, inspect_compare and inspect_result in Python. See Official SDKs. The curl calls above are the raw HTTP form of the same routes.
Node.js (18+)
js
import { FluidGhost } from "fluidghost";
const fg = new FluidGhost({ apiKey: process.env.FLUIDGHOST_KEY });
const meta = await fg.inspect({ path: "photo.jpg" });
console.log(meta.format, meta.tagCount, Object.keys(meta.groups));Run a job, then inspect each finished variant before downloading it:
js
const job = await fg.run({ image: { path: "photo.jpg" } });
for (const v of job.variants.filter((v) => v.status === "completed")) {
const meta = await fg.inspectResult(job.jobId, v.index);
console.log(v.index, meta.groups.IFD0?.Model, meta.tagCount);
const bytes = await fg.downloadVariant(job.jobId, v.index); // one-time: inspect first
}The job call needs a key with variants:write; the inspect calls need inspect:read. Errors throw FluidGhostError with status and code.
Python
python
import os
from fluidghost import FluidGhost
fg = FluidGhost(api_key=os.environ["FLUIDGHOST_KEY"])
meta = fg.inspect("photo.jpg")
print(meta["format"], meta["tagCount"], list(meta["groups"]))Inspect a variant before its one-time download:
python
job = fg.run("photo.jpg")
for v in job["variants"]:
if v["status"] == "completed":
meta = fg.inspect_result(job["jobId"], v["index"])
print(v["index"], meta["groups"].get("IFD0", {}).get("Model"), meta["tagCount"])
data = fg.download_variant(job["jobId"], v["index"]) # one-time: inspect firstCompare a source with a result
A simple diff: inspect the source upload and the variant, then compare tag by tag.
js
function diff(a, b) {
const out = [];
for (const g of new Set([...Object.keys(a.groups), ...Object.keys(b.groups)])) {
const A = a.groups[g] ?? {}, B = b.groups[g] ?? {};
for (const t of new Set([...Object.keys(A), ...Object.keys(B)])) {
if (A[t] !== B[t]) out.push({ group: g, tag: t, before: A[t], after: B[t] });
}
}
return out;
}Group labels can differ between the two reads if one falls back to the lighter reader, so compare files read in the same session.
No account? The free photo check on our site shows the same kind of read in your browser.
See also: Inspect reference · Rate limits · Errors