Integrate your app
Prosopia is a site-to-site broker for researcher profiles. One canonical profile per researcher lives here, owned by that person; your application reads it, ranks against it, and contributes to it through a single API key — without ever holding the researcher’s login. This guide is for the developer wiring another service (a lab portal, a matcher, an authoring tool) into Prosopia.
It answers, in order: how your app authenticates, what it is allowed to see, how your users reach their own access, how you control identity, and how you pull and push information. For the per-endpoint contract, see the API reference; for the ideas behind access and visibility, see Core concepts.
Base URLs: https://prosopia.databio.org (public; profiles.databio.org is
the same app). Inside the lab cluster, services reach it at
http://prosopia:8200.
The mental model
Section titled “The mental model”- A profile is keyed on a canonical
rid— a researcher’s ORCID iD, or a mintedlocal:id for someone with no ORCID. - A person owns their profile through an ORCID login. Your app never performs that login and never becomes an owner.
- Your app is an app principal: it authenticates with an app key that carries scopes (what operations it may perform). How much of a profile it may see is decided by that profile’s owner, through your app’s entry on the profile’s access list. A profile nobody has claimed yet is owned by the platform account, which shares it with the apps the operator names.
- You contribute by writing, and writing needs the
pushscope: either a canonical document you are the system of record for, or edits to a profile whose owner made your app an editor. Your app’s access says which profiles it reaches; your key’s scopes say what it may do there.
Everything below is a consequence of these four facts.
Get a key and authenticate
Section titled “Get a key and authenticate”Your app authenticates with a bearer token on every request:
Authorization: Bearer rpk_live_xxxxxxxxxxxxxxxxAn operator mints the key for your app from the admin panel
(POST /api/v1/admin/apps/{id}/keys); the plaintext is shown once, and only
its hash is stored. Ask the operator for a key with the scopes your
integration needs. If you need to read unclaimed profiles, ask the operator
to have the platform account share them with your app.
Two key kinds — use the app key. App keys are prefixed rpk_ and carry
app scopes. Personal API keys (rpa_) are a different mechanism: a person
makes one on their Privacy page and hands it to a narrow tool. It acts for
their whole account, and the person picks, part by part, what it may read and
write. A site-to-site integration uses an rpk_ app key. An rpa_ key holds
only read (plus push_own when its “Replace whole profiles” switch is on),
so it is refused with 403 at any route that needs another scope.
The difference that matters is reach. An app key reaches every profile, and each owner decides, profile by profile, how much of theirs it reads. A personal API key reaches only what its person reaches (their profiles, the profiles shared with them, and the collections they can see), and is worth an anonymous request everywhere else. If your application serves many researchers and you hold one credential for all of them, you want an app key. If each of your users brings their own profile (pastes in a token and an id), you want them to bring you a personal API key, and you never hold a credential that spans them.
Scopes — request only what you use:
| Scope | Lets your app |
|---|---|
read | Resolve a viewer tier for reads (reads themselves are not scope-gated; see below) |
match | Call /match and per-profile /search |
persona | Call the generative endpoints (ask, review, innovate, riff) |
push | Create or replace canonical profiles, and download archives |
A key says nothing about how much of a profile it reads: that is always the sharing on each profile (below).
What can go wrong
Section titled “What can go wrong”401— missing, malformed, unknown, or revoked key.403— the key lacks the scope for this route. The body is{"detail": "app '<name>' lacks scope '<scope>'"}.
(The richer {error, required, missing, hint} body you may see elsewhere
belongs to a write, when an app or key lacks Write on a part it tried to
change, not to app-scope calls.)
Know what you can retrieve
Section titled “Know what you can retrieve”Reads are not gated by a scope. Instead, every read is projected, part by part, on every request, to what your app may see of that profile:
- A claimed profile: your app’s entry on the profile’s access list. As a reader it reads the parts the owner picked (Summary, CV, Grants, …); as an editor or co-owner it reads the whole profile. Your app never reads less than the public. With no entry, it reads exactly what the public reads, which is nothing if the profile is unpublished.
- An unclaimed profile: the same rule. Its owner is the platform account, which shares it with the apps the operator names (today HydraPub, every part not private by default, and Sponsus, everything). None of these is published.
- Through a team: a team admin can add your app to a team. Your app then
reaches what the team reaches: the profiles the team owns, the profiles
shared with the team, and the team’s lists, including people added later.
Your key’s scopes decide what it may do there: a key with only
readreads and never writes.
What you may not see behaves like this:
- A part or artifact you may not read is returned as
404, indistinguishable from one that does not exist. You cannot probe for what you may not see. - A profile you may read nothing of is a
404too, just like a missing one. - The internal
.cache/and.keys/prefixes return403for everyone. No key, grant, or operator token lifts this.
Read the tier back from the response headers:
X-RP-Viewer-Tier— the tier your request resolved to. Present on every response, including refusals. When the owner lets your app read some parts but not all, you are served atpublicwith a copy of the profile rewritten for you, so this sayspubliceven though you read more. The declarations in that copy describe what YOUR app may read, not what the open web sees; do not republish them as the public view.X-RP-Effective-Tier— on a single-artifact read, the tier that artifact was projected to.X-RP-Archive-Tier,X-RP-Archive-Digest,X-RP-Profile-Level— on the push-scoped archive download.
Discover the catalog — three read routes tell you what exists and what you may fetch:
curl -H "Authorization: Bearer $RPK" \ https://prosopia.databio.org/api/v1/profiles # listing (only profiles you can see)curl -H "Authorization: Bearer $RPK" \ https://prosopia.databio.org/api/v1/collection.json # bundle of cards with absolute base URLscurl -H "Authorization: Bearer $RPK" \ https://prosopia.databio.org/api/v1/profiles/jane-doe # the profile recordBoth listings are private, no-store for every caller: who is in them depends
on who asked, so no shared cache may keep one.
The record is your summary of one profile. It is small (under 8 KB): long
lists are cut to their top entries with a total, and it carries sizes, not
long text:
{ "slug": "jane-doe", "rid": "0000-0002-1825-0097", "content_hash": "sha256:…", "view": "record", "fields": { "name": "…", "summary": "…", "research_interests": {"top": ["…"], "total": 19} }, "parts": { "soul": {"available": true, "bytes": 7081, "approx_tokens": 1770}, "expertise": {"available": true, "bytes": 6936, "approx_tokens": 1734}, "papers": {"available": true, "bytes": 32235, "approx_tokens": 8058}, "files_withheld": {"paper_fulltext": 53} }, "withheld": ["methodological_commitments"]}A field you may not see is null and named in withheld. Ask for
?view=full to get every field untrimmed plus the soul and expertise text.
The list of files (with each file’s tier) is its own route,
GET /api/v1/profiles/jane-doe/files; its withheld names the files you may
not read. Papers are paged rows (GET .../papers, then next_cursor), and a
paper’s summaries come in batches (GET .../summaries?ids=a,b). content_hash
is the value you use for safe writes (see Push information).
Let your users reach their own access
Section titled “Let your users reach their own access”Your app authenticates as itself, not as each of your users. There is no way to mint a per-user session from your key, and that is deliberate — Prosopia never lets a broker impersonate a person. So “what your users can access” resolves through three mechanisms, not a proxied login:
-
What the public sees is the floor for every user of your app, on every claimed profile.
-
Owners choose what your app gets, profile by profile. On a profile’s Access tab, its owner (or a co-owner) adds your app as a reader (with the parts it may read), an editor, or a co-owner. This is how an owner opts your app into the deeper parts of their profile. Your app can ask: its own
rpk_key asks for itself, and the owner approves or denies the request on the Access tab.Terminal window curl -H "Authorization: Bearer $RPK" \-H "Content-Type: application/json" \-X POST https://prosopia.databio.org/api/v1/manage/profiles/jane-doe/access-requests \-d '{"role": "reader", "preset": "extended", "message": "To match you to funding calls."}'roleisreader(withpartsor apreset:basics,extended, orread_all) oreditor. It returns201with the request and itsid; a second request while one is pending is a409(pending_request_exists), and a profile your app cannot see is a404. Your key can withdraw it withPOST /api/v1/account/access-requests/{id}/withdraw. A person in a team can also ask on your app’s behalf. A team admin can also add your app to the team: it then reaches what the team reaches, its lists included (see Read collections shared with your app). -
Person-links map your users to profiles (next section), so your app can answer “which canonical profile is my user #4821?” without asking the user.
If a flow genuinely needs a person to act as themselves — to claim a profile,
publish it, or make an owner-only edit — your site sends that person to
Prosopia’s ORCID login (/auth/orcid) and lets them act in their own
session. Your key cannot and should not stand in for them.
Control identity
Section titled “Control identity”The join key between your world and Prosopia’s is the rid. Get it right and
everything else lines up.
Resolve before you create. Before onboarding a researcher, ask whether a canonical profile already exists:
curl "https://prosopia.databio.org/api/v1/identity/resolve?orcid=0000-0002-1825-0097"{ "slug": "jane-doe", "rid": "0000-0002-1825-0097", "claimed": true }404 means the identity is not visible to you — anonymous callers see
only published, publicly visible profiles, so an unpublished profile 404s
here too. Do NOT treat a 404 as proof the person is absent or as a green
light to onboard them; that decision belongs to the authoritative resolver,
POST /api/v1/identity/resolve (send your app key with the resolve
scope), which matches or mints exactly once. To see unpublished profiles on
the GET, have the profile shared with your app: by its owner on its Access
tab, by sharing it with a team your app is on, or, for unclaimed profiles, by the operator through the platform
account’s sharing. claimed tells you
whether a person has taken ownership; owner_orcid appears only for the
profile’s owner, its co-owners and editors, and the operator. You can resolve by rid=
(ORCID or local:) or orcid=.
Do not invent local: ids yourself. For a researcher with no ORCID, let
Prosopia mint the id at push time by opting in — either a JSON field or a query
flag on the push:
curl -H "Authorization: Bearer $RPK" \ -H "Content-Type: application/json" \ -X PUT https://prosopia.databio.org/api/v1/profiles/rivka-cohen \ -d '{ "name": "Rivka Cohen", "mintLocalRid": true, "...": "..." }'Prosopia returns the minted rid (form local:<slug>-<hex6>) in the response;
record it as your permanent join key. The rules are strict, so minting is never
accidental:
- No
ridin the document and no opt-in →400(“supply an ORCID rid, or passmintLocalRid=true/?mint=local”). - Opt-in with an empty
name→400. - A
ridalready present and the mint flag set →400(no silent overwrite).
A local:-rid profile stays unclaimed — its rid is not an ORCID, so no ORCID
login self-claims it. It is an identity record, not a published web profile:
only apps the platform account shares unclaimed profiles with can read it.
Remember the mapping with person-links. Bind your own external id to the canonical profile so you never re-resolve:
curl -H "Authorization: Bearer $RPK" \ -H "Content-Type: application/json" \ -X POST https://prosopia.databio.org/api/v1/manage/apps/your-app/person-links \ -d '{ "external_id": "4821", "rid": "0000-0002-1825-0097" }'Look one up with the external id in the path:
curl -H "Authorization: Bearer $RPK" \ https://prosopia.databio.org/api/v1/manage/apps/your-app/person-links/4821# -> { "app_id": "your-app", "external_id": "4821", "slug": "jane-doe", "rid": "…" }You must call these as the named app; another app’s key gets 403.
Linking follows the same visibility rule as the GET above: linking a profile
your key may not see returns 404, and a link whose profile later becomes
hidden from you (unpublished, or the owner stops letting your app read it)
returns 404 too.
Pull information
Section titled “Pull information”Read one profile or the whole catalog — the routes shown under
Know what you can retrieve. Fetch a single
artifact directly, and read X-RP-Effective-Tier to see the tier it came back
at:
curl -H "Authorization: Bearer $RPK" \ https://prosopia.databio.org/api/v1/profiles/jane-doe/content/expertiseMatch across profiles (scope match) — semantic ranking, the core
brokerage read:
curl -H "Authorization: Bearer $RPK" \ -H "Content-Type: application/json" \ -X POST https://prosopia.databio.org/api/v1/match \ -d '{ "query": "single-cell chromatin accessibility", "k": 5 }'{ "matches": [ { "slug": "jane-doe", "name": "Jane Doe", "rid": "0000-0002-1825-0097", "orcid": "0000-0002-1825-0097", "score": 0.83, "evidence": { "centroid_score": 0.81, "top_papers": ["doe2024atlas"], "overlapping_topics": ["chromatin", "single-cell"], "top_chunks": [] } } ]}Request fields: query (required), k (default 5), prefilter (10),
require_topics (list, optional), diversify (true), lambda_ (0.5),
topk_chunks (5), normalize (true), include_chunks (false — set true to
populate evidence.top_chunks). Profiles you may not see are
dropped from the ranking, and evidence chunks come only from parts you may
read.
Matchability depends on an embedding index. A profile is rankable only once
it carries a built embedding index. A profile you created with a JSON push
(next section) has no index (indexed: false) and will not appear in /match
until it is enriched by a full-bundle push or a build. If the deployment has no
embeddings support installed, or no profile is indexed yet, /match returns
503.
Ask a profile (scope persona) — generative endpoints grounded in one
profile’s corpus: POST /api/v1/profiles/{slug}/ask, .../review,
.../innovate, .../riff. ask and review return grounded text with
citations; innovate and riff return structured idea lists. See the
API reference for each body.
Read collections shared with your app
Section titled “Read collections shared with your app”A collection is a named list of profiles that a person or a team keeps,
such as a department’s faculty. Your app sees a list when it is on a team
that owns the list or can see it: a team admin adds your app on the team’s
page. Each entry is served at your app’s own access to that profile, which
includes what the team reaches (the team’s own profiles whole, and what
people share with the team), the same as everywhere else. People added to the
list later follow with no extra step. Every route here needs a key with
the read scope (a signed-in person needs nothing more). Lists are small, so
nothing is paged.
List the collections you can see:
curl -H "Authorization: Bearer $RPK" \ https://prosopia.databio.org/api/v1/collections{ "collections": [ { "id": 3, "name": "Cancer Center", "role": "viewer", "entry_count": 212, "owner": { "kind": "team", "name": "Cancer Center curators" } } ] }role is owner, editor or viewer: your app’s role on the list.
List one collection’s entries with
GET /api/v1/collections/{id}/entries (add ?rid= to find one person):
{ "collection": { "id": 3, "name": "Cancer Center" }, "entries": [ { "profile_id": 123, "rid": "0000-0002-1825-0097", "name": "Jane Doe", "affiliation": "University of Virginia", "slug": "jane-doe", "is_self": false, "content_hash": "sha256:…", "notes": { "wanted_mechanisms": ["R01"], "citizenship": "US" }, "owner": { "principal_id": 45, "kind": "team", "name": "Cancer Center curators" }, "access": { "role": "reader", "parts": ["background", "expertise", "summary"], "level": "partial" } } ] }accessis YOUR app’s own access to that profile.roleisowner,co-owner,editor,readerornull.partslists the parts you may read.levelisfull(every part),partial(only the listed parts) orpublic(only what the open web sees;partsis empty). It always matches what a read of that profile returns.notesis the list’s own private content about that person, for example grant preferences. It is always an object ({}when empty), and everyone with a role on the list gets it.affiliationis filled only when you could read it on the profile itself; otherwise it isnull.is_selfistruewhen the entry is the person’s own profile. Otherwise it is usually a curated profile: one the list’s owner keeps about that person.
Read one entry with
GET /api/v1/collections/{id}/entries/{profile_id}?view=record|full. It
returns {"entry", "access", "profile"}, where profile is exactly the
profile record GET /api/v1/profiles/{slug} would return to you (see
Know what you can retrieve). A reader gets only
its parts, and the rest are null and named in withheld; an editor or
better gets the whole profile. view defaults to record; use view=full
for untrimmed lists plus the soul and expertise text. Read its papers
with GET /api/v1/collections/{id}/entries/{profile_id}/papers (limit,
cursor, then next_cursor), at the same level.
A 404 means the list or the entry does not exist, your app has no role on
the list, or you may read nothing of that profile. A curated profile is never
public, so at level public it is always a 404.
Never link to a curated profile’s slug. A curated profile is never
public, so its slug is not a web address anyone can open. Link people to the
entry page, https://prosopia.databio.org/collections/{id}/entries/{profile_id},
and open it in an MCP client as profile:#<profile_id> (a paper as
paper:#<profile_id>/<paper_id>). A person’s own profile keeps its slug and
profile:<slug>.
Push information
Section titled “Push information”There are two ways to contribute, and choosing correctly is the most important integration decision.
Upsert the canonical document (you are the source of record)
Section titled “Upsert the canonical document (you are the source of record)”Use this when your app authors the researcher’s profile — you hold the truth
about their identity and expertise. It is a create-or-replace on the canonical
document, scope push:
curl -H "Authorization: Bearer $RPK" \ -H "Content-Type: application/json" \ -X PUT https://prosopia.databio.org/api/v1/profiles/jane-doe \ -d '{ "rid": "0000-0002-1825-0097", "name": "Jane Doe", "...": "…the profile.jsonld document…" }'The body is a full profile document (the profile.jsonld shape). Success is
200 with:
{ "slug": "jane-doe", "rid": "0000-0002-1825-0097", "name": "Jane Doe", "level": "lite", "indexed": false }Two things to know:
Content-Type: application/jsonselects this path. Anything else — or a missing header — is treated as a gzipped-tarball bundle upload, the older path that carries papers and a prebuilt embedding index. Send the JSON content type explicitly.- JSON upsert writes the document only. It does not add or remove
sources/(papers) orcache/(embeddings) artifacts. So it establishes identity and expertise cheaply, but the profile is not matchable until a bundle push or a build adds an index. The division of labor is the point: your app owns identity, Prosopia owns enrichment.
Write safely with If-Match. To avoid clobbering a concurrent edit, send
the content_hash you last read:
curl -H "Authorization: Bearer $RPK" \ -H "Content-Type: application/json" \ -H "If-Match: sha256:abcd…" \ -X PUT https://prosopia.databio.org/api/v1/profiles/jane-doe \ -d '{ "...": "…" }'A stale hash returns 409 with the current value in the
X-RP-Content-Hash response header — re-read, merge, retry. Omitting If-Match
is last-writer-wins. On success, dateModified is stamped automatically, and
only when the content actually changed.
Edit a profile the owner shared
Section titled “Edit a profile the owner shared”Your app writes only with the push scope. An editor entry on a profile
(asked for as editor, see
Let your users reach their own access)
lets a key with push use the edit routes on it; a key without push reads
the profile and gets 403 on every write, whatever its entry or teams. (A
personal API key is different: it acts for its person and is held to its own
part-by-part list. Without push it may push only the parts it holds Write
on: send just the files you change, rp push --only <file> --mode merge, and
anything else is refused with 403.)
{ "detail": { "error": "insufficient_access", "required": ["paper_summary", "summary"], "missing": ["summary"], "needs_replace": [], "hint": "This needs Write on summary. ..." } }An owner who makes your app a co-owner lets it replace the profile outright, what the public sees included. A push this way never creates a profile.
Your push never changes a field the owner or a co-owner edited by hand. The
server keeps their value and names those fields in kept_owner_fields on the
push response.
A curated profile (one a team keeps about someone else) works the same way:
when its owner makes your app an editor, you push to it as above. A curated
profile never says who its person is, so a push that carries an identity
field (such as sameAs or identifier) is refused. An app cannot make a curated
profile or change a collection.
Errors at a glance
Section titled “Errors at a glance”| Status | Meaning |
|---|---|
400 | Bad request — invalid slug, missing/invalid rid or mint opt-in, empty mint name |
401 | Missing, malformed, unknown, or revoked key |
403 | Lacks the required scope; or a hard-floor artifact (.cache/, .keys/); or a person session on an app-only route |
404 | No such profile, or a part or artifact you may not read (the two are indistinguishable) |
409 | If-Match conflict (see X-RP-Content-Hash), a write conflict, or an access request already pending |
422 | The pushed JSON document failed validation |
503 | /match unavailable — no embeddings support, or no profile is indexed yet |
Sign your users in with Prosopia
Section titled “Sign your users in with Prosopia”Integrating reads and writes profiles. If you also want Prosopia to handle
your app’s sign-in (ORCID, Google and GitHub, one click for anyone already signed in
to Prosopia), it is a standard OpenID Connect provider at
https://prosopia.databio.org. You get a signed id_token saying who the
person is; no API key is involved. See
Sign people in to your app.
Related
Section titled “Related”- API reference — every endpoint, field, and header.
- Core concepts — identity, ownership, parts, and audiences.
- Share ownership — the owner’s side of granting your app access and answering its requests.