Reading and resolving are free and unauthenticated. Writing requires a Registration Agency credential.
GET /id/{id} | Canonical record page (HTML) |
|---|---|
GET /id/{id}.json | Machine-readable record |
GET /id/{id}/history?since=0&limit=100 | Bounded append-only change history; maximum 500 |
GET /id/{id}/avatar | Avatar image, served by APR |
GET /p/{slug} | Human-friendly alias — 302 to the canonical page |
GET /changes?since={cursor}&limit=100 | Ordered change feed — the sync primitive; maximum 500 |
GET /healthz | Database and current-schema readiness |
curl https://artificialpeoples.com/id/3F7K9Q2MW6TN.json
Input is normalised before validation: hyphens and whitespace are stripped,
input is uppercased, and I/L map to 1 and
O to 0. A valid non-canonical form redirects (301) to
the canonical URL rather than rendering at two addresses.
Every record carries a subject_type:
persona (the default), voice,
visual_likeness, character, or work_set.
Omit it on registration and you get persona.
Active records carry a relations array, in both directions — from
a voice record you see what uses it, not only what it points at:
"relations": [
{
"direction": "incoming",
"type": "uses_voice",
"record_id": "TA765DBB9QFE",
"subject_type": "persona",
"display_name": "Night Choir",
"basis": "license",
"note": null,
"asserted_by": "ra:camplifier",
"created_at": "2026-08-02T17:00:00.000Z"
}
]
Assert one — you must be the agency of record for the source, and both records must be active:
curl -X POST https://artificialpeoples.com/personas/{id}/relations \
-H "Authorization: Bearer $APR_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "9Q2MW6TN3F7K", "type": "uses_voice", "basis": "license" }'
Retract with DELETE /personas/{id}/relations/{relationId}. The
retraction is soft — the row survives so the history stays legible.
uses_voice | → a voice record |
|---|---|
uses_likeness | → a visual_likeness record |
portrays_character | → a character record |
includes_work_set | → a work_set record |
derived_from | → any record |
succeeds | → a record of the same subject type |
A relation is an assertion by the registering agency. Recording that a persona uses a voice says an agency asserted it — not that a licence exists, is valid, or is in force.
The one thing on a record that is not self-declared. A verification proves a
specific narrow proposition — currently
domain_control_verified: someone able to publish DNS records or web
content for a named domain completed a challenge. It establishes nothing about
identity, authority over any persona, or ownership of any right, and every
result carries that limitation.
The flow is the ACME pattern:
« 1 » create a challenge (attach to a record with persona_id, or leave it
free-standing for a later registration to consume)
curl -X POST https://artificialpeoples.com/verifications \
-H "Authorization: Bearer $APR_KEY" -H "Content-Type: application/json" \
-d '{ "proposition": "domain_control_verified", "method": "dns_txt",
"subject": "performer.example.com", "persona_id": "3F7K9Q2MW6TN" }'
« 2 » publish what the instructions say — a TXT record at
_apr-challenge.performer.example.com (dns_txt), or the token as the body
of https://performer.example.com/.well-known/apr-challenge/{id}
(https_well_known; redirects are not followed)
« 3 » run the check — retryable while DNS propagates
curl -X POST https://artificialpeoples.com/verifications/{id}/check \
-H "Authorization: Bearer $APR_KEY"
verification_id is a decimal string, not a JSON number:
the underlying value is a 64-bit integer, and one past 2⁵³ would round in any
client that parses it as a number. Send it back as the string wherever it is
used — the {id} above, and verification_id on
POST /personas, which also still accepts the JSON number earlier
releases returned.
Verified results appear in the record's verifications array and
on its page, each naming its proposition, subject, method, stored verifier, dates, and
limitations. Pending challenges are never public. Results expire after a year —
control of a channel changes hands — and an expired verification is displayed as
expired, never silently dropped.
A record's own agency can declare that a third party's evidence bears on a
narrow, named proposition about this record — never a bare badge, and never
something APR has verified. Each item carries
legal_effect: no_determination, the proposition it bears on, and
fixed limitations text stating what it does not establish:
curl -X POST https://artificialpeoples.com/personas/{id}/evidence \
-H "Authorization: Bearer $APR_KEY" -H "Content-Type: application/json" \
-d '{ "evidence_kind": "registry_crosswalk",
"proposition": "same_subject_as_external_id",
"asserted_by": "ISNI Registry",
"locator": "https://isni.org/isni/0000000123456789" }'
The locator is published immediately — in the
record's JSON and on its page, cached and crawled — and so is
asserted_by, and so is content_hash. Put nothing
personal in any of them: an identifier, a registry entry, a manifest URL, a
platform's own record id; never a name, an email address, a document about a
person, or anything that identifies someone. Hash only content that is itself
public and high-entropy — a hash of a low-entropy identifier or of a personal
document is a correlation value, not an anonymisation. APR never fetches or checks the locator and cannot detect personal
data in it; the declaring agency is responsible for what it puts there.
Both are nulled on erasure, but a cached copy is not.
Withdraw with DELETE /personas/{id}/evidence/{evidenceId} — soft,
via a new withdrawn row, so the history stays legible. Declaring evidence with
supersedes_id set to an existing active item's evidence_id
replaces it, rather than adding a second independent claim.
| evidence_kind → proposition | meaning |
|---|---|
c2pa_manifest_reference → c2pa_manifest_references_record | the asserting party declares that a signed C2PA manifest at the locator references this APR ID (docs/C2PA-BINDING-PROFILE.md §2.1) |
platform_classification → platform_classification_declared | the asserting party declares that a named platform classified this subject under its own AI-disclosure policy |
registry_crosswalk → same_subject_as_external_id | the asserting party declares that this record and an external identifier (ISNI, MBID, QID, …) denote the same subject (docs/REGISTRY-CROSSWALK.md §7) |
Never a badge. Each declared item shows its kind, who is said to assert it, its locator, and its limitations together — the same "never a bare badge" posture control verification uses. APR has not evaluated whether the underlying evidence is true.
Registering a natural_voice or natural_likeness
record requires consuming a completed, unattached verification and
carrying an attestation, both in the same request:
curl -X POST https://artificialpeoples.com/personas \
-H "Authorization: Bearer $APR_KEY" -H "Content-Type: application/json" \
-d '{ "slug": "a-performers-voice", "subject_type": "natural_voice",
"display_name": "A Performer’s Voice", "persona_type": "voice",
"ai_disclosure": "natural", "verification_id": "42",
"bio": "No synthetic derivation without written consent.",
"controller": { "type": "individual", "email": "person@example.com" },
"attestation": { "authority_basis": "subject_consent",
"evidence_classes": ["signed_consent"],
"statement_version": "0.1" } }'
The attestation is the registering agency's own declaration of what it
holds, not evidence APR reviews or stores: authority_basis names
why the agency may register this subject, and evidence_classes
names what class of supporting material the agency holds for that basis — a
signed consent document, a licence, a letter of authority, and so on (and, for
these two subject types, none_held cannot be one of them — the
attestation must name the class it actually holds). APR
stores the declaration; it never stores the evidence, an identity document, or
a consent form itself. An optional evidence_reference and
attester_reference are opaque internal pointers the agency alone
can resolve in its own records — an internal ticket number or a hash for
evidence_reference, an internal operator code for
attester_reference — never a direct identifier: a name, an email
address, a phone number, a government identifier, a document URL, or the
evidence itself. An internal code that identifies someone only within the
agency's own records is exactly what these fields are for; it identifies no
one to APR or to anyone outside the agency. own_creation ("the controller created the
persona; no identifiable natural person's voice or likeness is used or
imitated") cannot be used here — it contradicts a record that is, by
definition, about an identifiable natural person — and is refused with
422 invalid_attestation. own_creation is also refused, on any
subject, wherever ai_disclosure is or becomes
digital_replica (on POST /personas, or on a
PATCH that would change ai_disclosure against an
existing own_creation attestation) — the two directly contradict each other;
human_fronted and hybrid remain permitted alongside
own_creation. evidence_classes: ["none_held"] is refused the same
way, on any subject, wherever ai_disclosure is or becomes
digital_replica — a digital replica is the voice or likeness of
an identifiable natural person, so an attestation naming no evidence class
contradicts the basis it claims.
statement_version must equal the current
version — an explicit acknowledgement that the submitting agency has read what
it is citing. A missing or outdated value is refused with
422 invalid_attestation, naming the current version. The text
itself is uncounseled draft legal text, so — until counsel has reviewed it —
it is not published here; an authenticated agency reads it at
GET /attestation-statement (send your Bearer credential, same as
GET /personas/lookup).
Three rules, enforced structurally: no biometric payload ever (these records
may not carry an avatar_url at all); the verification must be the
caller's own, completed, unexpired, and not already consumed; and the payload is
consent posture, not identification. A synthetic voice built from the person's
work links back with a derived_from relation, so the derivation
chain is visible from the performer's own record. Domain control proves only
that the registrant controls the challenged domain; it does not verify identity,
consent, or authority over the person named.
Each JSON response carries a legal_effect code and the
disclaimer text. Branch on status and
legal_effect, not on the presence of a field — a non-active
record returns a reduced representation.
registry_record | Active record: identifier plus submitter-supplied assertions |
|---|---|
identifier_pointer_only | Merged. Carries deprecated_in_favor_of; no profile |
tombstone_only | Withdrawn. Carries the date and a neutral reason category; no profile |
identifier_reserved; personal fields removed | Redacted at a person's request; no profile |
An issued ID always resolves. The only case a well-formed ID returns 404 is one that was never issued.
Responses carry ETag and Last-Modified. Send
If-None-Match and you will get a 304 when nothing has
changed. Treat the ETag as opaque. It is a cache validator, not
a version: it includes a deployment-scoped representation revision and covers
state that is not an event — verifications lapse on
the clock, so a representation can change while record_version
stands still. For PATCH concurrency read the record_version field
from the JSON body.
Poll the change feed rather than re-fetching records:
curl "https://artificialpeoples.com/changes?since=0&limit=100"
{
"events": [{
"cursor": "14",
"event_id": "12",
"persona_id": "3F7K9Q2MW6TN",
"event_type": "updated",
"actor": "ra:camplifier",
"created_at": "2026-08-02T17:00:00.000Z"
}],
"next_cursor": "14"
}
event_id is the permanent event identity and the deduplication
key. cursor is an opaque decimal delivery position; send the returned
next_cursor back as the next exclusive since only after
the page is durable. Do not convert either value through a JavaScript
number. The per-record history endpoint uses the same cursor and
also returns has_more.
During publication-cursor activation, every consumer must
replay from 0 (or another independently proven-safe checkpoint),
deduplicate by event_id, refetch affected records, and atomically
persist the final next_cursor. Do not continue a legacy checkpoint
unchanged in the new mode.
Records get corrected, merged, withdrawn, and redacted. A stale copy of a
record that was withdrawn for impersonation, or redacted at someone's request,
actively causes the harm the change was meant to stop. If you cache display
fields, refresh within 24 hours and propagate deprecated,
withdrawn, and redacted states promptly.
A badge is a pointer, not a mirror. Always link to the canonical resolver URL.
Bearer credential, issued under a signed agreement.
Operating your own persona and have no Registration Agency? Direct applications are not open yet; when the operator opens them, this page will link to the application form. That path holds no evidence: a persona that involves a real person's voice, likeness or performance is registered through a Registration Agency that holds the authority or evidence for it; this path holds none.
POST /personas | Register a persona; returns a permanent ID |
|---|---|
PATCH /personas/{id} | Update mutable public fields of your own records |
GET /personas/lookup | Find your own record by external_ref or slug |
POST /personas/{id}/relations | Assert a relation to another record |
DELETE /personas/{id}/relations/{relationId} | Retract one |
POST /verifications | Create a control-verification challenge |
POST /verifications/{id}/check | Run the check (retryable while pending) |
DELETE /verifications/{id} | Revoke a challenge or verification |
POST /personas/{id}/evidence | Declare third-party evidence bearing on a record |
DELETE /personas/{id}/evidence/{evidenceId} | Withdraw one |
ai_disclosure and persona_type are both closed sets, validated on
POST/PATCH (422 invalid_ai_disclosure /
invalid_persona_type — the schema only checks shape, so an unknown value is a
semantic error like invalid_slug, not a generic 400). The response body
carries accepted, the value list, alongside meanings. Every
ai_disclosure value is the submitter's own declaration — APR has not verified
any of them.
| ai_disclosure | meaning |
|---|---|
fully_synthetic | The submitter declares that voice, likeness and performances are generated, and no identifiable natural person's voice or likeness is used or imitated. |
digital_replica | The submitter declares that the voice or likeness is a synthetic replica of an identifiable natural person, used on a basis the submitter records in its attestation (the person may appear as a natural_voice/natural_likeness subject). |
human_fronted | The submitter declares that an identifiable natural person performs or presents as the persona, with generated material behind it. |
hybrid | The submitter declares that this record is a mix not captured by the other values, described in the bio. |
natural | The submitter declares that the record describes an identifiable natural person's own voice or likeness and nothing about it is synthetic — the value for natural_voice / natural_likeness subject records, whose existence is what lets a digital_replica point at its source. |
| persona_type | meaning |
|---|---|
musician | A musician. |
vocalist | A vocalist or singer. |
band_or_group | A band or performing group. |
dj_or_producer | A DJ or music producer. |
visual_artist | A visual artist. |
writer | A writer or author. |
actor_or_performer | An actor or performer. |
presenter_or_host | A presenter or host. |
character | A named character — the kind of thing registered for `character` subject records. |
companion_or_assistant | A conversational companion or assistant. |
creator_or_influencer | A content creator or influencer. |
brand_mascot | A brand mascot or spokesperson character. |
voice | A voice — the kind of thing registered for `voice` and `natural_voice` subject records. |
visual_likeness | A visual likeness — the kind of thing registered for `visual_likeness` and `natural_likeness` subject records. |
work_set | An identified body of works — the kind of thing registered for `work_set` subject records. |
other | A persona type not covered by the other values. |
curl -X POST https://artificialpeoples.com/personas \
-H "Authorization: Bearer $APR_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "night-choir",
"subject_type": "persona",
"display_name": "Night Choir",
"persona_type": "musician",
"ai_disclosure": "fully_synthetic",
"bio": "A synthetic choral ensemble.",
"links": [{ "type": "official_site", "url": "https://example.com" }],
"external_ref": "your-own-stable-id",
"controller": {
"type": "organization",
"display_name": "Sable Labs",
"email": "ops@example.com"
}
}'
controller.email and controller.legal_name are
private: they are stored separately from the public record and never appear in
any response.
external_ref is your own retry key for the registration —
private to you, returned only on authenticated responses. It must be
opaque: a random or surrogate key, never an email address, a name, a username,
or anything else about the subject.
This is the one submitted field an erasure deliberately keeps. Clearing it
would make /personas/lookup miss on an erased record and invite
your own retry to register that person a second time — so retention protects
the subject, but only while the value says nothing about them. A UUID is the
right shape.
Registration issues a permanent identifier, so a blind retry is
dangerous. Send external_ref on every registration. If a retry
comes back 409 slug_taken or 409 external_ref_taken,
call GET /personas/lookup?external_ref=… — it answers only for your
own records — and confirm the existing record is your registration before
adopting it.
Send If-Match: <record_version> as a plain integer to make
an update a compare-and-set. On mismatch nothing is applied and you get
409 version_conflict with current_version. Without the
header the update applies unconditionally.
Read record_version from the response body — it
is returned on the 201, on /personas/lookup, and on
/id/{id}.json. Never derive it from an ETag.
The two are deliberately different things. If-Match on PATCH
takes a bare integer; an ETag is a quoted, opaque validator that
also covers state which is not an event — verifications lapse with the clock, so
the representation changes while record_version stands still.
Stripping the quotes gives you something like 7.0.deployment, which
If-Match rejects with 400 bad_if_match.
Merges, withdrawals, and redactions are registry operations, not agency ones, and verification state is never agency-writable. Request them through the correction and challenge process.
401 unauthorized | Missing or unknown credential |
|---|---|
403 not_agency_of_record | The record was registered by another agency |
409 slug_taken / external_ref_taken | Retry conflict — use lookup |
409 version_conflict | If-Match did not match |
409 record_not_active / record_redacted | Not updatable in its current state |
422 invalid_slug / invalid_url | Failed a semantic rule; detail names it |
422 invalid_relation / invalid_target | Relation points at the wrong subject type, or at itself |
409 relation_exists / target_not_active | Edge already asserted, or the other end is not active |
422 invalid_subject / verification_required / verification_not_usable / avatar_not_permitted | Verification rules; detail names the specific problem |
409 too_many_pending / challenge_expired | Complete or revoke existing challenges; expired ones cannot be revived |
429 | Rate limited — back off |
400 bad_cursor / bad_limit / bad_if_match | Expected a bounded decimal value; limits are 1–500 |
500 internal_error | Unexpected failure; implementation and database details are never returned |
The contract is append-only: fields and error codes may be
added, none is repurposed or removed. Treat unknown fields as forward
compatibility. Every route, field, header, and error code on this page is
described machine-readably at GET /openapi.json (OpenAPI 3.1) — generated from, and tested against, the live route table.