Verifications API
A verification asks a person to prove one or more things. You create it, send its link, and read the answers, each signed by Prson. The person answers in the Prson app and decides what to share.
| Method | Path | Scope | What it does |
|---|---|---|---|
POST | /v1/verifications | verifications:write | Create a verification; reserves its price |
POST | /v1/verifications/quote | verifications:read | What creating this body would reserve now; creates nothing |
GET | /v1/verifications | verifications:read | List, newest first (?status, ?external_ref, ?limit, ?cursor) |
GET | /v1/verifications/{id} | verifications:read | One verification with every answer so far |
GET | /v1/verifications/{id}/proofs/{proof_id}/{file} | verifications:read | An answer's file: photo, video, sensors or signed_pdf |
POST | /v1/verifications/{id}/cancel | verifications:write | Stop it; the unspent reserve goes back |
POST | /v1/verifications/documents | verifications:write | Upload a PDF for a document_signature requirement |
GET | /v1/verifications/requirements | none (public) | What can be asked for today, with prices |
Prices
A flat price per source, not per requirement: each is paid once per verification, however many requirements use it.
| Source | What it covers | Regular | Until 2027-03-31 |
|---|---|---|---|
| Base | Any verification. Includes live photos, videos, selfies and liveness, and email. | 0.50 | 0.25 |
| ID check: yes/no facts | Any number of facts from the person's passport or ID card chip: age, nationality, document validity, issuing country, verification level. | +0.50 | +0.25 |
| ID check: details | Any value from the chip: names, date of birth, nationality, document. Includes every fact; instead of the facts price, never both. | +1.00 | +0.50 |
| PDF signature | The person signs your PDF with their verified name. However many PDFs. | +1.00 | +0.50 |
| Premiums | Each premium requirement: bank account 1.00, bank balance 0.50, bank income 1.50, address 10.00, phone 0.50. Never discounted, never covered by a free plan. | per item | per item |
Amounts in your balance's currency, the same number in EUR, USD and GBP. Examples at launch prices: Age only 0.50; Name, nationality and age 0.75; Name and a PDF signature 1.25; Name and phone number 1.25. Where each check works: coverage by country.
How the price is reserved and spent: How you are charged.
Authentication
Send your key as Authorization: Bearer prs_live_… (or prs_sandbox_…) to https://api.prson.net. Keys come from the developer console and default to the scopes verifications:read and verifications:write. Keep keys on your server. AI agents can also connect with OAuth: see MCP.
Create a verification
POST /v1/verifications reserves the price from your balance and returns the verification with its share_url (201). Send the link to the person yourself.
curl -X POST https://api.prson.net/v1/verifications \
-H "Authorization: Bearer $PRSON_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-4821" \
-d @- <<'JSON'
{
"title": "Confirm you're over 18 and show your ID to rent a car",
"requirements": [
{ "type": "fact", "fact": "age_gte_18" },
{ "type": "property", "property": "family_name" },
{ "type": "document", "require_document_verification": true, "document_types": ["passport", "national_id"] },
{ "type": "selfie", "optional": true }
],
"expires_in_hours": 48,
"external_ref": "booking-4821",
"metadata": { "branch": "Lisbon airport" }
}
JSONBody
| Field | Type | Meaning |
|---|---|---|
title | string, required | What the person sees: who is asking and why. Up to 200 characters. |
requirements | array, required | 1 to 10 requirements. See What you can ask for. |
min_verification_level | 0 to 6 | The person's minimum identity level. 5: identity read from a passport or ID chip. |
allowed_identity_types | array | personal, organization. Default: any. |
expires_in_hours | integer | 1 to 168, default 72. |
external_ref | string | Your own id (an order, a customer). List can filter on it. |
metadata | object | Your own data, returned as given. At most 4 KB as JSON. |
currency | ISO 4217 | The balance to pay from. Default: EUR if you hold EUR, else your largest balance. |
A requirement
Every requirement takes these, plus the fields of its type:
type | One of: fact, property, selfie, document, selfie_with_document, photo, video, document_signature. |
name | A short label the person sees (up to 80 characters). Default: one made from the type. |
description | What to show or why (up to 300 characters). |
optional | true: the person may skip it. |
| Type | Fields |
|---|---|
fact |
|
property |
|
selfie |
|
document |
|
selfie_with_document |
|
photo |
|
video |
|
document_signature |
|
Every fact id, property type and capture, with an example: What you can ask for. Send Idempotency-Key to make retries safe: the same key returns the same verification (200, replayed: true) instead of a second one.
Quote
POST /v1/verifications/quote takes the same body (title optional) and says what creating it would reserve now, for your key's app: discounts, a free plan's allowance, sandbox. It creates nothing.
curl -X POST https://api.prson.net/v1/verifications/quote \
-H "Authorization: Bearer $PRSON_API_KEY" -H "Content-Type: application/json" \
-d '{ "requirements": [{ "type": "fact", "fact": "age_gte_18" }] }'
# → { "quote": { "regular_cents": 100, "discount": { "kind": "launch", "percent": 50 … },
# "price_cents": …, "reserved_cents": …, "test": false, "sources": { "document": "facts" },
# "source_cents": { "base": …, "document": …, "signature": 0 },
# "requirements": [{ "requirement": 0, "source": "document_facts", "premium_cents": 0 }] } }Get, list and cancel
GET /v1/verifications/{id} returns the verification with every answer. Status moves open → partially_fulfilled (some requirements answered) → fulfilled (one person answered every required one), or ends cancelled or expired. Several people can answer the same link.
# Newest first, 25 a page; pass next_cursor back as cursor.
curl "https://api.prson.net/v1/verifications?status=fulfilled&limit=25" -H "Authorization: Bearer $PRSON_API_KEY"
# Cancel: the link stops working, released_cents goes back to your balance.
curl -X POST https://api.prson.net/v1/verifications/$ID/cancel -H "Authorization: Bearer $PRSON_API_KEY"
# A file from an answer (names in the response's "files")
curl -o selfie.jpg https://api.prson.net/v1/verifications/$ID/proofs/$PROOF_ID/photo -H "Authorization: Bearer $PRSON_API_KEY"Responses
Each item of verification.responses is one answer to one requirement:
respondent_id | The person, as rsp_…: the same for your organisation every time, different for any other. |
slot_id, requirement_name, type | Which requirement this answers. |
status | uploaded, verified or rejected. |
verdict_level | How strongly Prson verified it. |
fact | { id, value, description, expires_at }: a yes or no. |
property | { type, value, value_disclosed, verified_at, expires_at }: value is null when reveal_value was false. |
document | { document_type, issuing_country, expires_on, verification_level, facts } |
signature | A signed PDF: signer_name, document hashes, certificate, signed_at, download_path, legal_type advanced_electronic_signature. |
files | Downloadable files: photo, video, sensors, signed_pdf. |
location | { lat, lng, accuracy_m } when the requirement asked for it. |
checks | For captures: device_attestation, motion_consistent, network_location_consistent. |
verdict_signature, verdict_disclosures | Prson's signature over the answer, and what it commits to (see Checking a verdict). |
The verification also has charge (reserved_cents, spent_cents, any discount or allowance) and test: true in sandbox.
Documents to sign
Upload a PDF, then ask for a document_signature. The person reads every page in the Prson app and signs with a certificate naming them as their chip-read passport or ID card does. You get the signed PDF (PAdES B-T, timestamped): your file plus the signature, so its first bytes hash to the sha256 you uploaded. It is an advanced electronic signature under eIDAS, not a qualified one.
# 1. Upload the PDF (at most 12 MB, not encrypted)
curl -X POST "https://api.prson.net/v1/verifications/documents?name=rental-agreement.pdf" \
-H "Authorization: Bearer $PRSON_API_KEY" \
-H "Content-Type: application/pdf" \
--data-binary @rental-agreement.pdf
# → { "document": { "id": "vdoc_…", "sha256": "…", "size": 48213, "pages": 3, "unused_expires_at": … } }
# 2. Ask for a signature on it
curl -X POST https://api.prson.net/v1/verifications \
-H "Authorization: Bearer $PRSON_API_KEY" -H "Content-Type: application/json" \
-d '{ "title": "Sign the rental agreement",
"requirements": [{ "type": "document_signature", "document_id": "vdoc_…",
"signature_position": { "page": 3, "x": 72, "y": 96, "width": 180, "height": 48 } }] }' You can also send JSON { "content_base64", "name" }. An upload no verification uses is deleted after a week; the signed PDF is deleted 30 days after the verification expires.
Webhooks
Add an endpoint and Prson POSTs events to it as they happen, instead of you polling.
curl -X POST https://api.prson.net/v1/org/webhooks \
-H "Authorization: Bearer $PRSON_API_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://example.com/prson/webhook", "events": ["verification.completed", "verification.slot_verified"] }'
# → { "endpoint": { "id": "whe_…", … }, "secret": "whsec_…" } the secret is shown once| Event | When |
|---|---|
verification.answered | Someone answered a requirement (data.response) |
verification.slot_verified | An answer was verified |
verification.slot_rejected | An answer was rejected |
verification.completed | One person answered every required requirement |
verification.expired | It expired |
verification.cancelled | You cancelled it |
Each delivery is JSON { id, type, created_at, api_version, data } with two headers: Prson-Event-Id (an idempotency key: a delivery can arrive twice) and Prson-Signature. Check the signature and that t is recent. Reply 2xx within 10 seconds; failures are retried with backoff for about a day.
import { createHmac, timingSafeEqual } from "node:crypto";
// Prson-Signature: t={unix seconds},v1={hex HMAC-SHA256(secret, "{t}.{raw body}")}
export function isFromPrson(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header).split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1 ?? "", "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}
// Express: keep the raw body for the check.
app.post("/prson/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!isFromPrson(req.body.toString("utf8"), req.get("Prson-Signature"), process.env.PRSON_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body); // { id, type, created_at, api_version, data: { verification, response? } }
// Prson-Event-Id is an idempotency key: a delivery can arrive twice.
res.sendStatus(200);
});Checking a verdict
Every answer carries verdict_signature: { envelope, signature, kid }: Prson's RS256 signature over the canonical JSON of the envelope, which names the verification, the requirement, the level and the time, and commits to each disclosed value. verdict_disclosures holds the values as [salt, kind, id, value]. Keep both to prove later, offline, exactly what Prson vouched for.
import { verifyVerdict } from "@prson/login-verifier";
const keys = await fetch("https://api.prson.net/v1/company/public-key").then((r) => r.json());
const r = response; // one item of verification.responses
const result = verifyVerdict(r.verdict_signature.envelope, r.verdict_signature.signature, r.verdict_disclosures, {
kid: r.verdict_signature.kid,
keys: keys.verificationKeys,
});
// result.ok: Prson signed this answer, and every disclosed value is covered by the signature.Errors
Errors are JSON: { "error": "code", "message": "…" }, sometimes with more fields.
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request | The body is malformed; issues lists each problem. |
| 400 | invalid_requirement | One requirement is wrong; requirement says which. |
| 400 | email_not_supported | Send share_url yourself; Prson doesn't email people. |
| 401 | invalid_token | Missing, unknown or revoked key. |
| 403 | missing_scope | The key lacks the scope. |
| 402 | insufficient_balance | Top up; required_cents says how much is needed. |
| 402 | allowance_exhausted | A free plan's monthly allowance is used up. |
| 404 | not_found | No such verification for your organisation. |
| 409 | Cancelling one that is already cancelled or expired. | |
| 413 | document_too_large | A PDF over 12 MB. |
| 422 | requirement_not_available | Prson can't verify this yet (bank, tax and some phone facts). |
| 422 | requirement_unavailable | Phone or address verification is off here: see capabilities. |
| 422 | document_unsupported | An encrypted or unreadable PDF. |
| 429 | too_many_requests | Slow down, then retry. |
Rate limits and limits
- Too many requests in a short time get 429
too_many_requests: wait and retry with backoff. Some routes have daily limits, named in the error. - 1 to 10 requirements per verification; a title up to 200 characters; metadata up to 4 KB.
- A link works 1 to 168 hours (default 72).
- A key can have a spend cap: past it, creating is refused with 422.
Sandbox and production
Apps in sandbox create free verifications marked test; they can't email people. Answer them with the Prson app like any other. Production needs a verified developer and a topped-up balance or an approved free plan; the console shows what's missing. Phone and address requirements work only where Prson can verify them: capabilities in GET /v1/verifications/requirements says so, and asking for one that is off gets 422 requirement_unavailable.
How you are charged
Creating a verification reserves its price from your prepaid balance. It is spent as answers verify: the base with the first verified answer, the ID check with the first verified answer that used the chip, the signature with the signed PDF, each premium with its requirement's answer. The rest goes back when you cancel or it expires. Nobody answers: nothing is charged. POST /v1/verifications/quote prices a request without creating it.
A capture that is checked against the chip (require_document_verification or required_facts) uses the ID check's facts price; a selfie, photo or document photo that isn't uses only the base. The larger of the launch and volume discounts applies, never both. Volume discounts: 25% from the 1,001th production verification of a month; 40% from the 10,001th production verification of a month. Open-source apps get 1,000 verifications a month free, non-commercial ones 100. Which price each requirement uses is in What you can ask for; the live prices are in GET /v1/verifications/requirements.