Build on proof that a photo is real
The prfd API seals photos at the moment they are taken, checks photos your customers upload for signs of fakery, and lets anyone verify a sealed file. One API key, plain JSON over HTTPS.
Every request goes to https://app.prfd.io/v1. Requests and responses are JSON, except where you upload a file (multipart form) or receive a sealed file (application/vnd.prfd).
Quickstart
Check your first photo in two steps.
1. Create an API key
Log in at app.prfd.io and open API keys. Create a key and copy it straight away: it is shown once and then only its hash is kept. Store it as an environment variable rather than in your code.
export PRFD_KEY=prfd_live_...
2. Check a photo
Send any photo to the check endpoint. The response tells you whether it passed, and why.
curl https://app.prfd.io/v1/analyze/file \ -H "Authorization: Bearer $PRFD_KEY" \ -F photo=@claim.jpg \ -F claim_id=RMA-20931 \ -F item=lipstick
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("photo", new Blob([await readFile("claim.jpg")]), "claim.jpg");
form.append("claim_id", "RMA-20931");
form.append("item", "lipstick");
const res = await fetch("https://app.prfd.io/v1/analyze/file", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PRFD_KEY}` },
body: form,
});
const check = await res.json();
console.log(check.verdict, check.signals);
import os, requests
with open("claim.jpg", "rb") as photo:
res = requests.post(
"https://app.prfd.io/v1/analyze/file",
headers={"Authorization": f"Bearer {os.environ['PRFD_KEY']}"},
files={"photo": photo},
data={"claim_id": "RMA-20931", "item": "lipstick"},
)
check = res.json()
print(check["verdict"], check["signals"])
A photo straight from a phone camera usually comes back ok. One that has lost its camera data comes back review, with a link for the shopper to take a live photo (see Step-up):
{
"verdict": "review",
"score": 0.4475,
"signals": [
{
"name": "exif.missing_camera_data",
"severity": 0.35,
"detail": "no camera make in the file's metadata, which a photo straight from a phone carries and a generated image usually does not"
},
{
"name": "jpeg.software_quant_table",
"severity": 0.15,
"detail": "quantization table is libjpeg's standard table at quality 85, which software encoders write"
}
],
"request_id": "req_8f2c41d07a",
"analysis_id": "an_3b91e0c2",
"claim_id": "RMA-20931",
"order_id": null,
"claim_amount": null,
"currency": null,
"reuse": null,
"step_up": {
"status": "pending",
"session_id": "cs_71d0a9e4",
"capture_url": "https://app.prfd.io/c/Qm9u...",
"expires_at": "2026-11-08T18:45:00+00:00",
"manifest_id": null,
"verdict": null
},
"test": false
}Authentication
Send your API key as a bearer token on every request. Keys look like prfd_live_7f3c1a9e_...; the short part after live_ is a public prefix you can safely show in logs, and the rest is the secret.
Authorization: Bearer prfd_live_7f3c1a9e_...
Each key carries scopes, chosen when you create it. A request outside the key's scopes is refused with 403 forbidden_scope.
| Scope | Allows |
|---|---|
| sign | Sealing photos, capture links and capture tickets |
| analyze | Checking photos, looking up checks, the returns summary |
| read | Listing sealed evidence, account details and usage |
Call the API from your server, never from a browser or a mobile app's bundled code, where the key can be extracted. For capture on a phone, create a capture link on your server and open it on the device; the link carries no key.
Verifying a sealed file and fetching the public signing keys need no key at all.
Test keys
Create a test key while you integrate. Keys starting prfd_test_ work like live keys, with three differences:
- They are free: each account gets 500 test photos a month that never touch its paid allowance.
- A file sealed with a test key does not verify. Anyone who checks it is told it is a test seal, so it can never be passed off as evidence.
- Test checks are left out of your reports, never matched against live photos for reuse, and kept for 30 days at most.
Responses, sealed-evidence records and webhooks made with a test key carry "test": true, and a test seal comes back with the header X-Prfd-Test: true.
Proof of delivery with a capture link
The quickest way to collect sealed delivery photos is prfd's hosted capture page. Your server asks for a link, the courier opens it on their phone, takes the photo through the live camera, and prfd seals it on the spot with the time and GPS position. There is no gallery picker, so an old photo cannot be submitted, and the seal means nobody can change the photo afterwards.
- Create a link for the delivery. Each link works once and expires after 15 minutes.
- Open the
urlon the courier's phone, in your app's in-app browser or as a link in a message. - The courier takes the photo. The page asks for camera and location permission, seals the photo and, if you gave a
return_url, sends them back to your app. - Look up the session, or receive a webhook, to get the sealed photo's
manifest_idand verdict. - Download the sealed
.prfdfile withGET /v1/manifests/{manifest_id}/file, or from the photo's page in the dashboard. That file is the evidence you hand to an insurer, a bank or a customer.
curl https://app.prfd.io/v1/capture/sessions \
-H "Authorization: Bearer $PRFD_KEY" \
-H "Content-Type: application/json" \
-d '{
"delivery_id": "NW-48210",
"carrier_code": "northwind",
"return_url": "northwind://delivery/NW-48210/photo"
}'
const res = await fetch("https://app.prfd.io/v1/capture/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PRFD_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
delivery_id: "NW-48210",
carrier_code: "northwind",
return_url: "northwind://delivery/NW-48210/photo",
}),
});
const { id, url } = await res.json();
// Open `url` on the courier's phone.
import os, requests
res = requests.post(
"https://app.prfd.io/v1/capture/sessions",
headers={"Authorization": f"Bearer {os.environ['PRFD_KEY']}"},
json={
"delivery_id": "NW-48210",
"carrier_code": "northwind",
"return_url": "northwind://delivery/NW-48210/photo",
},
)
session = res.json()
# Open session["url"] on the courier's phone.
The capture page only opens the camera on a phone. On a computer it shows a QR code to scan with a phone instead, and moves on by itself once the photo has been sent. When the person is sent back to your return_url, prfd adds a prfd_session parameter so you know which capture finished. The verdict is never put in the URL; read it from the API.
Capture in your own app
If your app has its own camera screen, seal photos from your server with POST /v1/sign. To prove the photo was taken live rather than picked from the camera roll, ask for a one-time capture ticket the moment the camera opens and send it with the photo.
- When the camera opens, your app asks your server for a ticket, and your server calls
POST /v1/capture/begin. The ticket works once and expires 60 seconds after it is issued. - When the photo is taken, your app sends it with the ticket to your server, which calls
POST /v1/signwith the photo and its metadata. - prfd returns the sealed
.prfdfile. Store it as your evidence.
# When the camera opens
curl https://app.prfd.io/v1/capture/begin \
-H "Authorization: Bearer $PRFD_KEY" \
-H "Content-Type: application/json" \
-d '{"device_fingerprint": "d41d8cd98f00b204", "app_id": "io.northwind.driver"}'
# When the photo is taken
curl https://app.prfd.io/v1/sign \
-H "Authorization: Bearer $PRFD_KEY" \
-F photo=@doorstep.jpg \
-F 'metadata={
"delivery_id": "NW-48210",
"carrier_code": "northwind",
"device": {"fingerprint": "d41d8cd98f00b204", "platform": "ios",
"app_version": "3.1.0", "app_id": "io.northwind.driver"},
"location": {"latitude": 40.7128, "longitude": -74.006,
"accuracy_meters": 5, "captured_at": "2026-11-08T18:30:00Z"},
"capture": {"timestamp": "2026-11-08T18:30:00Z", "nonce": "b7f1c2d3e4a5",
"attestation": {"source": "in_app_camera",
"live_capture_token": "TICKET_FROM_BEGIN"}}
}' \
-o NW-48210.prfd
import { readFile, writeFile } from "node:fs/promises";
const auth = { Authorization: `Bearer ${process.env.PRFD_KEY}` };
const device = { fingerprint: "d41d8cd98f00b204", platform: "ios",
app_version: "3.1.0", app_id: "io.northwind.driver" };
// When the camera opens
const ticket = await fetch("https://app.prfd.io/v1/capture/begin", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ device_fingerprint: device.fingerprint, app_id: device.app_id }),
}).then((r) => r.json());
// When the photo is taken
const now = new Date().toISOString();
const form = new FormData();
form.append("photo", new Blob([await readFile("doorstep.jpg")]), "doorstep.jpg");
form.append("metadata", JSON.stringify({
delivery_id: "NW-48210",
carrier_code: "northwind",
device,
location: { latitude: 40.7128, longitude: -74.006, accuracy_meters: 5, captured_at: now },
capture: { timestamp: now, nonce: crypto.randomUUID(),
attestation: { source: "in_app_camera", live_capture_token: ticket.live_capture_token } },
}));
const res = await fetch("https://app.prfd.io/v1/sign", { method: "POST", headers: auth, body: form });
await writeFile("NW-48210.prfd", Buffer.from(await res.arrayBuffer()));
console.log(res.headers.get("X-Prfd-Verdict"), res.headers.get("X-Prfd-Manifest-Id"));
import json, os, uuid, requests
from datetime import datetime, timezone
auth = {"Authorization": f"Bearer {os.environ['PRFD_KEY']}"}
device = {"fingerprint": "d41d8cd98f00b204", "platform": "ios",
"app_version": "3.1.0", "app_id": "io.northwind.driver"}
# When the camera opens
ticket = requests.post(
"https://app.prfd.io/v1/capture/begin", headers=auth,
json={"device_fingerprint": device["fingerprint"], "app_id": device["app_id"]},
).json()
# When the photo is taken
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
metadata = {
"delivery_id": "NW-48210",
"carrier_code": "northwind",
"device": device,
"location": {"latitude": 40.7128, "longitude": -74.006,
"accuracy_meters": 5, "captured_at": now},
"capture": {"timestamp": now, "nonce": uuid.uuid4().hex,
"attestation": {"source": "in_app_camera",
"live_capture_token": ticket["live_capture_token"]}},
}
with open("doorstep.jpg", "rb") as photo:
res = requests.post("https://app.prfd.io/v1/sign", headers=auth,
files={"photo": photo}, data={"metadata": json.dumps(metadata)})
open("NW-48210.prfd", "wb").write(res.content)
print(res.headers["X-Prfd-Verdict"], res.headers["X-Prfd-Manifest-Id"])
The device fingerprint and app id you send with the photo must match the ones the ticket was issued for. An account can be set to require a ticket on every seal; ask us to turn this on.
Checking return and claim photos
When a customer uploads a photo to support a refund or claim, send it to POST /v1/analyze/file (or POST /v1/analyze with base64 JSON). prfd reads the file's structure, metadata and pixels, checks whether the photo was used before and, if you say what the item is, whether the photo shows one, and returns a verdict.
| Verdict | Meaning | What to do |
|---|---|---|
| ok | Nothing suggests the photo is fake, edited or reused | Continue the claim |
| review | Something is missing or unusual, such as camera data stripped by a messaging app | Send the shopper the step-up link for a live photo |
| reject | Strong evidence of fakery: AI generation, an editor, a screenshot, or a photo already used | Decline, or let the shopper prove it with a live photo |
Pass your own references with each check. They are stored with it, returned in webhooks, and power the returns report in the dashboard.
claim_idalso scopes reuse detection: the same photo sent again on the same claim is a resubmission, while the same photo on a different claim is flagged.itemsays what the photo should show, in a few words such aslipstickorperfume bottle. prfd checks the photo looks like one, so a genuine photo of something else (a water bottle for a lipstick claim) is flagged and the shopper is asked for a live photo of the right item. The same description is checked against that live photo.claim_amountandcurrencylet the dashboard show the value of flagged claims.
If the same photo, or a resized, recompressed or screenshotted copy of it, was used on an earlier claim or sealed as a delivery photo, the response carries a reuse object naming the earlier record. A sealed .prfd file sent to this endpoint passes on its intact seal.
Use an Idempotency-Key so a retry after a timeout returns the same check instead of counting the photo twice.
Step-up
Step-up turns a flagged photo into a decision without staff time. When a check comes back review or reject, prfd also creates a one-time capture link and returns it as step_up.capture_url. Show it to the shopper, or let a webhook send it. They take a live photo of the item on their phone, it is sealed on the spot, and the check is updated.
A genuine customer has the item in front of them and finishes in seconds. Someone claiming with an AI image or a photo found online has nothing real to photograph.
Step-up is on by default. Pass step_up_return_url to send the shopper back to your returns page afterwards, or step_up=false to handle flagged photos yourself. Look up the check with GET /v1/analyses/{id} to see the outcome:
"step_up": {
"status": "completed",
"session_id": "cs_71d0a9e4",
"capture_url": null,
"expires_at": "2026-11-08T18:45:00+00:00",
"manifest_id": "mf_0c4e2b19",
"verdict": "ok"
}The live photo's sealed file can be downloaded with GET /v1/manifests/{manifest_id}/file. The capture link is returned once, when it is created. If you lose it, repeat the request with the same Idempotency-Key and you get a fresh link while the old one is retired.
Verifying a sealed file
A .prfd file holds the photo, its metadata and the verdict, signed together by prfd. Changing any of them breaks the signature. Anyone can check one, with no account:
- In a browser at app.prfd.io/verify, which shows the photo, time, place and verdict and prints as an evidence pack.
- From code with
POST /v1/verify, which is free and needs no key.
curl https://app.prfd.io/v1/verify -F file=@NW-48210.prfd
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("NW-48210.prfd")]), "NW-48210.prfd");
const result = await fetch("https://app.prfd.io/v1/verify", { method: "POST", body: form })
.then((r) => r.json());
console.log(result.valid ? "Seal holds" : `Broken: ${result.failure_reason}`);
import requests
with open("NW-48210.prfd", "rb") as f:
result = requests.post("https://app.prfd.io/v1/verify", files={"file": f}).json()
print("Seal holds" if result["valid"] else f"Broken: {result['failure_reason']}")
A file that fails verification still returns 200, with "valid": false and a reason, so your code handles one response shape.
Webhooks
Webhooks are set up in the dashboard under Flows: choose which results to act on, such as every rejected return photo, and the URL to call. prfd sends a JSON POST after each matching seal or check, without slowing the API response down.
| Event type | Sent when |
|---|---|
| capture.ok · capture.review · capture.reject | A photo is sealed, by verdict |
| return.passed · return.review · return.failed | A return or claim photo is checked, by verdict |
{
"type": "return.review",
"account_id": "acc_5e1f0b",
"occurred_at": "2026-11-08T18:30:02+00:00",
"test": false,
"return_check": {
"analysis_id": "an_3b91e0c2",
"claim_id": "RMA-20931",
"order_id": "ORD-55120",
"customer_id": "cus_8812",
"verdict": "review",
"score": 0.4475,
"signals": [ ... ],
"reuse": null,
"step_up": { "status": "pending", "capture_url": "https://app.prfd.io/c/Qm9u..." },
"photo_sha256": "9a3f...b1e4",
"request_id": "req_8f2c41d07a"
}
}Checking the signature
If you set a secret on the webhook, each call carries X-Prfd-Timestamp and X-Prfd-Signature. The signature is sha256= followed by the HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with your secret. Compare it in constant time and reject calls with an old timestamp.
import crypto from "node:crypto";
function verifyPrfdWebhook(rawBody, headers, secret) {
const timestamp = headers["x-prfd-timestamp"];
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const given = headers["x-prfd-signature"] || "";
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return fresh && given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
import hashlib, hmac, time
def verify_prfd_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers["X-Prfd-Timestamp"]
mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
fresh = abs(time.time() - int(timestamp)) < 300
return fresh and hmac.compare_digest(headers.get("X-Prfd-Signature", ""), expected)
Each delivery attempt is logged in the dashboard with its result. Failed deliveries are not retried automatically yet, so respond with a 2xx quickly and do your work afterwards.
Capture links
Creates a one-photo link for prfd's hosted capture page. The photo taken there is sealed under this key, and your flows run as for any seal.
| Field | Description |
|---|---|
| delivery_id | Your reference for the delivery or claim, up to 128 characters. |
| carrier_code | Who the photo is for, such as your company or carrier, up to 64 characters. |
| return_urloptional | Where to send the person afterwards: an https:// URL or your app's own scheme. |
| itemoptional | What the photo should show, such as lipstick. A photo that looks like something else is flagged. |
| claim_idoptional | Set when the photo is for a return or claim. The sealed photo is then filed under Returns with its verdict, and the session's analysis_id points to it. order_id, customer_id, claim_amount and currency work as on a photo check. |
Returns 201 with the session. The url is only returned here.
{
"id": "cs_9d21f7a0",
"url": "https://app.prfd.io/c/Xk2...",
"status": "pending",
"delivery_id": "NW-48210",
"expires_at": "2026-11-08T18:45:00+00:00",
"completed_at": null,
"manifest_id": null,
"verdict": null
}Returns the session. status is pending, completed or expired; once completed, manifest_id and verdict describe the sealed photo.
Capture tickets
Issues a one-time ticket proving a photo was taken live in your app. Call it when the camera opens. Free.
| Field | Description |
|---|---|
| device_fingerprint | A stable id for the device, up to 128 characters. Must match the seal request. |
| app_id | Your app's bundle or package id. Must match the seal request. |
{ "live_capture_token": "lct_...", "expires_in_seconds": 60 }Seal a photo
Analyzes and seals a photo with its metadata, and returns the sealed file itself with Content-Type: application/vnd.prfd.
| Field | Description |
|---|---|
| photo | The image file (multipart). For /v1/sign-json, send photo_base64 instead. |
| metadata | JSON: delivery_id, carrier_code, device (fingerprint, platform, app_version, app_id), location (latitude, longitude, accuracy_meters, captured_at) and capture (timestamp, nonce, optional attestation). A string field for multipart, an object for JSON. |
| run_analysisoptional | Default true. Seal the verdict into the file. |
| Response header | Description |
|---|---|
| X-Prfd-Manifest-Id | The sealed record's id, for /v1/manifests |
| X-Prfd-Verdict | ok, review or reject |
| X-Prfd-Key-Id | The key that signed the file |
| X-Prfd-Issued-At | When it was sealed |
| X-Prfd-Request-Id | Quote this to support |
An account can be set to refuse to seal flagged photos. Such a request returns 422 verdict_rejected with the verdict and signals, and no file is issued.
Check a photo
Checks an uploaded photo and returns a verdict, the signals behind it, any earlier use of the photo, and a step-up link if it was flagged.
| Field | Description |
|---|---|
| photo | The image file (multipart). For /v1/analyze, send photo_base64. |
| claim_idoptional | Your claim or RMA reference. Scopes reuse detection. |
| order_idoptional | Your order reference. |
| customer_idoptional | Your customer reference. |
| claim_amountoptional | What the claim would pay out, 0 to 10,000,000. |
| currencyoptional | Three-letter code such as USD. |
| step_upoptional | Default true. Create a live-capture link when the photo is flagged. |
| step_up_return_urloptional | Where the capture page sends the shopper afterwards. |
| itemoptional | What the photo should show, in a few words, such as lipstick. A photo that looks clearly like a different kind of thing adds item.mismatch, which asks for a live photo but never rejects on its own. prfd checks the item, not the damage. |
| claimed_in_app_captureoptional | Default false. Set when the photo should have come straight from your own camera screen; missing camera data then counts for more. |
Accepts an Idempotency-Key header. See the Quickstart for a full response.
Look up a check
Returns a check in the same shape as the original response, with the current state of its step-up. The capture link itself is not shown again.
The photo exactly as it was sent, as image/jpeg or image/png, so your team can see what the customer submitted. It is kept for your account's retention period.
Returns summary
Counts and claim values over the last days days (1 to 366), for your own reporting. Amounts are in major units per currency; "flagged" is what was at risk, not what was saved.
{
"days": 30,
"checks": 1240,
"passed": 1104,
"flagged": 136,
"needs_review": 98,
"failed": 38,
"reused_photos": 17,
"held": 21,
"step_ups": { "sent": 98, "completed": 71 },
"claims_with_amount": 1190,
"value_by_currency": {
"USD": { "checked": 81240.5, "flagged": 9120.0, "held": 1630.25, "reused": 940.0 }
}
}Verify a file
Checks a .prfd file sent as the multipart field file against prfd's published keys.
{
"valid": true,
"key_id": "prfd-prod-2026-01",
"delivery_id": "NW-48210",
"signed_at": "2026-11-08T18:30:01Z",
"verdict": "ok",
"score": 0.0,
"failure_reason": null
}Returns the file's manifest and metadata as JSON, for debugging. It does not tell you whether the seal holds; use /v1/verify for that.
Sealed evidence
Lists the photos sealed under your account, newest first. Filter with delivery_id or verdict; page with limit (1 to 100, default 25) and cursor, the id of the last row you saw.
{
"data": [
{
"id": "mf_0c4e2b19",
"delivery_id": "NW-48210",
"carrier_code": "northwind",
"photo_sha256": "9a3f...b1e4",
"key_id": "prfd-prod-2026-01",
"signed_at": "2026-11-08T18:30:01+00:00",
"verdict": "ok",
"score": 0.0,
"signals": [],
"photo_bytes": 2481152,
"file_available": true,
"test": false
}
],
"has_more": false,
"next_cursor": null
}Returns one record. file_available is true when prfd holds the sealed file.
The photo inside a sealed file prfd holds, as an image, for showing on screen. Hand over the .prfd file from /file as evidence; the photo alone carries no proof.
Downloads the sealed .prfd file for a photo taken on the hosted capture page, including step-up live photos. Photos sealed through /v1/sign are not kept, because the file went straight back to your server; for those this returns 404.
curl https://app.prfd.io/v1/manifests/mf_0c4e2b19/file \ -H "Authorization: Bearer $PRFD_KEY" \ -o NW-48210.prfd
import { writeFile } from "node:fs/promises";
const res = await fetch("https://app.prfd.io/v1/manifests/mf_0c4e2b19/file", {
headers: { Authorization: `Bearer ${process.env.PRFD_KEY}` },
});
await writeFile("NW-48210.prfd", Buffer.from(await res.arrayBuffer()));
import os, requests
res = requests.get(
"https://app.prfd.io/v1/manifests/mf_0c4e2b19/file",
headers={"Authorization": f"Bearer {os.environ['PRFD_KEY']}"},
)
open("NW-48210.prfd", "wb").write(res.content)
Account and usage
Your account, plan and limits. retention_days is how long your evidence is kept: the period chosen in Settings, capped by your plan. Records of sealed photos and return checks, and stored sealed files, are deleted after it. A .prfd file you already hold keeps verifying.
Photos used this period against your allowance. Sealing and checking draw on one shared allowance; verification is free.
{
"period": "2026-11",
"plan": "growth",
"operations": {
"photos": { "used": 1840, "limit": 10000, "remaining": 8160 },
"sign": { "used": 600, "limit": 10000, "remaining": 8160 },
"analyze": { "used": 1240, "limit": 10000, "remaining": 8160 },
"verify": { "used": null, "limit": "unlimited", "remaining": "unlimited" }
}
}Public keys
The keys prfd signs with. Rotation only ever adds keys, so files sealed years ago keep verifying.
{ "authority": "prfd.io", "keys": [ { "key_id": "prfd-prod-2026-01", "active": true } ] }Verdicts and signals
Every check returns a score from 0 to 1 and a verdict: ok below 0.40, review from 0.40, and reject from 0.80. The score combines every signal that fired, so several weak signs add up while no single weak one decides the outcome.
Each signal has a stable name you can branch on, a severity, and a human-readable detail that may be reworded. Names are grouped by family:
| Family | What it looks at |
|---|---|
| exif.* | Camera data a photo straight from a phone carries |
| format.* | The file type, including screenshots |
| metadata.* | AI-tool and editor markers, and declared AI sources |
| c2pa.* | Content Credentials attached to the file |
| jpeg.* · pixel.* | How the image was encoded, and its pixels |
| reuse.* · prfd.* | Whether the photo was used or sealed before |
| item.* | Whether the photo shows the item you described: item.mismatch when it looks like something else |
New signals are added over time. Treat an unknown name as information, not an error.
Errors
Every error has the same shape. Branch on type, which is stable; message is for people and may change.
{
"error": {
"type": "invalid_request",
"message": "Field required",
"param": "carrier_code",
"request_id": "f35da8a3da27418ba8488a63e680b8f3"
}
}| Type | Status | Meaning |
|---|---|---|
| invalid_request | 400 · 422 | A field is missing or malformed; param names it |
| unauthenticated | 401 · 403 | The key is missing or invalid, or the account is suspended |
| forbidden_scope | 403 | The key lacks the scope this endpoint needs |
| live_capture_invalid | 403 | The capture ticket is expired, used, or for another device or app |
| not_found | 404 | No such record in your account |
| idempotency_key_reused | 409 | The Idempotency-Key was already used for a different photo |
| payload_too_large | 413 | The photo is over 25 MB |
| live_capture_required | 422 | Your account requires a capture ticket on every seal |
| verdict_rejected | 422 | Your account refuses to seal flagged photos, and this one was flagged |
| quota_exceeded | 402 | This period's photo allowance, or the free test allowance for a test key, is used up |
| rate_limited | 429 | Too many requests; wait for Retry-After seconds |
| internal_error | 500 | Something failed on our side; retry with the same Idempotency-Key |
Limits and quota
Each plan includes a number of photos per month, shared between sealing and checking. Every sealing or checking response reports where you stand:
| Header | Description |
|---|---|
| X-Prfd-Quota-Limit | Photos included this period, or unlimited |
| X-Prfd-Quota-Remaining | Photos left this period |
| X-Prfd-Quota-Period | The current period, such as 2026-11 |
Requests are also limited per minute to protect the service:
| Requests | Limit |
|---|---|
| Sealing and checking | 120 per minute per account |
| Capture links and tickets | 300 per minute per account |
| Verify and inspect | 120 per minute per IP address |
Over a limit, the API returns 429 rate_limited with a Retry-After header. Photos can be up to 25 MB, in JPEG or PNG.
Idempotency
Send an Idempotency-Key header, up to 128 characters, with each photo check. If the request times out and you retry with the same key and the same photo, you get the original check back, marked with Idempotent-Replayed: true, and the photo is not counted twice. Using the key for a different photo returns 409 idempotency_key_reused. A good key is your own claim or upload id.
Idempotency-Key: RMA-20931-upload-1
Questions about the API, or need something it does not do yet? Write to hello@prfd.io.