prfd API · v1

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.

shell
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):

response · 200
{
  "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.

header
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.

ScopeAllows
signSealing photos, capture links and capture tickets
analyzeChecking photos, looking up checks, the returns summary
readListing 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.

Guide

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.

  1. Create a link for the delivery. Each link works once and expires after 15 minutes.
  2. Open the url on the courier's phone, in your app's in-app browser or as a link in a message.
  3. 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.
  4. Look up the session, or receive a webhook, to get the sealed photo's manifest_id and verdict.
  5. Download the sealed .prfd file with GET /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.

Guide

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.

  1. 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.
  2. When the photo is taken, your app sends it with the ticket to your server, which calls POST /v1/sign with the photo and its metadata.
  3. prfd returns the sealed .prfd file. 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.

Guide

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.

VerdictMeaningWhat to do
okNothing suggests the photo is fake, edited or reusedContinue the claim
reviewSomething is missing or unusual, such as camera data stripped by a messaging appSend the shopper the step-up link for a live photo
rejectStrong evidence of fakery: AI generation, an editor, a screenshot, or a photo already usedDecline, 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_id also 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.
  • item says what the photo should show, in a few words such as lipstick or perfume 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_amount and currency let 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.

Guide

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 once the shopper has taken the live photo
"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.

Guide

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.

Guide

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 typeSent when
capture.ok · capture.review · capture.rejectA photo is sealed, by verdict
return.passed · return.review · return.failedA return or claim photo is checked, by verdict
return.review
{
  "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.

API reference

Capture links

POST/v1/capture/sessionsscope: sign

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.

FieldDescription
delivery_idYour reference for the delivery or claim, up to 128 characters.
carrier_codeWho the photo is for, such as your company or carrier, up to 64 characters.
return_urloptionalWhere to send the person afterwards: an https:// URL or your app's own scheme.
itemoptionalWhat the photo should show, such as lipstick. A photo that looks like something else is flagged.
claim_idoptionalSet 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.

response · 201
{
  "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
}
GET/v1/capture/sessions/{id}scope: sign

Returns the session. status is pending, completed or expired; once completed, manifest_id and verdict describe the sealed photo.

Capture tickets

POST/v1/capture/beginscope: sign

Issues a one-time ticket proving a photo was taken live in your app. Call it when the camera opens. Free.

FieldDescription
device_fingerprintA stable id for the device, up to 128 characters. Must match the seal request.
app_idYour app's bundle or package id. Must match the seal request.
response · 200
{ "live_capture_token": "lct_...", "expires_in_seconds": 60 }

Seal a photo

POST/v1/signscope: sign · multipart
POST/v1/sign-jsonscope: sign · base64 JSON

Analyzes and seals a photo with its metadata, and returns the sealed file itself with Content-Type: application/vnd.prfd.

FieldDescription
photoThe image file (multipart). For /v1/sign-json, send photo_base64 instead.
metadataJSON: 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_analysisoptionalDefault true. Seal the verdict into the file.
Response headerDescription
X-Prfd-Manifest-IdThe sealed record's id, for /v1/manifests
X-Prfd-Verdictok, review or reject
X-Prfd-Key-IdThe key that signed the file
X-Prfd-Issued-AtWhen it was sealed
X-Prfd-Request-IdQuote 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

POST/v1/analyze/filescope: analyze · multipart
POST/v1/analyzescope: analyze · base64 JSON

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.

FieldDescription
photoThe image file (multipart). For /v1/analyze, send photo_base64.
claim_idoptionalYour claim or RMA reference. Scopes reuse detection.
order_idoptionalYour order reference.
customer_idoptionalYour customer reference.
claim_amountoptionalWhat the claim would pay out, 0 to 10,000,000.
currencyoptionalThree-letter code such as USD.
step_upoptionalDefault true. Create a live-capture link when the photo is flagged.
step_up_return_urloptionalWhere the capture page sends the shopper afterwards.
itemoptionalWhat 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_captureoptionalDefault 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

GET/v1/analyses/{id}scope: analyze

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.

GET/v1/analyses/{id}/photoscope: analyze

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

GET/v1/returns/summary?days=30scope: analyze

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.

response · 200
{
  "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

POST/v1/verifyno key · free

Checks a .prfd file sent as the multipart field file against prfd's published keys.

response · 200
{
  "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
}
POST/v1/inspectno key · free

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

GET/v1/manifestsscope: read

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.

response · 200
{
  "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
}
GET/v1/manifests/{id}scope: read

Returns one record. file_available is true when prfd holds the sealed file.

GET/v1/manifests/{id}/photoscope: read

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.

GET/v1/manifests/{id}/filescope: read

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

GET/v1/accountscope: read

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.

GET/v1/usagescope: read

Photos used this period against your allowance. Sealing and checking draw on one shared allowance; verification is free.

response · 200
{
  "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

GET/v1/keysno key

The keys prfd signs with. Rotation only ever adds keys, so files sealed years ago keep verifying.

response · 200
{ "authority": "prfd.io", "keys": [ { "key_id": "prfd-prod-2026-01", "active": true } ] }
Concepts

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:

FamilyWhat 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
{
  "error": {
    "type": "invalid_request",
    "message": "Field required",
    "param": "carrier_code",
    "request_id": "f35da8a3da27418ba8488a63e680b8f3"
  }
}
TypeStatusMeaning
invalid_request400 · 422A field is missing or malformed; param names it
unauthenticated401 · 403The key is missing or invalid, or the account is suspended
forbidden_scope403The key lacks the scope this endpoint needs
live_capture_invalid403The capture ticket is expired, used, or for another device or app
not_found404No such record in your account
idempotency_key_reused409The Idempotency-Key was already used for a different photo
payload_too_large413The photo is over 25 MB
live_capture_required422Your account requires a capture ticket on every seal
verdict_rejected422Your account refuses to seal flagged photos, and this one was flagged
quota_exceeded402This period's photo allowance, or the free test allowance for a test key, is used up
rate_limited429Too many requests; wait for Retry-After seconds
internal_error500Something 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:

HeaderDescription
X-Prfd-Quota-LimitPhotos included this period, or unlimited
X-Prfd-Quota-RemainingPhotos left this period
X-Prfd-Quota-PeriodThe current period, such as 2026-11

Requests are also limited per minute to protect the service:

RequestsLimit
Sealing and checking120 per minute per account
Capture links and tickets300 per minute per account
Verify and inspect120 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.

header
Idempotency-Key: RMA-20931-upload-1

Questions about the API, or need something it does not do yet? Write to hello@prfd.io.