Build your request
Pick what to ask, and get the code: the REST call, the MCP tool call for an AI agent, or a Sign in with Prson snippet for a website. The link to this page keeps your choices, so you can share it.
Kind of request
Start from a recipe
Recipes are ready-made requests that Prson writes and keeps up to date. Start from one and the code sends recipe (and recipe_settings) instead of its requirements; you can add more below. Only Prson writes recipes.
Facts
Yes or no answers, signed by Prson. Search by what you want to know, then add it. All facts, with what each proves.
- 18 or older
age_gte_18What it proves
Details
Verified values the person shares, such as their name. Turn off "share the value" to learn only that a verified one exists.
Captures
Taken live in the Prson camera and signed by the phone. Sign in with Prson can't ask for these.
Title and options
1 to 168, default 72.
Only people at this level or above can answer. 5: verified human.
Your code
1 requirement (up to 10).
curl -X POST https://api.prson.net/v1/verifications \
-H "Authorization: Bearer $PRSON_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<'JSON'
{
"title": "Confirm you are over 18 to open an account",
"requirements": [
{ "type": "fact", "fact": "age_gte_18" }
]
}
JSON// Server side only: the key stays in an environment variable, never in a browser.
const res = await fetch("https://api.prson.net/v1/verifications", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PRSON_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Confirm you are over 18 to open an account",
requirements: [
{ type: "fact", fact: "age_gte_18" }
]
}),
});
if (!res.ok) throw new Error(`Prson ${res.status}: ${await res.text()}`);
const { verification, share_url } = await res.json();
// Send share_url to the person yourself (email, chat, your site). Keep verification.id to read the answers.import os
import requests
# Server side only: the key stays in an environment variable.
res = requests.post(
"https://api.prson.net/v1/verifications",
headers={
"Authorization": f"Bearer {os.environ['PRSON_API_KEY']}",
},
json={
"title": "Confirm you are over 18 to open an account",
"requirements": [
{"type": "fact", "fact": "age_gte_18"}
]
},
timeout=30,
)
res.raise_for_status()
data = res.json()
verification, share_url = data["verification"], data["share_url"]
# Send share_url to the person yourself (email, chat, your site).{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_verification",
"arguments": {
"title": "Confirm you are over 18 to open an account",
"requirements": [
{ "type": "fact", "fact": "age_gte_18" }
]
}
}
}{
"title": "Confirm you are over 18 to open an account",
"requirements": [
{ "type": "fact", "fact": "age_gte_18" }
]
}<prson-signin-button id="signin" variant="amber" size="lg"></prson-signin-button>
<p id="who" aria-live="polite"></p>
<script type="module">
import "https://prson.net/vendor/prson-login/prson-login-ui.js"; // the button and the dialog
import { PrsonLogin } from "https://prson.net/vendor/prson-login/prson-login.js";
document.querySelector("#signin").addEventListener("click", async () => {
const result = await PrsonLogin.signIn({
// A one-time nonce from YOUR server, fresh for each code shown.
nonce: async () => (await fetch("/auth/prson/nonce", { method: "POST" }).then((r) => r.json())).nonce,
required: {
predicates: ["age_gte_18"]
}
});
// Check it on your server: result.claims here is only a copy for the page.
const res = await fetch("/auth/prson", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ presentation: result.sdJwtVc, nonce: result.nonce }),
});
// Show who signed in at once: whoever scans the code first signs in.
if (res.ok) document.querySelector("#who").textContent = `Signed in as ${result.prsonId.slice(0, 8)}…`;
});
</script>What comes back
The link to send, then each answer signed by Prson, and how to check that signature.
{
"verification": {
"id": "8d2f0c4e-5b1a-4c3e-9f7d-2a6b1c0e9d41",
"status": "open",
"title": "Confirm you are over 18 to open an account",
"requirements": [
{
"slot_id": "s_1a2b3c4d",
"type": "fact",
"name": "Age at least 18",
"description": null,
"optional": false,
"fact": "age_gte_18"
}
],
"min_verification_level": 0,
"allowed_identity_types": [],
"created_at": 1772611200000,
"expires_at": 1772870400000,
"completed_at": null,
"cancelled_at": null,
"share_url": "https://app.prson.net/challenge/…",
"external_ref": null,
"metadata": null,
"charge": {
"currency": "EUR",
"reserved_cents": 50,
"spent_cents": 0,
"discount": {
"kind": "launch",
"percent": 50,
"until": "2027-03-31",
"regular_cents": 100
}
},
"app_id": "app_…",
"test": false,
"responses": []
},
"share_url": "https://app.prson.net/challenge/…"
}{
"verification": {
"id": "8d2f0c4e-5b1a-4c3e-9f7d-2a6b1c0e9d41",
"status": "fulfilled",
"title": "Confirm you are over 18 to open an account",
"requirements": [
{
"slot_id": "s_1a2b3c4d",
"type": "fact",
"name": "Age at least 18",
"description": null,
"optional": false,
"fact": "age_gte_18"
}
],
"min_verification_level": 0,
"allowed_identity_types": [],
"created_at": 1772611200000,
"expires_at": 1772870400000,
"completed_at": 1772611560000,
"cancelled_at": null,
"share_url": "https://app.prson.net/challenge/…",
"external_ref": null,
"metadata": null,
"charge": {
"currency": "EUR",
"reserved_cents": 50,
"spent_cents": 50,
"discount": {
"kind": "launch",
"percent": 50,
"until": "2027-03-31",
"regular_cents": 100
}
},
"app_id": "app_…",
"test": false,
"responses": [
{
"respondent_id": "rsp_4f0c9a1e7b3d5c2a8e6f1b0d9c4a7e3f",
"slot_id": "s_1a2b3c4d",
"requirement_name": "Age at least 18",
"type": "fact",
"status": "verified",
"verdict_level": "strong",
"answered_at": 1772611500000,
"verified_at": 1772611500000,
"proof_id": "prf_7c1ea9d2e4b6",
"files": [],
"fact": {
"id": "age_gte_18",
"value": true,
"description": "Age at least 18",
"expires_at": null
},
"verdict_signature": {
"envelope": {
"scope": "prson-truth-verdict/2",
"proofId": "prf_7c1ea9d2e4b6",
"type": "fragment",
"captureHash": null,
"artefactHashes": null,
"challengeId": "8d2f0c4e-5b1a-4c3e-9f7d-2a6b1c0e9d41",
"slotId": "s_1a2b3c4d",
"signerIdentityId": "rsp_4f0c9a1e7b3d5c2a8e6f1b0d9c4a7e3f",
"level": "strong",
"verifiedAt": "2026-03-04T08:05:00.000Z",
"disclosures": [
{ "kind": "fact", "id": "age_gte_18", "digest": "…" }
]
},
"signature": "…",
"kid": "…"
},
"verdict_disclosures": [
["kQ2…", "fact", "age_gte_18", true]
]
}
]
}
}// Check Prson's signature on each answer (Node 18+, no packages).
// The same check as verifyVerdict in @prson/login-verifier.
import { createHash, createVerify } from "node:crypto";
// Sorted keys at every level, no spaces: the bytes Prson signed.
const canonical = (v) =>
v === null || v === undefined ? "null"
: Array.isArray(v) ? `[${v.map(canonical).join(",")}]`
: typeof v === "object" ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`
: JSON.stringify(v);
// Prson's public keys, active and retired, by kid. Cache them: they change rarely.
const { verificationKeys } = await fetch("https://api.prson.net/v1/company/public-key").then((r) => r.json());
/** One answer from verification.responses: is it signed by Prson, for this verification? Returns the values Prson vouched for. */
export function checkAnswer(answer, verification) {
const seal = answer.verdict_signature;
if (!seal?.envelope || !seal.signature) return { ok: false, reason: "not signed" };
const { envelope } = seal;
if (envelope.scope !== "prson-truth-verdict/2") return { ok: false, reason: "unknown scope" };
const message = canonical(envelope);
const keys = [...verificationKeys].sort((a, b) => (b.kid === seal.kid) - (a.kid === seal.kid));
const signed = keys.some((k) => {
try {
return createVerify("RSA-SHA256").update(message).verify(k.publicKey, seal.signature, "base64");
} catch {
return false; // an unreadable key: try the next one
}
});
if (!signed) return { ok: false, reason: "signature does not match" };
// Bound to your request: this verification, one of its requirements.
if (envelope.challengeId !== verification.id) return { ok: false, reason: "another verification" };
if (!verification.requirements.some((r) => r.slot_id === envelope.slotId)) return { ok: false, reason: "unknown requirement" };
// Each value must be one the signature covers: base64url(SHA-256(canonical [salt, kind, id, value])).
const values = {};
for (const d of answer.verdict_disclosures ?? []) {
const digest = createHash("sha256").update(canonical(d)).digest("base64url");
const covered = (envelope.disclosures ?? []).some((e) => e.kind === d[1] && e.id === d[2] && e.digest === digest);
if (!covered) return { ok: false, reason: `${d[1]} ${d[2]} is not covered by the signature` };
values[`${d[1]}:${d[2]}`] = d[3];
}
return { ok: true, slotId: envelope.slotId, level: envelope.level, values };
}
// What this request asked for, in order (verification.requirements has the same order).
const ASKED = [
{ type: "fact", fact: "age_gte_18" }
];
/** Every required item has a verified, signed answer; facts are true. */
export function isComplete(verification) {
return ASKED.every((asked, i) => {
if (asked.optional) return true;
const slot = verification.requirements[i].slot_id;
return verification.responses.some((answer) => {
if (answer.slot_id !== slot || answer.status !== "verified") return false;
const check = checkAnswer(answer, verification);
if (!check.ok) return false;
return asked.fact ? check.values[`fact:${asked.fact}`] === true : true;
});
});
}
// GET the verification with your key (or take data.verification from a verification.* webhook).
const verificationId = process.env.VERIFICATION_ID; // the id POST /v1/verifications returned
const { verification } = await fetch(`https://api.prson.net/v1/verifications/${verificationId}`, {
headers: { Authorization: `Bearer ${process.env.PRSON_API_KEY}` },
}).then((r) => r.json());
console.log(isComplete(verification) ? "Verified by Prson" : "Not yet");The recipe's app link
Each recipe also has a link that opens the Prson app with it filled in, for someone asking from their own phone without code. They check it in the app, can change anything, and send it to the person themselves.
Start from one of Prson's recipes above and its link appears here. Only Prson writes recipes: a request you put together yourself is sent with the API or MCP.