Developer docs

Proof of a real human, in one request.

Patina turns the history in accounts a person already owns into a score out of 100, high only when there are real years of history behind it. You can read that score, signed and verifiable, for anyone with a public Patina profile. No API key, no OAuth, one GET.

Being scored rather than doing the checking? How it works is the page for you: what is read from each account, what survives, and what the number means.

What it is good for

Sybil resistance without KYC or biometrics: airdrops, quadratic funding, DAO votes, gated betas, one-human-one-vote. A fresh wallet is free; a decade of ordinary digital life is not, and the score is exactly how much of that history it can prove. Use it as a signal or a gate, say, only wallets whose owner scores above 40.

The endpoint

Public and CORS-open, so you can call it straight from a browser or a backend.

GET https://patinadata.xyz/api/verify/{username}

Returns 404 if nobody holds that name. Otherwise:

{
  "username": "alice",
  "score": 71,
  "verdict": "Well established",
  "oldestYear": 2012,
  "yearsOfHistory": 14.2,
  "sourcesConnected": ["github", "linkedin", "spotify"],
  "components": [
    { "key": "age", "label": "Age", "points": 30, "max": 30, "detail": "..." },
    { "key": "continuity", "label": "Continuity", "points": 19.6, "max": 25, "detail": "..." },
    { "key": "corroboration", "label": "Corroboration", "points": 15, "max": 15, "detail": "..." },
    { "key": "vouches", "label": "Vouches", "points": 0.7, "max": 12, "detail": "..." },
    { "key": "depth", "label": "Depth", "points": 7.2, "max": 10, "detail": "..." },
    { "key": "breadth", "label": "Breadth", "points": 3.6, "max": 8, "detail": "..." }
  ],
  "provisional": false,
  "provisionalReason": null,
  "issuedAt": "2026-08-24T12:00:00.000Z",
  "expiresAt": "2026-09-23T12:00:00.000Z",
  "attestation": {
    "app": "0x3989bdFaf3BA242d27B4D0cEed98F446d0c52DAD",
    "message": "Patina score attestation\n\nusername: alice\nscore: 71/100\n...\nexpiresAt: 2026-09-23T12:00:00.000Z\napp: 0x…",
    "signature": "0x…",
    "expiresAt": "2026-09-23T12:00:00.000Z",
    "howToVerify": "Recover the EIP-191 signer of `message` from `signature`; it equals `app`. Then check the expiresAt line inside the message is still in the future."
  }
}

Verify the signature

This is the part that matters: don't trust our JSON, check the signature. Every response carries an attestation signed by Patina's app key. Recover the signer from the message and signature and confirm it equals Patina's public address. If it does, the score is genuinely ours and has not been altered, and you never had to trust this server.

Patina's app address

0x3989bdFaf3BA242d27B4D0cEed98F446d0c52DAD

viem

import { recoverMessageAddress } from "viem";

const res = await fetch("https://patinadata.xyz/api/verify/alice");
const { score, attestation } = await res.json();

const signer = await recoverMessageAddress({
  message: attestation.message,
  signature: attestation.signature,
});

// Did Patina really sign this exact score?
const genuine = signer.toLowerCase() === attestation.app.toLowerCase();

// Is it still current? The expiry lives INSIDE the signed message, which is
// what keeps this check offline. Reading the field next to it instead would
// let anyone passing on a stale score simply edit the date.
const expiresAt = new Date(attestation.message.match(/^expiresAt: (.+)$/m)[1]);
const current = expiresAt > new Date();

// You need both. Genuine but expired means fetch a fresh one, not reject them.
const trustworthy = genuine && current;

ethers

import { verifyMessage } from "ethers";

const signer = verifyMessage(attestation.message, attestation.signature);
const genuine = signer.toLowerCase() === attestation.app.toLowerCase();

const expiresAt = new Date(attestation.message.match(/^expiresAt: (.+)$/m)[1]);
const trustworthy = genuine && expiresAt > new Date();

Prefer to see it work before you write any code? The standalone checker runs the same recovery in your browser, with no call back to Patina.

Embed a verified badge

A live SVG badge for any public profile, drop it on a site, a README, or a bio. It reads current and links back to the proof.

Looks likePatina · 83/100 · 13yr

HTML

<a href="https://patinadata.xyz/verify?u=alice">
  <img src="https://patinadata.xyz/api/badge/alice" alt="Patina verified" height="30" />
</a>

Markdown

[![Patina verified](https://patinadata.xyz/api/badge/alice)](https://patinadata.xyz/verify?u=alice)

The fields

score
0–100. Age (30), Continuity (25) and Corroboration (15) are the time signals and are earned outright. Vouches (12), Depth (10) and Breadth (8) are gated behind them, because volume and followers can be manufactured in an afternoon and elapsed years cannot. The full model, component by component.
components
The breakdown, each with its points, max, and a plain-English reason. Show it, or reduce it to a single number, it is your call.
provisional
True when nothing the person connected proves a date, so there is nothing for Patina to attest to. The score is still computed honestly and attestation is null. Read it as no evidence either way, never as grounds for suspicion. If you want a stronger bar than Patina's own, gate on yearsOfHistory or the length of sourcesConnected rather than on this flag: Patina signs a single well-evidenced account on purpose, and how much corroboration you require is your call, not ours.
oldestYear
The earliest year Patina can prove across every connected source, or null. Paired with yearsOfHistory, the same span to one decimal, which is usually the number you actually want to threshold on.
issuedAt
When this attestation was signed. Scores rise as people connect more, so an attestation is a snapshot, re-fetch for a fresh one.
expiresAt
When it stops being good, thirty days after signing. The value that counts is the expiresAt line inside attestation.message, because that is the part the signature covers; the copy beside it is a convenience. A genuine signature past its expiry does not mean anybody lied. It means the score was true when it was issued and you should ask for a current one.

Good to know

  • Only people who have chosen a public username are verifiable, the same data as their public card.
  • No key and no rate limit today, but be reasonable; the score changes slowly, so cache it.
  • The signature is a plain EIP-191 message, so any Ethereum library verifies it, on-chain or off.

From an AI agent

Patina runs an MCP server, so Claude, ChatGPT and anything else that speaks the protocol can read a score mid-conversation. Same data as the endpoint above, same lack of an API key, with tools for the two questions an agent actually asks: what is this person's score, and do they clear a bar.

https://patinadata.xyz/api/mcp

Setup for each client, and what the tools do and deliberately do not return, is on the MCP page.

Pricing

Patina is free for individuals and free to start building on. Businesses that verify humans at volume pay for it, and that is how Patina makes money. No card to try it.

PersonalFree

Your own score, page and story. No wallet, no card, free for good.

DeveloperFree

Read any public score and every MCP tool. No key, CORS-open, rate limited only enough to stop a script.

VolumeTalk to us

Higher limits, uptime commitments and integration help. We would rather hear what you need than guess at a price.

Try it live at /verify, or ask in the Vana Discord.