Skip to content

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

Kind of request

Facts

Yes or no answers, signed by Prson. Search by what you want to know, then add it. All facts, with what each 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 of 10 requirements.

cURLPOST /v1/verifications
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
Nodecreate.mjs
// 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.
Pythoncreate.py
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).
MCPtools/call on https://api.prson.net/mcp/verify
{
  "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" }
      ]
    }
  }
}
JSON bodyrequest.json
{
  "title": "Confirm you are over 18 to open an account",
  "requirements": [
    { "type": "fact", "fact": "age_gte_18" }
  ]
}
Website (Sign in)login.js
<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.

Created201 Created
{
  "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/…"
}
AnsweredGET /v1/verifications/{id}
{
  "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 the signatureverify.mjs
// 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");

Verifications APISign in with Prson