Every Davi card publishes a small public, unauthenticated JSON document that describes it: who holds it, who issued it, and where to reach them. A tapped card, a pasted link, or a crawled profile yields structured identity to any client — no API key, no account, no SDK.
The card document is the public API for identity. Because it's public and cacheable, fields may be added but never renamed or removed, which is why versioning comes first.
Format version 1.0, media type
application/vnd.davi.card+json. The document, its discovery, and resolving one from another host are live. A few fields are explicitly reserved (see Reserved fields), and the trust model that would let a document from elsewhere carry authority is not built.
Fetching a document
There is exactly one card JSON URL per card, and it lives under /c/:
https://davi.social/c/{uid}/json ← the document
https://davi.social/c/{uid} ← the card page, which advertises it
https://davi.social/{username} ← the profile page, which advertises the primary card's document
curl -H "Accept: application/vnd.davi.card+json" \
"https://davi.social/c/CARD_UID/json"
The endpoint sends Access-Control-Allow-Origin: *, so browser clients can fetch
it cross-origin directly.
Note the profile page advertises rather than serves: identical bytes at two URLs would mean two cache entries and two canonical candidates, so canonicality is structural instead of a convention consumers have to honour. The cost is one extra hop from a username.
Discovery
A page points at its document with a standard alternate link, so the same HTML discovery works from a card page or a profile:
<link rel="alternate"
type="application/vnd.davi.card+json"
href="https://davi.social/c/{uid}/json">
Fetch the page, find the link, fetch the document. The package ships the helper:
import { DAVI_CARD_ACCEPT, findCardJsonHref, parseCard } from "@davi/card-json";
const page = await fetch(url, { headers: { Accept: DAVI_CARD_ACCEPT } });
const href = findCardJsonHref(await page.text(), url); // absolute, or null
On a profile page the link is present only when the owner has a primary card; when they don't, it is simply omitted and there is no card document to reach.
Transport
| Behaviour | Detail |
|---|---|
| Content type | application/vnd.davi.card+json |
| Accept | DAVI_CARD_ACCEPT negotiates HTML or the document; DAVI_CARD_ACCEPT_DIRECT asks only for the document |
| Caching | Cache-Control: public, max-age=60, stale-while-revalidate=600 |
| Validators | ETag on every response; send If-None-Match to get a 304 |
| Vary | Accept, on the document endpoint |
| CORS | Access-Control-Allow-Origin: *, GET/OPTIONS |
| Errors | RFC 9457 application/problem+json |
Vary: Accept is sent by /c/{uid}/json. Once you know a card's document URL,
request it directly with DAVI_CARD_ACCEPT_DIRECT rather than content-negotiating
against the card page — the direct fetch is one hop instead of two, and it doesn't
depend on every intermediary between you and the origin keying its cache on Accept.
Negotiate on a page only when discovery is the point: you have a URL and don't yet
know whether it's a card page, a profile, or another host entirely.
A missing card returns 404 with a problem document:
{
"type": "https://davi.social/problems/card-not-found",
"title": "Card not found",
"status": 404
}
The same body is returned for a card that doesn't exist and for blank unclaimed inventory — two distinguishable responses would let anyone enumerate valid card uids, and unclaimed cards are exactly the ones worth finding.
The document
Only version is required, so the smallest valid document is:
{ "version": "1.0" }
A populated one — this is the schema's own example, so it's the shape to write fixtures against:
{
"version": "1.0",
"uid": "ABC123",
"url": "https://davi.social/c/ABC123",
"status": "claimed",
"wallet_address": "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2",
"issuer": null,
"issuer_url": null,
"issuer_logo_url": null,
"owner_type": "person",
"owner_username": "alice",
"owner_name": "Alice Doe",
"owner_given_name": "Alice",
"owner_family_name": "Doe",
"owner_avatar_url": "https://cdn.davi.social/avatars/alice.png",
"owner_avatar_alt": "Alice Doe",
"owner_bio": "Full-stack developer",
"owner_job_title": "Software Engineer",
"owner_company": "Acme Corp",
"owner_profile_url": "https://davi.social/alice",
"owner_links": [
{
"label": "LinkedIn",
"url": "https://linkedin.com/in/alice",
"rel": "social",
"verified": false
}
],
"owner_vcard_url": "https://davi.social/alice/vcf",
"updated_at": "2026-07-26T10:00:00Z",
"proof": null
}
The card
| Field | Meaning |
|---|---|
| version | Required. MAJOR.MINOR, matching ^1\.[0-9]+$. |
| uid | The card's public identifier. |
| url | The card's page. |
| status | claimed, unclaimed, or frozen. |
| wallet_address | The bound ledger account, if any. |
| updated_at | Advisory content timestamp. |
The issuer
issuer, issuer_url, issuer_logo_url — who issued the card.
The holder
| Field | Meaning |
|---|---|
| owner_type | person or organization. |
| owner_username | Resolves to their profile. |
| owner_name | The display name — the only name field that applies to every holder type, and what you should render. |
| owner_given_name, owner_family_name | Refine it for people; absent for organizations. |
| owner_avatar_url, owner_avatar_alt | Avatar and its alt text. |
| owner_bio, owner_job_title, owner_company | Profile details. |
| owner_profile_url | Their profile page. |
| owner_vcard_url | A text/vcard representation of the same identity. |
| owner_links | Public links (see below). |
Name fields are named for role, not position — matching OIDC claims, vCard N
components and JSContact name component kinds, because "first" and "last" encode
an ordering that isn't universal. The split is stored data, not derived: a vCard
serializer without it has to guess by splitting on whitespace, which mangles
mononyms and multiple surnames.
owner_links
An array of objects, because the data genuinely repeats:
{ "label": "Site", "url": "https://ada.example", "rel": "custom", "verified": false }
url is required; label and rel (social or custom) are optional.
owner_links never contains an email address or a phone number.
What Davi emits today
The field list above is the format's, not Davi's. Several fields are structurally present but constant in the documents Davi serves, so a client written against Davi alone never exercises them — and a client that hardcodes their current values breaks on the first host that doesn't:
| Field | In Davi's documents |
|---|---|
| owner_type | Always "person". Davi doesn't issue org-held cards. |
| owner_links[].rel | Always null — link provenance isn't recorded, so nothing distinguishes a social profile from a custom URL. |
| updated_at | Always null — no content timestamp is tracked. |
| issuer_url | An identifier of the form https://davi.social/o/{slug}. It does not resolve today — there is no public route at /o/. Don't render it as a link. |
| proof, owner_links[].verified | Reserved — see below. |
Rules for consumers
These are normative — follow them and your client keeps working as the format grows:
- Ignore unknown fields. New fields are added in minor versions.
- Never fail on an unrecognized minor version. Reject only an unrecognized major.
- Absent and
nullmean different things.nullis "known to be empty"; absent is "this host doesn't implement it." - The only field you may require is
version. Everything else can legitimately be missing. - A document with no wallet is valid.
wallet_addressis absent for unclaimed inventory, or a host that doesn't model wallets. Whether that matters is your call — a rewards integration should decline, a contact importer never looks. - Don't depend on
updated_atfor correctness. It's advisory; caching is governed byETagandCache-Control. - Schema validation does not check string syntax. The schema annotates URL and
timestamp fields with JSON Schema
format, but neither reference implementation enables a format checker, so"owner_avatar_url": "not a url"validates. Parse a URL before you fetch it and a timestamp before you compare it — especially for a document from a host you don't run. - Host-specific fields are namespaced under a reversed domain
(
"social.davi.foo","com.example.bar") — including Davi's own. The flat namespace is the interoperable one. owner_*andissuer_*are reserved group prefixes. A new unprefixed field describes the card itself, not its holder or issuer.
Don't infer liveness from the fields
A frozen card still carries its holder's details — the freeze withholds the
card as a credential, not the identity. So check status rather than inferring
from the presence of owner_* fields:
import { isLiveCard } from "@davi/card-json";
isLiveCard(card); // true only when status === "claimed"
Reserved fields
Present in the schema, but not usable in 1.0:
| Field | Status |
|---|---|
| proof | Reserved for a Davi-issued attestation binding wallet_address to the host serving the document. Must be null or absent in 1.0. |
| owner_links[].verified | Reserved. Must be false in 1.0 — there is no verification mechanism, so do not render a verified badge on its basis. |
| owner_type: "organization" | Reserved rather than reachable — Davi doesn't issue org-held cards today. It exists so adding a holder kind later isn't a breaking change, and because a federated host may describe one. |
Using the package
@davi/card-json is the reference implementation — constants, parsing and
discovery, plus the normative schema itself.
import {
DAVI_CARD_ACCEPT,
DAVI_CARD_MEDIA_TYPE,
cardJsonSchema,
displayName,
isCardContentType,
parseCard,
} from "@davi/card-json";
const res = await fetch(url, { headers: { Accept: DAVI_CARD_ACCEPT } });
if (!isCardContentType(res.headers.get("content-type"))) { /* got HTML — discover */ }
const result = parseCard(await res.json());
if (result.ok) {
console.log(displayName(result.card)); // "Ada Lovelace"
} else {
console.warn(result.error.kind);
}
parseCard checks the protocol rules — the payload is an object, its version is
readable and its major is one you support — and returns { ok: true, card, version }
or { ok: false, error }, where error.kind is one of not_an_object,
missing_version, malformed_version, or unsupported_major. describeParseError
turns one into a readable sentence. Field shapes are a separate concern:
cardJsonSchema is the normative schema, to hand to the JSON Schema validator you
already use.
isCardContentType accepts the vendor type, application/json, and any +json
structured suffix — a host may serve the document without the vendor type. That
last rule is deliberately loose, so it will accept a response typed
application/ld+json; parse the body before trusting the header.
Other helpers: displayName (falls back from owner_name to the given/family
pair), isLiveCard, parseCardVersion, isSupportedCardVersion,
isHtmlContentType, essenceOf, and findCardJsonHref.
Porting to another language? The schema at schema/davi-card.v1.schema.json is
normative, and the package's test suite is written as the behaviour a port must
reproduce. Behaviour ports; API shape doesn't have to — Davi's own Python port
(backend/lib/cardjson) raises CardParseError instead of returning a result
object, and exposes card.display_name / card.is_live as properties. Match the
decisions, not the signatures.
Privacy
The document is public and unauthenticated, so it carries only what a holder has
chosen to publish. owner_links excludes email and phone by rule. A field earns a
place only if a consumer that isn't Davi can act on it — owner-facing state such as
a holder's private label for a card, or which card they consider primary, is
authored for a dashboard, not an audience, and publishing it would disclose the
shape of someone's card portfolio to anyone who taps one.
Every identifier in the document must be resolvable. Internal primary keys resolve to nothing for anyone but the issuing host and would leak a stable correlator that survives a username change, so they aren't published.
Documents from other hosts
A card document doesn't have to be Davi's. A person's card can live on their
domain, and Davi is one Davi-compatible host among many. This is why every field
except version may be absent: a host publishes what it has.
Davi already reads them. Given an arbitrary URL, it resolves a document in this order:
- If the URL has no path of its own —
https://ada.example— tryhttps://ada.example/.well-known/davi-cardfirst. A host that serves one subject for a whole domain can answer in one hop; an absent or unusable well-known isn't an error, it just falls through. With a path present this step is skipped, because a path means the caller is pointing at a particular subject and a domain-level document would answer about a different one. - Fetch the URL negotiating on
Accept. A card document comes back — done. - HTML comes back — find the
rel="alternate"link and fetch that.
A document from a host you don't run is schema-validated as well as parsed;
one Davi built from its own typed model isn't, because there's nothing to check.
Note that Davi does not currently serve a /.well-known/davi-card — its documents
live under /c/{uid}/json and are found by discovery.
wallet_address is a claim, not a credential
This is the constraint that matters, and it applies to your integration exactly as
it applies to Davi's. Nothing binds a wallet address to the host serving the
document. Any site can publish any address. So a wallet_address read from a
third-party document may be looked up — Davi will resolve it against its own wallet
records — but it must never on its own grant a balance, a reward, a membership, or
any other entitlement. The address identifies; it does not authorize.
The mechanism that would change that — proof, a Davi-issued attestation binding a
wallet to the host that published it — is reserved and unbuilt.
Until it exists, treat every field in a document from an untrusted host as
self-asserted.
If you fetch documents server-side
The URL is caller-supplied and the fetch leaves your infrastructure, so it's an SSRF
surface. Davi's resolver refuses anything but https, resolves the host and rejects
it unless every address is public (which covers cloud metadata endpoints like
169.254.169.254), caps the body at 512 KiB, the timeout at 5 s and redirects at 3 —
and applies the same host check again to the href discovery hands back, since a
discovered link can point anywhere, including inward. Reproduce all of that, or don't
fetch server-side.
Next
- Core concepts — how cards, wallets and profiles relate.
- Card templates — the artwork format.