Skip to content

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.

  • A profile is keyed on a canonical rid — a researcher’s ORCID iD, or a minted local: 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 push scope: 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.

Your app authenticates with a bearer token on every request:

Authorization: Bearer rpk_live_xxxxxxxxxxxxxxxx

An 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:

ScopeLets your app
readResolve a viewer tier for reads (reads themselves are not scope-gated; see below)
matchCall /match and per-profile /search
personaCall the generative endpoints (ask, review, innovate, riff)
pushCreate 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).

  • 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.)

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 read reads 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 404 too, just like a missing one.
  • The internal .cache/ and .keys/ prefixes return 403 for 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 at public with a copy of the profile rewritten for you, so this says public even 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:

Terminal window
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 URLs
curl -H "Authorization: Bearer $RPK" \
https://prosopia.databio.org/api/v1/profiles/jane-doe # the profile record

Both 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).

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:

  1. What the public sees is the floor for every user of your app, on every claimed profile.

  2. 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."}'

    role is reader (with parts or a preset: basics, extended, or read_all) or editor. It returns 201 with the request and its id; a second request while one is pending is a 409 (pending_request_exists), and a profile your app cannot see is a 404. Your key can withdraw it with POST /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).

  3. 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.

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:

Terminal window
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:

Terminal window
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 rid in the document and no opt-in → 400 (“supply an ORCID rid, or pass mintLocalRid=true / ?mint=local”).
  • Opt-in with an empty name → 400.
  • A rid already 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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
curl -H "Authorization: Bearer $RPK" \
https://prosopia.databio.org/api/v1/profiles/jane-doe/content/expertise

Match across profiles (scope match) — semantic ranking, the core brokerage read:

Terminal window
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.

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:

Terminal window
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" } } ] }
  • access is YOUR app’s own access to that profile. role is owner, co-owner, editor, reader or null. parts lists the parts you may read. level is full (every part), partial (only the listed parts) or public (only what the open web sees; parts is empty). It always matches what a read of that profile returns.
  • notes is 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.
  • affiliation is filled only when you could read it on the profile itself; otherwise it is null.
  • is_self is true when 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>.

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:

Terminal window
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/json selects 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) or cache/ (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:

Terminal window
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.

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.

StatusMeaning
400Bad request — invalid slug, missing/invalid rid or mint opt-in, empty mint name
401Missing, malformed, unknown, or revoked key
403Lacks the required scope; or a hard-floor artifact (.cache/, .keys/); or a person session on an app-only route
404No such profile, or a part or artifact you may not read (the two are indistinguishable)
409If-Match conflict (see X-RP-Content-Hash), a write conflict, or an access request already pending
422The pushed JSON document failed validation
503/match unavailable — no embeddings support, or no profile is indexed yet

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.

  • 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.