@capseal/sdk
v0.1.0
Published
Verify where a photo, video or document came from. Reads C2PA manifests, EXIF and XMP, and tells you how it knows - never a confidence score it cannot justify.
Maintainers
Readme
CapSeal
Find out where a photo, video or document actually came from.
C2PA manifests, EXIF, XMP — read in one call, with an answer that tells you how it knows.
Try it now, no signup · Free API key · Docs
npm i @capseal/sdkimport { CapSeal } from "@capseal/sdk";
import { readFileSync } from "node:fs";
const capseal = new CapSeal({ apiKey: process.env.CAPSEAL_API_KEY });
const { verdict } = await capseal.inspect(readFileSync("photo.jpg"));
console.log(verdict.verdict); // "AI_GENERATED"
console.log(verdict.basis); // "declared"
console.log(verdict.headline); // "This file says it was generated by Adobe Firefly."That's the whole integration. Get a key — free, instant, no card.
Why you need this
You accept files from people. A claims photo, a KYC selfie, an insurance inspection, a marketplace listing, a signed PDF, a piece of user-generated content. Until recently you could assume that a photograph was a photograph.
That assumption is gone. Anyone can generate a convincing image of damage that never happened, in seconds, for free. Meanwhile the countermeasure is arriving: cameras, phones and the major generative tools are all shipping C2PA Content Credentials — a signed record, embedded in the file, of where it came from and what was done to it.
The problem is that reading it correctly is fiddly and most implementations get it wrong. Manifests span multiple JPEG segments. Assertions are CBOR inside JUMBF boxes. The interesting facts are scattered across the manifest, EXIF and XMP, and they contradict each other in informative ways.
This SDK is one call that reads all of it and gives you a straight answer.
What makes the answer trustworthy
Most tools in this space return a percentage. 0.87 likely AI. That number is
almost always invented, and it fails in the direction that hurts: a screenshot of
a real photograph gets flagged, a generated image that has been re-encoded gets
waved through.
CapSeal returns a basis — how the answer was reached:
| basis | what happened | automate on it? |
| --- | --- | --- |
| proven | cryptography settles it | yes |
| declared | the file says so, and a signature backs the file | yes |
| claimed | the file says so, nothing backs the claim | no — review |
| indicative | metadata leans that way, could be wrong | no — review |
| unknown | the file does not say, and neither will we | no — review |
const { verdict } = await capseal.inspect(bytes);
if (verdict.basis === "proven" || verdict.basis === "declared") {
act(verdict.verdict); // safe to route automatically
} else {
review(verdict.headline); // a person should look
}Build on basis and your logic stays correct as detection improves, because you
never encoded a guess as a fact.
Why there is no "is it AI?" score
An unsigned JPEG with no metadata is a grid of pixels. Nothing in those bytes distinguishes:
- a genuine photograph that WhatsApp stripped the metadata from
- a lightly edited photograph
- a diffusion model's output
Anything claiming otherwise is guessing, and defeating it costs an attacker one
screenshot. So for that case you get NO_PROVENANCE / unknown and a sentence
explaining why — which is less satisfying and considerably more useful, because
you can build on it.
When a file does carry provenance — and an increasing share do — you get a definite answer with the receipts attached.
Quick start
1. Get a key at capseal.ai/account. Free, no card, 1,000 verifications a day.
2. Install and call.
npm i @capseal/sdk
export CAPSEAL_API_KEY=csk_live_...import { CapSeal } from "@capseal/sdk";
const capseal = new CapSeal({ apiKey: process.env.CAPSEAL_API_KEY });
const result = await capseal.inspect(fileBytes);3. Or try it first with no signup at all:
curl -X POST --data-binary @photo.jpg https://capseal.ai/api/demo/inspectExamples
Runnable versions of everything below are in examples/.
Node — the basics
import { CapSeal } from "@capseal/sdk";
import { readFileSync } from "node:fs";
const capseal = new CapSeal({ apiKey: process.env.CAPSEAL_API_KEY });
const { verdict, report } = await capseal.inspect(readFileSync("evidence.jpg"));
console.log(verdict.headline);
console.log(`basis: ${verdict.basis}`);
if (report.c2pa.present) {
const m = report.c2pa.activeManifest;
console.log(`signed by ${m?.claimGenerator}`);
console.log(`algorithm ${m?.signatureAlgorithm}`);
console.log(`actions ${m?.actions.map((a) => a.action).join(", ")}`);
}
if (report.exif?.make) {
console.log(`camera ${report.exif.make} ${report.exif.model}`);
}Express — gate an upload
import express from "express";
import { CapSeal } from "@capseal/sdk";
const capseal = new CapSeal({ apiKey: process.env.CAPSEAL_API_KEY });
const app = express();
app.post("/claims/:id/evidence",
express.raw({ type: "*/*", limit: "25mb" }),
async (req, res) => {
const { verdict, report } = await capseal.inspect(req.body);
// Refuse only what is provably wrong. Everything uncertain goes to a human,
// because rejecting an honest customer costs more than a review does.
if (verdict.verdict === "AI_GENERATED" && verdict.basis === "declared") {
return res.status(422).json({
accepted: false,
reason: verdict.headline,
evidence: verdict.supportedBy,
});
}
await store(req.params.id, req.body, {
sha256: report.file.sha256,
verdict: verdict.verdict,
basis: verdict.basis,
needsReview: verdict.basis !== "declared" && verdict.basis !== "proven",
});
res.json({ accepted: true, verdict: verdict.verdict, basis: verdict.basis });
});Next.js — a route handler
// app/api/verify/route.ts
import { CapSeal } from "@capseal/sdk";
import { NextResponse } from "next/server";
const capseal = new CapSeal({ apiKey: process.env.CAPSEAL_API_KEY! });
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof Blob)) {
return NextResponse.json({ error: "no file" }, { status: 400 });
}
const { verdict, report } = await capseal.inspect(file);
return NextResponse.json({
verdict: verdict.verdict,
basis: verdict.basis,
headline: verdict.headline,
sha256: report.file.sha256,
});
}Browser — never put the key in the client
// The key belongs on your server. This posts to your own endpoint, which calls
// CapSeal. A key shipped to a browser is a key you have given away.
const response = await fetch("/api/verify", { method: "POST", body: file });
const { verdict, basis, headline } = await response.json();
document.querySelector("#result").textContent =
basis === "declared" || basis === "proven"
? headline
: `${headline} (needs a human: ${basis})`;Cloudflare Workers
import { CapSeal } from "@capseal/sdk";
export default {
async fetch(request: Request, env: { CAPSEAL_API_KEY: string }) {
const capseal = new CapSeal({ apiKey: env.CAPSEAL_API_KEY });
const { verdict } = await capseal.inspect(await request.arrayBuffer());
return Response.json(verdict);
},
};Handling every verdict
const { verdict } = await capseal.inspect(bytes);
switch (verdict.verdict) {
case "SEALED_BY_CAPSEAL":
// Captured through a CapSeal SDK. Integrity signals were measured on the
// device at the moment of capture.
break;
case "AI_GENERATED":
// A generator declared itself, in a signed manifest or in XMP.
break;
case "EDITED":
// The file records edits: crops, colour work, composites.
break;
case "PROVENANCE_PRESENT":
// C2PA provenance from a camera or tool, nothing notable declared.
break;
case "CAMERA_ORIGINAL_LIKELY":
// MakerNote, capture timestamp, no editor named. Indicative, not proof.
break;
case "NO_PROVENANCE":
// Nothing to go on. Common and not suspicious on its own.
break;
}Verify a proof with no key and no network
If someone hands you a CapSeal proof object, checking it through our API means trusting us about our own work. Don't.
npm i @capseal/wasmimport { loadCapseal } from "@capseal/wasm";
import { readFileSync } from "node:fs";
const capseal = await loadCapseal(
readFileSync("node_modules/@capseal/wasm/capseal.wasm"),
);
const result = capseal.replay({ signedProof, proofPublicKeyBase64, assetBase64 });
result.signatureValid // we issued it, unedited
result.reasoningReplays // the conclusions follow from the checks
result.evidenceReplays // the checks follow from the file
result.trustworthyThat module imports nothing — it cannot open a socket or read your disk, and you can confirm that in one line rather than take our word for it:
WebAssembly.Module.imports(new WebAssembly.Module(bytes)); // []It exists precisely so a verdict never depends on our goodwill or our uptime.
What it reads
C2PA / Content Credentials — the full manifest: claim generator, every assertion, actions, ingredients, signature algorithm. Multi-segment manifests are reassembled correctly (a manifest routinely exceeds JPEG's 64KB segment limit, and readers that miss this report valid files as corrupt).
EXIF — every IFD0, ExifIFD and GPS tag, with the provenance-relevant ones surfaced: camera make and model, MakerNote presence (editors usually destroy it), Software, capture and modify times, coordinates.
XMP — CreatorTool, edit history, and the IPTC digitalSourceType vocabulary,
which is how a generative model declares itself.
PDF — Producer and Creator, plus the count of appended revisions, which is direct evidence of modification after writing.
Containers — JPEG, PNG, WebP, GIF, TIFF, HEIC, AVIF, MP4, QuickTime, WebM, PDF, SVG. Detected by magic bytes, never by the filename you were given.
The full report
verdict is the answer. report is everything it was based on — so if you
disagree, the material to argue with is in the same response.
report.file.sha256 // ties the result to exact bytes
report.file.container // "jpeg"
report.c2pa.activeManifest?.claimGenerator
report.c2pa.activeManifest?.actions // [{ action: "c2pa.color_adjustments" }]
report.c2pa.activeManifest?.assertions // every one, with size and kind
report.exif // every tag
report.xmp // parsed, plus the raw packet
report.evidence // each observation, with its source
report.warnings // anything that failed to parseErrors you can act on
import { CapSealError } from "@capseal/sdk";
try {
await capseal.inspect(bytes);
} catch (error) {
if (error instanceof CapSealError) {
error.status; // 401, 413, 429 ...
error.retryable; // true on 429
error.message; // "That API key was revoked. Issue a new one at ..."
}
}The platform distinguishes not recognised from revoked from over quota, and the SDK passes that through rather than collapsing it into "request failed".
Watch your quota without guessing:
const { quota } = await capseal.inspect(bytes);
if (quota && quota.remaining < 50) warnOperations();Reference
new CapSeal({
apiKey: string, // required — https://capseal.ai/account
baseUrl?: string, // defaults to https://capseal.ai
fetch?: typeof fetch, // for tests or a proxy
timeoutMs?: number, // defaults to 30000
});| method | does |
| --- | --- |
| inspect(file) | read a file's provenance. Takes Uint8Array, ArrayBuffer, Buffer or Blob |
| replay(proof) | re-check a proof object we issued |
| capabilities() | what this deployment does, and what it deliberately will not |
Node 18+, Deno, Bun, browsers, Cloudflare Workers. Types included. No runtime dependencies.
FAQ
Can you tell me if an image is AI-generated? If the generator declared it — which Firefly, and a growing number of others, do via C2PA — then yes, definitively, with the manifest as evidence. If it didn't, then no, and neither can anyone else reliably. We say so rather than guess.
What about a photo with the metadata stripped?
NO_PROVENANCE / unknown. That is the honest answer: stripping is what every
messaging app does to perfectly genuine photographs, so its absence tells you
almost nothing. Route those to a human.
Do you store my files?
No. Files are read in memory and discarded. The response says so explicitly in
retention.
Is the SDK open source?
Yes — github.com/CapSeal-ai/sdk. Verification
runs on the platform, which is what makes the API key meaningful. If you want
verification that runs entirely on your machine, that is
@capseal/wasm, and it needs no
key at all.
How do I stop this happening in the first place?
Read provenance and you are always reacting. Seal at capture and you are not:
the CapSeal capture SDKs for iOS and Android measure integrity signals on the
device — parallax against the IMU, depth relief, screen-replay detection,
hardware attestation — and bind them into the manifest at the moment the shutter
fires. That turns unknown into proven. See
capseal.ai/platform.
Limits
- 25 MB per file
- 1,000 calls a day on the free tier
- Nothing stored
By SYPTime Pty Ltd · Issues · capseal.ai
